From fe6e411d2c1b8d33f74557b08219de703c42394c Mon Sep 17 00:00:00 2001 From: kun1h Date: Wed, 8 Jul 2026 16:31:16 +0800 Subject: [PATCH] feat(w2): ship v1.3.0 project pack UI, preload, and E2E Wire .novel/.novel-all export-import through modals, settings backup, and home bulk export with progress IPC. Co-authored-by: Cursor --- README.md | 9 +- .../plans/2026-07-08-bilin-w2-project-pack.md | 28 +-- e2e/helpers.ts | 10 + e2e/pack-export-all.spec.ts | 49 ++++ e2e/pack-export-import.spec.ts | 74 ++++++ package.json | 2 +- public/locales/en/translation.json | 37 +++ public/locales/zh-CN/translation.json | 37 +++ src/main/ipc/handlers/pack.handler.ts | 26 +- src/preload/index.ts | 31 +++ src/renderer/App.tsx | 2 + .../components/export/ExportAllModal.tsx | 166 +++++++++++++ .../components/export/ExportModal.tsx | 123 ++++++--- src/renderer/components/home/HomePage.tsx | 4 +- .../components/import/ImportBookModal.tsx | 233 ++++++++++++------ .../components/settings/BackupSettingsTab.tsx | 91 +++++++ .../components/settings/SettingsPage.tsx | 15 +- src/renderer/stores/useAppStore.ts | 4 + src/shared/electron-api.d.ts | 13 + 19 files changed, 823 insertions(+), 131 deletions(-) create mode 100644 e2e/pack-export-all.spec.ts create mode 100644 e2e/pack-export-import.spec.ts create mode 100644 src/renderer/components/export/ExportAllModal.tsx create mode 100644 src/renderer/components/settings/BackupSettingsTab.tsx 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 {