diff --git a/README.md b/README.md
index 2f75e00..d22af08 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,13 @@
# 笔临 (Bilin)
-长篇创作智能协作平台 — Electron 桌面客户端 v1.2.0(基础治理)
+长篇创作智能协作平台 — Electron 桌面客户端 v1.3.0(项目包)
+
+## 功能概览(v1.3.0)
+
+- `.novel` 单书项目包导出/导入(含数据库、元数据、附件)
+- `.novel-all` 批量导出/导入全部书籍与全局设置
+- 主页「导出所有书籍」、设置页备份区、导出模态项目包选项
+- 大文件导出确认与进度推送
## 功能概览(v1.2.0)
diff --git a/docs/superpowers/plans/2026-07-08-bilin-w2-project-pack.md b/docs/superpowers/plans/2026-07-08-bilin-w2-project-pack.md
index 2204a13..7363b5c 100644
--- a/docs/superpowers/plans/2026-07-08-bilin-w2-project-pack.md
+++ b/docs/superpowers/plans/2026-07-08-bilin-w2-project-pack.md
@@ -90,8 +90,8 @@
- Modify: `src/preload/index.ts`、`electron-api.d.ts`
- [x] **Step 1:** IPC 常量与 handler 注册
-- [ ] **Step 2:** preload `pack.exportBook/importBook/exportAll/importAll/pickFile`
-- [ ] **Step 3:** `onPackProgress` 事件订阅
+- [x] **Step 2:** preload `pack.exportBook/importBook/exportAll/importAll/pickFile`
+- [x] **Step 3:** `onPackProgress` 事件订阅
---
@@ -102,9 +102,9 @@
- Modify: `src/renderer/components/import/ImportBookModal.tsx`
- Modify: `public/locales/*/translation.json`
-- [ ] **Step 1:** ExportModal 增加「笔临项目包 (.novel)」
-- [ ] **Step 2:** ImportBookModal 文件过滤含 `.novel`/`.novel-all`
-- [ ] **Step 3:** `.novel` 直接导入;`.novel-all` 打开策略选择 + 进度条
+- [x] **Step 1:** ExportModal 增加「笔临项目包 (.novel)」
+- [x] **Step 2:** ImportBookModal 文件过滤含 `.novel`/`.novel-all`
+- [x] **Step 3:** `.novel` 直接导入;`.novel-all` 打开策略选择 + 进度条
---
@@ -115,8 +115,8 @@
- Modify: `src/renderer/components/home/HomePage.tsx`
- Modify: `src/renderer/stores/useAppStore.ts`(可选 modal 状态)
-- [ ] **Step 1:** `ExportAllModal` — 选路径、大小确认、进度、取消
-- [ ] **Step 2:** HomePage「导出所有书籍」启用并打开 modal
+- [x] **Step 1:** `ExportAllModal` — 选路径、大小确认、进度、取消
+- [x] **Step 2:** HomePage「导出所有书籍」启用并打开 modal
---
@@ -126,8 +126,8 @@
- Create: `src/renderer/components/settings/BackupSettingsTab.tsx`
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
-- [ ] **Step 1:** 导出当前书 / 导出全部按钮
-- [ ] **Step 2:** i18n `backup.*`
+- [x] **Step 1:** 导出当前书 / 导出全部按钮
+- [x] **Step 2:** i18n `backup.*`
---
@@ -139,11 +139,11 @@
- Modify: `e2e/helpers.ts`
- Modify: `package.json`、`README.md`、`SettingsPage.tsx`
-- [ ] **Step 1:** E2E 单书 .novel 导出再导入
-- [ ] **Step 2:** E2E-ALL-03 主页导出全部
-- [ ] **Step 3:** 全量 `npm run test` + 相关 E2E
-- [ ] **Step 4:** 版本 **v1.3.0**
-- [ ] **Step 5:** 勾选本 plan 全部 checkbox
+- [x] **Step 1:** E2E 单书 .novel 导出再导入
+- [x] **Step 2:** E2E-ALL-03 主页导出全部
+- [x] **Step 3:** 全量 `npm run test` + 相关 E2E
+- [x] **Step 4:** 版本 **v1.3.0**
+- [x] **Step 5:** 勾选本 plan 全部 checkbox
---
diff --git a/e2e/helpers.ts b/e2e/helpers.ts
index ab3632f..e56d619 100644
--- a/e2e/helpers.ts
+++ b/e2e/helpers.ts
@@ -62,6 +62,16 @@ export async function openExportModal(page: Page): Promise {
await expect(modal).toBeVisible({ timeout: 10_000 })
}
+export async function openExportAllModal(page: Page): Promise {
+ await page.getByTestId('home-export-all').click()
+ await expect(page.getByTestId('export-all-modal')).toBeVisible({ timeout: 10_000 })
+}
+
+export async function openImportBookModal(page: Page): Promise {
+ await page.getByTestId('home-import-book').click()
+ await expect(page.getByTestId('import-book-modal')).toBeVisible({ timeout: 10_000 })
+}
+
export async function openBookSettings(page: Page): Promise {
await page.getByTestId('panel-tab-book-settings').click()
await expect(page.getByTestId('book-settings-tab')).toBeVisible({ timeout: 10_000 })
diff --git a/e2e/pack-export-all.spec.ts b/e2e/pack-export-all.spec.ts
new file mode 100644
index 0000000..bca5aab
--- /dev/null
+++ b/e2e/pack-export-all.spec.ts
@@ -0,0 +1,49 @@
+import { mkdtempSync, rmSync, existsSync } from 'fs'
+import { join } from 'path'
+import { tmpdir } from 'os'
+import { test, expect } from '@playwright/test'
+import { launchApp, skipOnboarding, dismissCockpitIfOpen } from './helpers'
+
+test.describe('Pack export all', () => {
+ let userDataDir: string
+ let packAllPath: string
+
+ test.beforeEach(() => {
+ userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-pack-all-'))
+ packAllPath = join(userDataDir, 'backup.novel-all')
+ })
+
+ test.afterEach(() => {
+ if (userDataDir && existsSync(userDataDir)) {
+ try {
+ rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
+ } catch {
+ /* ignore */
+ }
+ }
+ })
+
+ test('E2E-ALL-03: home export all books', async () => {
+ const app = await launchApp(userDataDir, { BILIN_E2E_PACK_SAVE: packAllPath })
+ const page = await app.firstWindow()
+ await skipOnboarding(page)
+
+ await page.getByRole('button', { name: /新建书籍/ }).first().click()
+ await page.locator('.dialog-content input').first().fill('批量导出书A')
+ await page.getByRole('button', { name: '创建' }).click()
+ await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
+ await dismissCockpitIfOpen(page)
+
+ await page.getByRole('button', { name: /笔临|Bilin/i }).first().click()
+ await expect(page.locator('#home-page')).toBeVisible({ timeout: 10_000 })
+
+ await page.getByTestId('home-export-all').click()
+ await expect(page.getByTestId('export-all-modal')).toBeVisible()
+ await page.getByTestId('export-all-pick').click()
+ await expect(page.getByTestId('export-all-dest')).not.toHaveValue(/未选择|No destination/)
+ await page.getByTestId('export-all-start').click()
+ await expect(page.getByText(/已导出|exported/i)).toBeVisible({ timeout: 30_000 })
+
+ await app.close()
+ })
+})
diff --git a/e2e/pack-export-import.spec.ts b/e2e/pack-export-import.spec.ts
new file mode 100644
index 0000000..78a9581
--- /dev/null
+++ b/e2e/pack-export-import.spec.ts
@@ -0,0 +1,74 @@
+import { mkdtempSync, rmSync, existsSync } from 'fs'
+import { join } from 'path'
+import { tmpdir } from 'os'
+import { test, expect } from '@playwright/test'
+import {
+ launchApp,
+ skipOnboarding,
+ createBookAndOpenEditor,
+ ensureChapterEditor,
+ openExportModal,
+ dismissCockpitIfOpen
+} from './helpers'
+
+test.describe('Pack export/import', () => {
+ let userDataDir: string
+ let packPath: string
+
+ test.beforeEach(() => {
+ userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-pack-'))
+ packPath = join(userDataDir, 'roundtrip.novel')
+ })
+
+ test.afterEach(() => {
+ if (userDataDir && existsSync(userDataDir)) {
+ try {
+ rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
+ } catch {
+ /* ignore */
+ }
+ }
+ })
+
+ test('E2E-PACK-01: export .novel and re-import via UI', async () => {
+ const app = await launchApp(userDataDir, {
+ BILIN_E2E_PACK_SAVE: packPath,
+ BILIN_E2E_PACK_IMPORT_FILE: packPath
+ })
+ const page = await app.firstWindow()
+ await skipOnboarding(page)
+ await createBookAndOpenEditor(page, 'Pack Roundtrip')
+ await ensureChapterEditor(page)
+
+ const editor = page.locator('.ProseMirror')
+ await editor.click()
+ await editor.pressSequentially('项目包导出导入测试正文。')
+ await page.keyboard.press('Control+S')
+ await expect(page.getByText('已保存')).toBeVisible({ timeout: 10_000 })
+
+ await openExportModal(page)
+ await page.getByTestId('export-kind-select').selectOption('novel')
+ await page.getByTestId('export-novel-btn').click()
+ await expect(page.getByText(/已导出/)).toBeVisible({ timeout: 15_000 })
+
+ await page.evaluate(async (dest) => {
+ const list = await window.electronAPI.book.list()
+ if (!list.ok || !list.data?.length) throw new Error('no books')
+ await window.electronAPI.book.delete(list.data[0].id)
+ }, packPath)
+
+ await page.getByRole('button', { name: /笔临|Bilin/i }).first().click()
+ await expect(page.locator('#home-page')).toBeVisible({ timeout: 10_000 })
+
+ await page.getByTestId('home-import-book').click()
+ await expect(page.getByTestId('import-book-modal')).toBeVisible()
+ await page.getByTestId('import-pick-file').click()
+ await expect(page.getByTestId('import-pack-hint')).toBeVisible({ timeout: 10_000 })
+ await page.getByTestId('import-execute-btn').click()
+ await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 30_000 })
+ await dismissCockpitIfOpen(page)
+ await expect(page.locator('.chapter-item')).toHaveCount(1, { timeout: 15_000 })
+
+ await app.close()
+ })
+})
diff --git a/package.json b/package.json
index 9672955..eeaca60 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bilin",
- "version": "1.2.0",
+ "version": "1.3.0",
"description": "笔临 - 长篇创作智能协作平台",
"main": "./out/main/index.js",
"type": "module",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index cec8f59..bc37c9a 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -442,6 +442,43 @@
"export.preview": "Preview",
"export.loading": "Loading…",
"export.noPreview": "(no preview)",
+ "export.kind": "Export type",
+ "export.kindSubmission": "Submission format (current chapter)",
+ "export.kindNovel": "Bilin project pack (.novel)",
+ "export.novelDesc": "Export the full book database, metadata, and attachments. Restore via Import Book.",
+ "export.exportNovel": "Export project pack",
+ "import.novelHint": "Imports the entire book from a .novel pack (chapters, settings, knowledge).",
+ "import.novelAllHint": "Imports all books from a .novel-all pack. Choose a conflict strategy.",
+ "pack.exportAllTitle": "Export all books",
+ "pack.exportAllDesc": "Package all books and global settings into a .novel-all file.",
+ "pack.estimating": "Estimating size…",
+ "pack.estimatedSize": "Estimated size: {{size}}",
+ "pack.noDest": "No destination selected",
+ "pack.pickDest": "Choose location",
+ "pack.largeExportConfirm": "Export is large ({{size}}). Continue?",
+ "pack.exportAllStart": "Start export",
+ "pack.exportAllSuccess": "All books exported",
+ "pack.exportAllFailed": "Export failed",
+ "pack.exportBookSuccess": "\"{{name}}\" exported as project pack",
+ "pack.exportBookFailed": "Project pack export failed",
+ "pack.exporting": "Exporting…",
+ "pack.cancel": "Cancel",
+ "pack.progress": "Progress {{current}}/{{total}}",
+ "pack.importStrategy": "Import strategy",
+ "pack.strategyNew": "Import all as new books",
+ "pack.strategySkip": "Skip existing books",
+ "pack.strategyOverwrite": "Overwrite matching books",
+ "pack.importAllSuccess": "Import done: {{imported}} new, {{skipped}} skipped, {{overwritten}} overwritten",
+ "backup.desc": "Export books as Bilin project packs for backup, migration, or restore.",
+ "backup.exportCurrentBook": "Export current book",
+ "backup.exportBookBtn": "Export as .novel",
+ "backup.exportAllBooks": "Export all books",
+ "backup.exportAllBtn": "Open bulk export",
+ "backup.noBook": "Open a book in the editor first",
+ "backup.openBookHint": "Open a book in the editor to export a single project pack",
+ "backup.exportBookSuccess": "\"{{name}}\" exported",
+ "backup.exportBookFailed": "Export failed",
+ "backup.formatHint": "Bulk exports over 500 MB require extra confirmation.",
"submission.title": "Submission Presets",
"submission.titleFormat": "Title format",
"submission.indent": "Indent paragraphs",
diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json
index ad57341..c397087 100644
--- a/public/locales/zh-CN/translation.json
+++ b/public/locales/zh-CN/translation.json
@@ -442,6 +442,43 @@
"export.preview": "预览",
"export.loading": "加载中…",
"export.noPreview": "(无预览)",
+ "export.kind": "导出类型",
+ "export.kindSubmission": "投稿格式(当前章节)",
+ "export.kindNovel": "笔临项目包 (.novel)",
+ "export.novelDesc": "导出整本书的数据库、元数据与附件,可在其他设备或备份后通过「导入书籍」恢复。",
+ "export.exportNovel": "导出项目包",
+ "import.novelHint": "将导入 .novel 项目包中的整本书(含章节、设定与知识库)。",
+ "import.novelAllHint": "将批量导入 .novel-all 包中的所有书籍,请选择冲突处理策略。",
+ "pack.exportAllTitle": "导出所有书籍",
+ "pack.exportAllDesc": "将所有书籍与全局设置打包为 .novel-all 文件。",
+ "pack.estimating": "正在估算大小…",
+ "pack.estimatedSize": "预计大小:{{size}}",
+ "pack.noDest": "未选择保存路径",
+ "pack.pickDest": "选择保存位置",
+ "pack.largeExportConfirm": "导出体积较大({{size}}),确认继续?",
+ "pack.exportAllStart": "开始导出",
+ "pack.exportAllSuccess": "全部书籍已导出",
+ "pack.exportAllFailed": "导出失败",
+ "pack.exportBookSuccess": "「{{name}}」已导出为项目包",
+ "pack.exportBookFailed": "项目包导出失败",
+ "pack.exporting": "导出中…",
+ "pack.cancel": "取消",
+ "pack.progress": "进度 {{current}}/{{total}}",
+ "pack.importStrategy": "导入策略",
+ "pack.strategyNew": "全部作为新书导入",
+ "pack.strategySkip": "跳过已存在的书",
+ "pack.strategyOverwrite": "覆盖同名书籍",
+ "pack.importAllSuccess": "导入完成:新增 {{imported}},跳过 {{skipped}},覆盖 {{overwritten}}",
+ "backup.desc": "将书籍导出为笔临项目包,便于备份、迁移或在其他设备恢复。",
+ "backup.exportCurrentBook": "导出当前书籍",
+ "backup.exportBookBtn": "导出为 .novel",
+ "backup.exportAllBooks": "导出全部书籍",
+ "backup.exportAllBtn": "打开批量导出",
+ "backup.noBook": "请先在编辑器中打开一本书",
+ "backup.openBookHint": "在编辑器中打开书籍后可导出单书项目包",
+ "backup.exportBookSuccess": "「{{name}}」已导出",
+ "backup.exportBookFailed": "导出失败",
+ "backup.formatHint": "超过 500 MB 的批量导出需要额外确认。",
"submission.title": "投稿预设",
"submission.titleFormat": "标题格式",
"submission.indent": "段首空两格",
diff --git a/src/main/ipc/handlers/pack.handler.ts b/src/main/ipc/handlers/pack.handler.ts
index c63807b..3762758 100644
--- a/src/main/ipc/handlers/pack.handler.ts
+++ b/src/main/ipc/handlers/pack.handler.ts
@@ -58,8 +58,25 @@ export function registerPackHandlers(
})
)
- ipcMain.handle(IPC.PACK_PICK_FILE, (_e, { mode }: { mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' }) =>
+ ipcMain.handle(IPC.PACK_PICK_FILE, (_e, { mode }: { mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import' }) =>
wrap(async () => {
+ if (process.env.BILIN_E2E === '1') {
+ if (
+ (mode === 'save-novel' || mode === 'save-all') &&
+ process.env.BILIN_E2E_PACK_SAVE
+ ) {
+ return process.env.BILIN_E2E_PACK_SAVE
+ }
+ if (
+ (mode === 'import' || mode === 'novel' || mode === 'novel-all') &&
+ process.env.BILIN_E2E_PACK_IMPORT_FILE
+ ) {
+ return process.env.BILIN_E2E_PACK_IMPORT_FILE
+ }
+ if (mode === 'import' && process.env.BILIN_E2E_IMPORT_FILE) {
+ return process.env.BILIN_E2E_IMPORT_FILE
+ }
+ }
if (mode === 'save-novel' || mode === 'save-all') {
const ext = mode === 'save-all' ? 'novel-all' : 'novel'
const { canceled, filePath } = await dialog.showSaveDialog({
@@ -69,7 +86,12 @@ export function registerPackHandlers(
if (canceled || !filePath) return null
return filePath
}
- const extensions = mode === 'novel-all' ? ['novel-all'] : ['novel', 'novel-all']
+ const extensions =
+ mode === 'import'
+ ? ['txt', 'md', 'docx', 'novel', 'novel-all']
+ : mode === 'novel-all'
+ ? ['novel-all']
+ : ['novel', 'novel-all']
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Bilin Pack', extensions }]
diff --git a/src/preload/index.ts b/src/preload/index.ts
index b0c5a6d..a398de3 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -60,6 +60,11 @@ import type {
ImportExecuteParams,
ImportSplitMode
} from '../shared/types'
+import type {
+ PackImportStrategy,
+ PackImportReport,
+ PackProgressEvent
+} from '../shared/novel-pack'
const electronAPI = {
window: {
@@ -599,6 +604,25 @@ const electronAPI = {
saveTxt: (text: string, defaultName: string): Promise> =>
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
},
+ pack: {
+ pickFile: (
+ mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
+ ): Promise> => ipcRenderer.invoke(IPC.PACK_PICK_FILE, { mode }),
+ exportBook: (bookId: string, destPath: string): Promise> =>
+ ipcRenderer.invoke(IPC.PACK_EXPORT_BOOK, { bookId, destPath }),
+ importBook: (filePath: string): Promise> =>
+ ipcRenderer.invoke(IPC.PACK_IMPORT_BOOK, { filePath }),
+ exportAll: (destPath: string): Promise> =>
+ ipcRenderer.invoke(IPC.PACK_EXPORT_ALL, { destPath }),
+ importAll: (
+ filePath: string,
+ strategy: PackImportStrategy
+ ): Promise> =>
+ ipcRenderer.invoke(IPC.PACK_IMPORT_ALL, { filePath, strategy }),
+ estimateExportAll: (): Promise> =>
+ ipcRenderer.invoke(IPC.PACK_ESTIMATE_EXPORT_ALL),
+ cancel: (): Promise> => ipcRenderer.invoke(IPC.PACK_CANCEL)
+ },
bridge: {
get: (
bookId: string,
@@ -674,6 +698,13 @@ const electronAPI = {
}
ipcRenderer.on(IPC.IMPORT_PROGRESS, handler)
return () => ipcRenderer.removeListener(IPC.IMPORT_PROGRESS, handler)
+ },
+ onPackProgress: (callback: (payload: PackProgressEvent) => void): (() => void) => {
+ const handler = (_event: IpcRendererEvent, payload: PackProgressEvent) => {
+ callback(payload)
+ }
+ ipcRenderer.on(IPC.PACK_PROGRESS, handler)
+ return () => ipcRenderer.removeListener(IPC.PACK_PROGRESS, handler)
}
}
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index d50d34b..854a61e 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -18,6 +18,7 @@ import { useEditStore } from '@renderer/stores/useEditStore'
import { SearchModal } from '@renderer/components/search/SearchModal'
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
+import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
import { useSearchStore } from '@renderer/stores/useSearchStore'
@@ -209,6 +210,7 @@ function AppInner(): React.JSX.Element {
+
setNamingCheckOpen(false)} />
{toast && {toast}
}
diff --git a/src/renderer/components/export/ExportAllModal.tsx b/src/renderer/components/export/ExportAllModal.tsx
new file mode 100644
index 0000000..a98f736
--- /dev/null
+++ b/src/renderer/components/export/ExportAllModal.tsx
@@ -0,0 +1,166 @@
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { PackProgressEvent } from '@shared/novel-pack'
+import { useAppStore } from '@renderer/stores/useAppStore'
+import { ipcCall } from '@renderer/lib/ipc-client'
+
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+export function ExportAllModal(): React.JSX.Element | null {
+ const { t } = useTranslation()
+ const open = useAppStore((s) => s.exportAllModalOpen)
+ const setOpen = useAppStore((s) => s.setExportAllModalOpen)
+ const showToast = useAppStore((s) => s.showToast)
+
+ const [estimating, setEstimating] = useState(false)
+ const [exporting, setExporting] = useState(false)
+ const [bytes, setBytes] = useState(0)
+ const [needsConfirm, setNeedsConfirm] = useState(false)
+ const [confirmed, setConfirmed] = useState(false)
+ const [progress, setProgress] = useState(null)
+ const [destPath, setDestPath] = useState('')
+
+ useEffect(() => {
+ if (!open) return
+ setEstimating(true)
+ setExporting(false)
+ setBytes(0)
+ setNeedsConfirm(false)
+ setConfirmed(false)
+ setProgress(null)
+ setDestPath('')
+ void ipcCall(() => window.electronAPI.pack.estimateExportAll())
+ .then((info) => {
+ setBytes(info.bytes)
+ setNeedsConfirm(info.needsConfirm)
+ })
+ .catch(() => showToast(t('pack.exportAllFailed')))
+ .finally(() => setEstimating(false))
+ }, [open, showToast, t])
+
+ useEffect(() => {
+ if (!open) return
+ const unsub = window.electronAPI.onPackProgress((payload) => {
+ setProgress(payload)
+ })
+ return unsub
+ }, [open])
+
+ const handlePickDest = async (): Promise => {
+ const path = await ipcCall(() => window.electronAPI.pack.pickFile('save-all'))
+ if (path) setDestPath(path)
+ }
+
+ const handleExport = async (): Promise => {
+ if (!destPath) return
+ if (needsConfirm && !confirmed) return
+ setExporting(true)
+ setProgress(null)
+ try {
+ await ipcCall(() => window.electronAPI.pack.exportAll(destPath))
+ showToast(t('pack.exportAllSuccess'))
+ setOpen(false)
+ } catch {
+ showToast(t('pack.exportAllFailed'))
+ } finally {
+ setExporting(false)
+ setProgress(null)
+ }
+ }
+
+ const handleCancel = (): void => {
+ if (exporting) {
+ void ipcCall(() => window.electronAPI.pack.cancel()).catch(() => undefined)
+ }
+ setOpen(false)
+ }
+
+ if (!open) return null
+
+ const progressPct =
+ progress && progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0
+
+ return (
+
+
+
+
{t('pack.exportAllTitle')}
+
+
+
+
{t('pack.exportAllDesc')}
+ {estimating ? (
+
{t('pack.estimating')}
+ ) : (
+
+ {t('pack.estimatedSize', { size: formatFileSize(bytes) })}
+
+ )}
+
+
+
+
+ {needsConfirm && (
+
+ )}
+ {progress && (
+
+
+
+ {progress.message ??
+ t('pack.progress', { current: progress.current, total: progress.total })}
+
+
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/components/export/ExportModal.tsx b/src/renderer/components/export/ExportModal.tsx
index a3ad15a..e42150c 100644
--- a/src/renderer/components/export/ExportModal.tsx
+++ b/src/renderer/components/export/ExportModal.tsx
@@ -8,12 +8,15 @@ import { useAppStore } from '@renderer/stores/useAppStore'
import { useExportStore } from '@renderer/stores/useExportStore'
import { ipcCall } from '@renderer/lib/ipc-client'
+type ExportKind = 'submission' | 'novel'
+
export function ExportModal(): React.JSX.Element | null {
const { t } = useTranslation()
const open = useExportStore((s) => s.open)
const close = useExportStore((s) => s.close)
const showToast = useAppStore((s) => s.showToast)
const bookId = useBookStore((s) => s.currentBookId)
+ const bookName = useBookStore((s) => s.books.find((b) => b.id === bookId)?.name)
const chapters = useBookStore((s) => s.chapters)
const target = useEditStore((s) => s.target)
const customPresets = useSettingsStore((s) => s.submissionPresets)
@@ -23,17 +26,20 @@ export function ExportModal(): React.JSX.Element | null {
const chapterId = target?.kind === 'chapter' ? target.id : null
const chapter = chapterId ? chapters.find((c) => c.id === chapterId) : null
+ const [exportKind, setExportKind] = useState('submission')
const [presetId, setPresetId] = useState(defaultPresetId)
const [preview, setPreview] = useState('')
const [loading, setLoading] = useState(false)
+ const [exportingNovel, setExportingNovel] = useState(false)
useEffect(() => {
if (!open) return
+ setExportKind('submission')
setPresetId(defaultPresetId)
}, [open, defaultPresetId])
useEffect(() => {
- if (!open || !bookId || !chapterId) {
+ if (!open || exportKind !== 'submission' || !bookId || !chapterId) {
setPreview('')
return
}
@@ -42,7 +48,7 @@ export function ExportModal(): React.JSX.Element | null {
.then((text) => setPreview(text.slice(0, 500)))
.catch(() => setPreview(''))
.finally(() => setLoading(false))
- }, [open, bookId, chapterId, presetId])
+ }, [open, exportKind, bookId, chapterId, presetId])
const handleCopy = async (): Promise => {
if (!bookId || !chapterId) return
@@ -65,6 +71,22 @@ export function ExportModal(): React.JSX.Element | null {
}
}
+ const handleExportNovel = async (): Promise => {
+ if (!bookId) return
+ const destPath = await ipcCall(() => window.electronAPI.pack.pickFile('save-novel'))
+ if (!destPath) return
+ setExportingNovel(true)
+ try {
+ await ipcCall(() => window.electronAPI.pack.exportBook(bookId, destPath))
+ showToast(t('pack.exportBookSuccess', { name: bookName ?? '' }))
+ close()
+ } catch {
+ showToast(t('pack.exportBookFailed'))
+ } finally {
+ setExportingNovel(false)
+ }
+ }
+
if (!open) return null
return (
@@ -80,53 +102,86 @@ export function ExportModal(): React.JSX.Element | null {
{t('export.title')}
- {chapter ? ` — ${chapter.title}` : ''}
+ {exportKind === 'submission' && chapter ? ` — ${chapter.title}` : ''}
+ {exportKind === 'novel' && bookName ? ` — ${bookName}` : ''}
-
+
-
-
- {loading ? t('export.loading') : preview || t('export.noPreview')}
-
+ {exportKind === 'submission' ? (
+ <>
+
+
+
+
+ {loading ? t('export.loading') : preview || t('export.noPreview')}
+
+ >
+ ) : (
+
+ {t('export.novelDesc')}
+
+ )}
-
-
+ {exportKind === 'submission' ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
diff --git a/src/renderer/components/home/HomePage.tsx b/src/renderer/components/home/HomePage.tsx
index 435fadd..bf9b95c 100644
--- a/src/renderer/components/home/HomePage.tsx
+++ b/src/renderer/components/home/HomePage.tsx
@@ -8,6 +8,7 @@ import { ipcCall } from '@renderer/lib/ipc-client'
export function HomePage(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
+ const setExportAllModalOpen = useAppStore((s) => s.setExportAllModalOpen)
const setView = useAppStore((s) => s.setView)
const { books, loadBooks, openBook } = useBookStore()
const { openBookTab, openSettingsTab } = useTabStore()
@@ -63,8 +64,7 @@ export function HomePage(): React.JSX.Element {
type="button"
className="btn-large"
data-testid="home-export-all"
- disabled
- title={t('home.exportAllWave2')}
+ onClick={() => setExportAllModalOpen(true)}
>
{t('home.exportAll')}
diff --git a/src/renderer/components/import/ImportBookModal.tsx b/src/renderer/components/import/ImportBookModal.tsx
index 85debc4..37f3f70 100644
--- a/src/renderer/components/import/ImportBookModal.tsx
+++ b/src/renderer/components/import/ImportBookModal.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ImportPreviewResult, ImportSplitMode } from '@shared/types'
+import type { PackImportStrategy } from '@shared/novel-pack'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useTabStore } from '@renderer/stores/useTabStore'
@@ -14,6 +15,13 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
+function detectFileKind(filePath: string): 'text' | 'novel' | 'novel-all' {
+ const lower = filePath.toLowerCase()
+ if (lower.endsWith('.novel-all')) return 'novel-all'
+ if (lower.endsWith('.novel')) return 'novel'
+ return 'text'
+}
+
export function ImportBookModal(): React.JSX.Element | null {
const { t } = useTranslation()
const open = useAppStore((s) => s.importBookModalOpen)
@@ -25,63 +33,115 @@ export function ImportBookModal(): React.JSX.Element | null {
const [filePath, setFilePath] = useState('')
const [fileName, setFileName] = useState('')
+ const [fileKind, setFileKind] = useState<'text' | 'novel' | 'novel-all'>('text')
const [bookName, setBookName] = useState('')
const [category, setCategory] = useState('玄幻')
const [splitMode, setSplitMode] = useState('auto')
const [customRegex, setCustomRegex] = useState('')
+ const [packStrategy, setPackStrategy] = useState('new')
const [preview, setPreview] = useState(null)
const [importing, setImporting] = useState(false)
- const [progress, setProgress] = useState<{ current: number; total: number } | null>(null)
+ const [progress, setProgress] = useState<{ current: number; total: number; message?: string } | null>(
+ null
+ )
useEffect(() => {
if (!open) return
setFilePath('')
setFileName('')
+ setFileKind('text')
setBookName('')
setCategory('玄幻')
setSplitMode('auto')
setCustomRegex('')
+ setPackStrategy('new')
setPreview(null)
setProgress(null)
}, [open])
useEffect(() => {
if (!open) return
- const unsub = window.electronAPI.onImportProgress((payload) => {
+ const unsubImport = window.electronAPI.onImportProgress((payload) => {
setProgress(payload)
})
- return unsub
+ const unsubPack = window.electronAPI.onPackProgress((payload) => {
+ setProgress(payload)
+ })
+ return () => {
+ unsubImport()
+ unsubPack()
+ }
}, [open])
const handlePickFile = async (): Promise => {
- const result = await ipcCall(() => window.electronAPI.import.pickFile())
- if (result.canceled || !result.filePath) return
- setFilePath(result.filePath)
- const name = result.filePath.split(/[/\\]/).pop() ?? ''
+ const path = await ipcCall(() => window.electronAPI.pack.pickFile('import'))
+ if (!path) return
+ setFilePath(path)
+ const name = path.split(/[/\\]/).pop() ?? ''
setFileName(name)
- const baseName = name.replace(/\.(txt|md|docx)$/i, '')
- if (!bookName.trim()) setBookName(baseName)
+ const kind = detectFileKind(path)
+ setFileKind(kind)
+ setPreview(null)
+ if (kind === 'text') {
+ const baseName = name.replace(/\.(txt|md|docx)$/i, '')
+ if (!bookName.trim()) setBookName(baseName)
+ }
}
const handlePreview = async (): Promise => {
- if (!filePath) return
+ if (!filePath || fileKind !== 'text') return
const result = await ipcCall(() =>
window.electronAPI.import.preview(filePath, splitMode, customRegex || undefined)
)
setPreview(result)
}
+ const openImportedBook = async (bookId: string, name: string): Promise => {
+ await loadBooks()
+ const meta = useBookStore.getState().books.find((b) => b.id === bookId)
+ await openBook(bookId)
+ openBookTab(bookId, meta?.name ?? name)
+ setView('editor')
+ showToast(t('import.success'))
+ setOpen(false)
+ }
+
const handleImport = async (): Promise => {
- if (!filePath || !bookName.trim()) return
- if (preview && preview.fileSizeBytes > LARGE_FILE_BYTES) {
+ if (!filePath) return
+ if (fileKind === 'text' && !bookName.trim()) return
+
+ if (fileKind === 'text' && preview && preview.fileSizeBytes > LARGE_FILE_BYTES) {
const ok = confirm(
t('import.largeFileConfirm', { size: formatFileSize(preview.fileSizeBytes) })
)
if (!ok) return
}
+
setImporting(true)
setProgress(null)
try {
+ if (fileKind === 'novel') {
+ const bookId = await ipcCall(() => window.electronAPI.pack.importBook(filePath))
+ await openImportedBook(bookId, bookName.trim() || fileName)
+ return
+ }
+
+ if (fileKind === 'novel-all') {
+ const report = await ipcCall(() =>
+ window.electronAPI.pack.importAll(filePath, packStrategy)
+ )
+ await loadBooks()
+ showToast(
+ t('pack.importAllSuccess', {
+ imported: report.imported,
+ skipped: report.skipped,
+ overwritten: report.overwritten
+ })
+ )
+ setOpen(false)
+ return
+ }
+
if (!preview) await handlePreview()
const bookId = await ipcCall(() =>
window.electronAPI.import.execute({
@@ -92,13 +152,7 @@ export function ImportBookModal(): React.JSX.Element | null {
customRegex: customRegex || undefined
})
)
- await loadBooks()
- const meta = useBookStore.getState().books.find((b) => b.id === bookId)
- await openBook(bookId)
- openBookTab(bookId, meta?.name ?? bookName.trim())
- setView('editor')
- showToast(t('import.success'))
- setOpen(false)
+ await openImportedBook(bookId, bookName.trim())
} catch {
showToast(t('import.failed'))
} finally {
@@ -110,6 +164,7 @@ export function ImportBookModal(): React.JSX.Element | null {
if (!open) return null
const previewTitles = preview?.chapters.slice(0, 5).map((ch) => ch.title) ?? []
+ const isPack = fileKind === 'novel' || fileKind === 'novel-all'
return (
-
- setBookName(e.target.value)}
- />
-
- setCategory(e.target.value)}
- />
-
-
- {splitMode === 'regex' && (
+ {fileKind === 'novel' && (
+
+ {t('import.novelHint')}
+
+ )}
+ {fileKind === 'novel-all' && (
<>
-
- {t('import.novelAllHint')}
+
+
>
)}
-
- {preview && (
-
-
- {t('import.chapterCount', { count: preview.chapters.length })}
- {preview.warnings.length > 0 && ` (${preview.warnings.join(', ')})`}
-
-
- {previewTitles.map((title, i) => (
- - {title}
- ))}
-
-
+ {fileKind === 'text' && (
+ <>
+
+ setBookName(e.target.value)}
+ />
+
+ setCategory(e.target.value)}
+ />
+
+
+ {splitMode === 'regex' && (
+ <>
+
+ setCustomRegex(e.target.value)}
+ placeholder="^第.+章"
+ />
+ >
+ )}
+
+ {preview && (
+
+
+ {t('import.chapterCount', { count: preview.chapters.length })}
+ {preview.warnings.length > 0 && ` (${preview.warnings.join(', ')})`}
+
+
+ {previewTitles.map((title, i) => (
+ - {title}
+ ))}
+
+
+ )}
+ >
)}
{progress && (
@@ -209,7 +289,10 @@ export function ImportBookModal(): React.JSX.Element | null {
className="import-progress-fill"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
- {t('import.progress', { current: progress.current, total: progress.total })}
+
+ {progress.message ??
+ t('import.progress', { current: progress.current, total: progress.total })}
+
)}
@@ -221,7 +304,7 @@ export function ImportBookModal(): React.JSX.Element | null {
type="button"
className="btn btn-primary"
data-testid="import-execute-btn"
- disabled={!filePath || !bookName.trim() || importing}
+ disabled={!filePath || (!isPack && !bookName.trim()) || importing}
onClick={() => void handleImport()}
>
{importing ? t('import.importing') : t('import.start')}
diff --git a/src/renderer/components/settings/BackupSettingsTab.tsx b/src/renderer/components/settings/BackupSettingsTab.tsx
new file mode 100644
index 0000000..c4b7f26
--- /dev/null
+++ b/src/renderer/components/settings/BackupSettingsTab.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { PackProgressEvent } from '@shared/novel-pack'
+import { useAppStore } from '@renderer/stores/useAppStore'
+import { useBookStore } from '@renderer/stores/useBookStore'
+import { ipcCall } from '@renderer/lib/ipc-client'
+
+export function BackupSettingsTab(): React.JSX.Element {
+ const { t } = useTranslation()
+ const showToast = useAppStore((s) => s.showToast)
+ const setExportAllModalOpen = useAppStore((s) => s.setExportAllModalOpen)
+ const bookId = useBookStore((s) => s.currentBookId)
+ const bookName = useBookStore((s) => s.books.find((b) => b.id === bookId)?.name)
+
+ const [exportingBook, setExportingBook] = useState(false)
+ const [progress, setProgress] = useState(null)
+
+ useEffect(() => {
+ const unsub = window.electronAPI.onPackProgress((payload) => {
+ setProgress(payload)
+ })
+ return unsub
+ }, [])
+
+ const handleExportBook = async (): Promise => {
+ if (!bookId) {
+ showToast(t('backup.noBook'))
+ return
+ }
+ const destPath = await ipcCall(() => window.electronAPI.pack.pickFile('save-novel'))
+ if (!destPath) return
+ setExportingBook(true)
+ setProgress(null)
+ try {
+ await ipcCall(() => window.electronAPI.pack.exportBook(bookId, destPath))
+ showToast(t('backup.exportBookSuccess', { name: bookName ?? '' }))
+ } catch {
+ showToast(t('backup.exportBookFailed'))
+ } finally {
+ setExportingBook(false)
+ setProgress(null)
+ }
+ }
+
+ const progressPct =
+ progress && progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0
+
+ return (
+
+
+ {t('backup.desc')}
+
+
+ {t('backup.exportCurrentBook')}
+
+ {!bookId && {t('backup.openBookHint')}}
+
+
+ {t('backup.exportAllBooks')}
+
+
+ {progress && (
+
+
+
+ {progress.message ??
+ t('pack.progress', { current: progress.current, total: progress.total })}
+
+
+ )}
+
+ {t('backup.formatHint')}
+
+
+ )
+}
diff --git a/src/renderer/components/settings/SettingsPage.tsx b/src/renderer/components/settings/SettingsPage.tsx
index a9d93f4..5797090 100644
--- a/src/renderer/components/settings/SettingsPage.tsx
+++ b/src/renderer/components/settings/SettingsPage.tsx
@@ -5,6 +5,7 @@ import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
+import { BackupSettingsTab } from '@renderer/components/settings/BackupSettingsTab'
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
const THEMES: { id: ThemeId; key: string }[] = [
@@ -21,7 +22,7 @@ export function SettingsPage(): React.JSX.Element {
const { t } = useTranslation()
const settings = useSettingsStore()
const [section, setSection] = useState<
- 'general' | 'ai' | 'templates' | 'submission' | 'shortcuts' | 'about'
+ 'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'shortcuts' | 'about'
>('general')
return (
@@ -62,6 +63,15 @@ export function SettingsPage(): React.JSX.Element {
>
{t('settings.submission')}
+ setSection('backup')}
+ role="button"
+ tabIndex={0}
+ data-testid="settings-nav-backup"
+ >
+ {t('settings.backup')}
+
setSection('shortcuts')}
@@ -249,10 +259,11 @@ export function SettingsPage(): React.JSX.Element {
{section === 'ai' &&
}
{section === 'templates' &&
}
{section === 'submission' &&
}
+ {section === 'backup' &&
}
{section === 'shortcuts' &&
}
{section === 'about' && (
- {t('app.name')} v1.2.0
+ {t('app.name')} v1.3.0
{t('app.tagline')}
diff --git a/src/renderer/stores/useAppStore.ts b/src/renderer/stores/useAppStore.ts
index b7d2d88..4bdf72a 100644
--- a/src/renderer/stores/useAppStore.ts
+++ b/src/renderer/stores/useAppStore.ts
@@ -10,6 +10,7 @@ interface AppState {
landmarksModalOpen: boolean
inspirationModalOpen: boolean
importBookModalOpen: boolean
+ exportAllModalOpen: boolean
focusMode: boolean
namingCheckOpen: boolean
toast: string | null
@@ -20,6 +21,7 @@ interface AppState {
setLandmarksModalOpen: (open: boolean) => void
setInspirationModalOpen: (open: boolean) => void
setImportBookModalOpen: (open: boolean) => void
+ setExportAllModalOpen: (open: boolean) => void
setFocusMode: (on: boolean) => void
setNamingCheckOpen: (open: boolean) => void
showToast: (message: string) => void
@@ -34,6 +36,7 @@ export const useAppStore = create
((set) => ({
landmarksModalOpen: false,
inspirationModalOpen: false,
importBookModalOpen: false,
+ exportAllModalOpen: false,
focusMode: false,
namingCheckOpen: false,
toast: null,
@@ -44,6 +47,7 @@ export const useAppStore = create((set) => ({
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
+ setExportAllModalOpen: (exportAllModalOpen) => set({ exportAllModalOpen }),
setFocusMode: (focusMode) => set({ focusMode }),
setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }),
showToast: (toast) => {
diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts
index 6db0014..2a00382 100644
--- a/src/shared/electron-api.d.ts
+++ b/src/shared/electron-api.d.ts
@@ -59,6 +59,7 @@ import type {
ImportExecuteParams,
ImportSplitMode
} from './types'
+import type { PackImportStrategy, PackImportReport, PackProgressEvent } from './novel-pack'
export interface ElectronAPI {
window: {
@@ -445,6 +446,17 @@ export interface ElectronAPI {
copyClipboard: (text: string) => Promise>
saveTxt: (text: string, defaultName: string) => Promise>
}
+ pack: {
+ pickFile: (
+ mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
+ ) => Promise>
+ exportBook: (bookId: string, destPath: string) => Promise>
+ importBook: (filePath: string) => Promise>
+ exportAll: (destPath: string) => Promise>
+ importAll: (filePath: string, strategy: PackImportStrategy) => Promise>
+ estimateExportAll: () => Promise>
+ cancel: () => Promise>
+ }
bridge: {
get: (
bookId: string,
@@ -471,6 +483,7 @@ export interface ElectronAPI {
onPomodoroTick: (callback: (state: PomodoroState) => void) => () => void
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void
onImportProgress: (callback: (payload: { current: number; total: number }) => void) => () => void
+ onPackProgress: (callback: (payload: PackProgressEvent) => void) => () => void
}
declare global {