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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 16:31:16 +08:00
parent a4412793f4
commit fe6e411d2c
19 changed files with 823 additions and 131 deletions
+8 -1
View File
@@ -1,6 +1,13 @@
# 笔临 (Bilin) # 笔临 (Bilin)
长篇创作智能协作平台 — Electron 桌面客户端 v1.2.0基础治理 长篇创作智能协作平台 — Electron 桌面客户端 v1.3.0项目包
## 功能概览(v1.3.0
- `.novel` 单书项目包导出/导入(含数据库、元数据、附件)
- `.novel-all` 批量导出/导入全部书籍与全局设置
- 主页「导出所有书籍」、设置页备份区、导出模态项目包选项
- 大文件导出确认与进度推送
## 功能概览(v1.2.0 ## 功能概览(v1.2.0
@@ -90,8 +90,8 @@
- Modify: `src/preload/index.ts``electron-api.d.ts` - Modify: `src/preload/index.ts``electron-api.d.ts`
- [x] **Step 1:** IPC 常量与 handler 注册 - [x] **Step 1:** IPC 常量与 handler 注册
- [ ] **Step 2:** preload `pack.exportBook/importBook/exportAll/importAll/pickFile` - [x] **Step 2:** preload `pack.exportBook/importBook/exportAll/importAll/pickFile`
- [ ] **Step 3:** `onPackProgress` 事件订阅 - [x] **Step 3:** `onPackProgress` 事件订阅
--- ---
@@ -102,9 +102,9 @@
- Modify: `src/renderer/components/import/ImportBookModal.tsx` - Modify: `src/renderer/components/import/ImportBookModal.tsx`
- Modify: `public/locales/*/translation.json` - Modify: `public/locales/*/translation.json`
- [ ] **Step 1:** ExportModal 增加「笔临项目包 (.novel)」 - [x] **Step 1:** ExportModal 增加「笔临项目包 (.novel)」
- [ ] **Step 2:** ImportBookModal 文件过滤含 `.novel`/`.novel-all` - [x] **Step 2:** ImportBookModal 文件过滤含 `.novel`/`.novel-all`
- [ ] **Step 3:** `.novel` 直接导入;`.novel-all` 打开策略选择 + 进度条 - [x] **Step 3:** `.novel` 直接导入;`.novel-all` 打开策略选择 + 进度条
--- ---
@@ -115,8 +115,8 @@
- Modify: `src/renderer/components/home/HomePage.tsx` - Modify: `src/renderer/components/home/HomePage.tsx`
- Modify: `src/renderer/stores/useAppStore.ts`(可选 modal 状态) - Modify: `src/renderer/stores/useAppStore.ts`(可选 modal 状态)
- [ ] **Step 1:** `ExportAllModal` — 选路径、大小确认、进度、取消 - [x] **Step 1:** `ExportAllModal` — 选路径、大小确认、进度、取消
- [ ] **Step 2:** HomePage「导出所有书籍」启用并打开 modal - [x] **Step 2:** HomePage「导出所有书籍」启用并打开 modal
--- ---
@@ -126,8 +126,8 @@
- Create: `src/renderer/components/settings/BackupSettingsTab.tsx` - Create: `src/renderer/components/settings/BackupSettingsTab.tsx`
- Modify: `src/renderer/components/settings/SettingsPage.tsx` - Modify: `src/renderer/components/settings/SettingsPage.tsx`
- [ ] **Step 1:** 导出当前书 / 导出全部按钮 - [x] **Step 1:** 导出当前书 / 导出全部按钮
- [ ] **Step 2:** i18n `backup.*` - [x] **Step 2:** i18n `backup.*`
--- ---
@@ -139,11 +139,11 @@
- Modify: `e2e/helpers.ts` - Modify: `e2e/helpers.ts`
- Modify: `package.json``README.md``SettingsPage.tsx` - Modify: `package.json``README.md``SettingsPage.tsx`
- [ ] **Step 1:** E2E 单书 .novel 导出再导入 - [x] **Step 1:** E2E 单书 .novel 导出再导入
- [ ] **Step 2:** E2E-ALL-03 主页导出全部 - [x] **Step 2:** E2E-ALL-03 主页导出全部
- [ ] **Step 3:** 全量 `npm run test` + 相关 E2E - [x] **Step 3:** 全量 `npm run test` + 相关 E2E
- [ ] **Step 4:** 版本 **v1.3.0** - [x] **Step 4:** 版本 **v1.3.0**
- [ ] **Step 5:** 勾选本 plan 全部 checkbox - [x] **Step 5:** 勾选本 plan 全部 checkbox
--- ---
+10
View File
@@ -62,6 +62,16 @@ export async function openExportModal(page: Page): Promise<void> {
await expect(modal).toBeVisible({ timeout: 10_000 }) await expect(modal).toBeVisible({ timeout: 10_000 })
} }
export async function openExportAllModal(page: Page): Promise<void> {
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<void> {
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<void> { export async function openBookSettings(page: Page): Promise<void> {
await page.getByTestId('panel-tab-book-settings').click() await page.getByTestId('panel-tab-book-settings').click()
await expect(page.getByTestId('book-settings-tab')).toBeVisible({ timeout: 10_000 }) await expect(page.getByTestId('book-settings-tab')).toBeVisible({ timeout: 10_000 })
+49
View File
@@ -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()
})
})
+74
View File
@@ -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()
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bilin", "name": "bilin",
"version": "1.2.0", "version": "1.3.0",
"description": "笔临 - 长篇创作智能协作平台", "description": "笔临 - 长篇创作智能协作平台",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"type": "module", "type": "module",
+37
View File
@@ -442,6 +442,43 @@
"export.preview": "Preview", "export.preview": "Preview",
"export.loading": "Loading…", "export.loading": "Loading…",
"export.noPreview": "(no preview)", "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.title": "Submission Presets",
"submission.titleFormat": "Title format", "submission.titleFormat": "Title format",
"submission.indent": "Indent paragraphs", "submission.indent": "Indent paragraphs",
+37
View File
@@ -442,6 +442,43 @@
"export.preview": "预览", "export.preview": "预览",
"export.loading": "加载中…", "export.loading": "加载中…",
"export.noPreview": "(无预览)", "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.title": "投稿预设",
"submission.titleFormat": "标题格式", "submission.titleFormat": "标题格式",
"submission.indent": "段首空两格", "submission.indent": "段首空两格",
+24 -2
View File
@@ -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 () => { 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') { if (mode === 'save-novel' || mode === 'save-all') {
const ext = mode === 'save-all' ? 'novel-all' : 'novel' const ext = mode === 'save-all' ? 'novel-all' : 'novel'
const { canceled, filePath } = await dialog.showSaveDialog({ const { canceled, filePath } = await dialog.showSaveDialog({
@@ -69,7 +86,12 @@ export function registerPackHandlers(
if (canceled || !filePath) return null if (canceled || !filePath) return null
return filePath 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({ const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'], properties: ['openFile'],
filters: [{ name: 'Bilin Pack', extensions }] filters: [{ name: 'Bilin Pack', extensions }]
+31
View File
@@ -60,6 +60,11 @@ import type {
ImportExecuteParams, ImportExecuteParams,
ImportSplitMode ImportSplitMode
} from '../shared/types' } from '../shared/types'
import type {
PackImportStrategy,
PackImportReport,
PackProgressEvent
} from '../shared/novel-pack'
const electronAPI = { const electronAPI = {
window: { window: {
@@ -599,6 +604,25 @@ const electronAPI = {
saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> => saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> =>
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName }) ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
}, },
pack: {
pickFile: (
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
): Promise<IpcResult<string | null>> => ipcRenderer.invoke(IPC.PACK_PICK_FILE, { mode }),
exportBook: (bookId: string, destPath: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.PACK_EXPORT_BOOK, { bookId, destPath }),
importBook: (filePath: string): Promise<IpcResult<string>> =>
ipcRenderer.invoke(IPC.PACK_IMPORT_BOOK, { filePath }),
exportAll: (destPath: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.PACK_EXPORT_ALL, { destPath }),
importAll: (
filePath: string,
strategy: PackImportStrategy
): Promise<IpcResult<PackImportReport>> =>
ipcRenderer.invoke(IPC.PACK_IMPORT_ALL, { filePath, strategy }),
estimateExportAll: (): Promise<IpcResult<{ bytes: number; needsConfirm: boolean }>> =>
ipcRenderer.invoke(IPC.PACK_ESTIMATE_EXPORT_ALL),
cancel: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.PACK_CANCEL)
},
bridge: { bridge: {
get: ( get: (
bookId: string, bookId: string,
@@ -674,6 +698,13 @@ const electronAPI = {
} }
ipcRenderer.on(IPC.IMPORT_PROGRESS, handler) ipcRenderer.on(IPC.IMPORT_PROGRESS, handler)
return () => ipcRenderer.removeListener(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)
} }
} }
+2
View File
@@ -18,6 +18,7 @@ import { useEditStore } from '@renderer/stores/useEditStore'
import { SearchModal } from '@renderer/components/search/SearchModal' import { SearchModal } from '@renderer/components/search/SearchModal'
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal' import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
import { ImportBookModal } from '@renderer/components/import/ImportBookModal' import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay' import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal' import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
import { useSearchStore } from '@renderer/stores/useSearchStore' import { useSearchStore } from '@renderer/stores/useSearchStore'
@@ -209,6 +210,7 @@ function AppInner(): React.JSX.Element {
<SearchModal /> <SearchModal />
<InspirationModal /> <InspirationModal />
<ImportBookModal /> <ImportBookModal />
<ExportAllModal />
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} /> <NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
{toast && <div className="toast">{toast}</div>} {toast && <div className="toast">{toast}</div>}
</div> </div>
@@ -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<PackProgressEvent | null>(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<void> => {
const path = await ipcCall(() => window.electronAPI.pack.pickFile('save-all'))
if (path) setDestPath(path)
}
const handleExport = async (): Promise<void> => {
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 (
<div
className="modal-overlay"
data-testid="export-all-modal"
role="dialog"
aria-modal="true"
aria-label={t('pack.exportAllTitle')}
>
<div className="modal-content">
<div className="modal-header">
<h3>{t('pack.exportAllTitle')}</h3>
<button type="button" className="modal-close" onClick={handleCancel}>
</button>
</div>
<div className="modal-body">
<p className="text-muted">{t('pack.exportAllDesc')}</p>
{estimating ? (
<p>{t('pack.estimating')}</p>
) : (
<p data-testid="export-all-size">
{t('pack.estimatedSize', { size: formatFileSize(bytes) })}
</p>
)}
<div className="import-file-row">
<input
className="form-control"
readOnly
value={destPath || t('pack.noDest')}
data-testid="export-all-dest"
/>
<button
type="button"
className="btn"
data-testid="export-all-pick"
disabled={exporting}
onClick={() => void handlePickDest()}
>
{t('pack.pickDest')}
</button>
</div>
{needsConfirm && (
<label className="form-label" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="checkbox"
data-testid="export-all-confirm"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
{t('pack.largeExportConfirm', { size: formatFileSize(bytes) })}
</label>
)}
{progress && (
<div className="import-progress" data-testid="export-all-progress">
<div className="import-progress-fill" style={{ width: `${progressPct}%` }} />
<span>
{progress.message ??
t('pack.progress', { current: progress.current, total: progress.total })}
</span>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={handleCancel}>
{exporting ? t('pack.cancel') : t('dialog.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="export-all-start"
disabled={!destPath || exporting || estimating || (needsConfirm && !confirmed)}
onClick={() => void handleExport()}
>
{exporting ? t('pack.exporting') : t('pack.exportAllStart')}
</button>
</div>
</div>
</div>
)
}
+89 -34
View File
@@ -8,12 +8,15 @@ import { useAppStore } from '@renderer/stores/useAppStore'
import { useExportStore } from '@renderer/stores/useExportStore' import { useExportStore } from '@renderer/stores/useExportStore'
import { ipcCall } from '@renderer/lib/ipc-client' import { ipcCall } from '@renderer/lib/ipc-client'
type ExportKind = 'submission' | 'novel'
export function ExportModal(): React.JSX.Element | null { export function ExportModal(): React.JSX.Element | null {
const { t } = useTranslation() const { t } = useTranslation()
const open = useExportStore((s) => s.open) const open = useExportStore((s) => s.open)
const close = useExportStore((s) => s.close) const close = useExportStore((s) => s.close)
const showToast = useAppStore((s) => s.showToast) const showToast = useAppStore((s) => s.showToast)
const bookId = useBookStore((s) => s.currentBookId) 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 chapters = useBookStore((s) => s.chapters)
const target = useEditStore((s) => s.target) const target = useEditStore((s) => s.target)
const customPresets = useSettingsStore((s) => s.submissionPresets) 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 chapterId = target?.kind === 'chapter' ? target.id : null
const chapter = chapterId ? chapters.find((c) => c.id === chapterId) : null const chapter = chapterId ? chapters.find((c) => c.id === chapterId) : null
const [exportKind, setExportKind] = useState<ExportKind>('submission')
const [presetId, setPresetId] = useState(defaultPresetId) const [presetId, setPresetId] = useState(defaultPresetId)
const [preview, setPreview] = useState('') const [preview, setPreview] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [exportingNovel, setExportingNovel] = useState(false)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setExportKind('submission')
setPresetId(defaultPresetId) setPresetId(defaultPresetId)
}, [open, defaultPresetId]) }, [open, defaultPresetId])
useEffect(() => { useEffect(() => {
if (!open || !bookId || !chapterId) { if (!open || exportKind !== 'submission' || !bookId || !chapterId) {
setPreview('') setPreview('')
return return
} }
@@ -42,7 +48,7 @@ export function ExportModal(): React.JSX.Element | null {
.then((text) => setPreview(text.slice(0, 500))) .then((text) => setPreview(text.slice(0, 500)))
.catch(() => setPreview('')) .catch(() => setPreview(''))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [open, bookId, chapterId, presetId]) }, [open, exportKind, bookId, chapterId, presetId])
const handleCopy = async (): Promise<void> => { const handleCopy = async (): Promise<void> => {
if (!bookId || !chapterId) return if (!bookId || !chapterId) return
@@ -65,6 +71,22 @@ export function ExportModal(): React.JSX.Element | null {
} }
} }
const handleExportNovel = async (): Promise<void> => {
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 if (!open) return null
return ( return (
@@ -80,53 +102,86 @@ export function ExportModal(): React.JSX.Element | null {
<div className="modal-header"> <div className="modal-header">
<h3> <h3>
{t('export.title')} {t('export.title')}
{chapter ? `${chapter.title}` : ''} {exportKind === 'submission' && chapter ? `${chapter.title}` : ''}
{exportKind === 'novel' && bookName ? `${bookName}` : ''}
</h3> </h3>
<button type="button" className="modal-close" onClick={close}> <button type="button" className="modal-close" onClick={close}>
</button> </button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<label className="form-label">{t('export.submissionPresets')}</label> <label className="form-label">{t('export.kind')}</label>
<select <select
className="form-control form-control--wide" className="form-control form-control--wide"
data-testid="export-preset-select" data-testid="export-kind-select"
value={presetId} value={exportKind}
onChange={(e) => setPresetId(e.target.value)} onChange={(e) => setExportKind(e.target.value as ExportKind)}
> >
{presets.map((p) => ( <option value="submission">{t('export.kindSubmission')}</option>
<option key={p.id} value={p.id}> <option value="novel">{t('export.kindNovel')}</option>
{p.name}
</option>
))}
</select> </select>
<label className="form-label">{t('export.preview')}</label> {exportKind === 'submission' ? (
<pre className="export-preview" data-testid="export-preview"> <>
{loading ? t('export.loading') : preview || t('export.noPreview')} <label className="form-label">{t('export.submissionPresets')}</label>
</pre> <select
className="form-control form-control--wide"
data-testid="export-preset-select"
value={presetId}
onChange={(e) => setPresetId(e.target.value)}
>
{presets.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<label className="form-label">{t('export.preview')}</label>
<pre className="export-preview" data-testid="export-preview">
{loading ? t('export.loading') : preview || t('export.noPreview')}
</pre>
</>
) : (
<p className="text-muted" style={{ lineHeight: 1.6 }}>
{t('export.novelDesc')}
</p>
)}
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn" onClick={close}> <button type="button" className="btn" onClick={close}>
{t('dialog.cancel')} {t('dialog.cancel')}
</button> </button>
<button {exportKind === 'submission' ? (
type="button" <>
className="btn" <button
data-testid="export-save-txt" type="button"
disabled={!chapterId} className="btn"
onClick={() => void handleSaveTxt()} data-testid="export-save-txt"
> disabled={!chapterId}
{t('export.saveTxt')} onClick={() => void handleSaveTxt()}
</button> >
<button {t('export.saveTxt')}
type="button" </button>
className="btn btn-primary" <button
data-testid="export-copy-clipboard" type="button"
disabled={!chapterId} className="btn btn-primary"
onClick={() => void handleCopy()} data-testid="export-copy-clipboard"
> disabled={!chapterId}
{t('export.copyClipboard')} onClick={() => void handleCopy()}
</button> >
{t('export.copyClipboard')}
</button>
</>
) : (
<button
type="button"
className="btn btn-primary"
data-testid="export-novel-btn"
disabled={!bookId || exportingNovel}
onClick={() => void handleExportNovel()}
>
{exportingNovel ? t('pack.exporting') : t('export.exportNovel')}
</button>
)}
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -8,6 +8,7 @@ import { ipcCall } from '@renderer/lib/ipc-client'
export function HomePage(): React.JSX.Element { export function HomePage(): React.JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast) const showToast = useAppStore((s) => s.showToast)
const setExportAllModalOpen = useAppStore((s) => s.setExportAllModalOpen)
const setView = useAppStore((s) => s.setView) const setView = useAppStore((s) => s.setView)
const { books, loadBooks, openBook } = useBookStore() const { books, loadBooks, openBook } = useBookStore()
const { openBookTab, openSettingsTab } = useTabStore() const { openBookTab, openSettingsTab } = useTabStore()
@@ -63,8 +64,7 @@ export function HomePage(): React.JSX.Element {
type="button" type="button"
className="btn-large" className="btn-large"
data-testid="home-export-all" data-testid="home-export-all"
disabled onClick={() => setExportAllModalOpen(true)}
title={t('home.exportAllWave2')}
> >
{t('home.exportAll')} {t('home.exportAll')}
</button> </button>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { ImportPreviewResult, ImportSplitMode } from '@shared/types' import type { ImportPreviewResult, ImportSplitMode } from '@shared/types'
import type { PackImportStrategy } from '@shared/novel-pack'
import { useAppStore } from '@renderer/stores/useAppStore' import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore' import { useBookStore } from '@renderer/stores/useBookStore'
import { useTabStore } from '@renderer/stores/useTabStore' import { useTabStore } from '@renderer/stores/useTabStore'
@@ -14,6 +15,13 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 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 { export function ImportBookModal(): React.JSX.Element | null {
const { t } = useTranslation() const { t } = useTranslation()
const open = useAppStore((s) => s.importBookModalOpen) const open = useAppStore((s) => s.importBookModalOpen)
@@ -25,63 +33,115 @@ export function ImportBookModal(): React.JSX.Element | null {
const [filePath, setFilePath] = useState('') const [filePath, setFilePath] = useState('')
const [fileName, setFileName] = useState('') const [fileName, setFileName] = useState('')
const [fileKind, setFileKind] = useState<'text' | 'novel' | 'novel-all'>('text')
const [bookName, setBookName] = useState('') const [bookName, setBookName] = useState('')
const [category, setCategory] = useState('玄幻') const [category, setCategory] = useState('玄幻')
const [splitMode, setSplitMode] = useState<ImportSplitMode>('auto') const [splitMode, setSplitMode] = useState<ImportSplitMode>('auto')
const [customRegex, setCustomRegex] = useState('') const [customRegex, setCustomRegex] = useState('')
const [packStrategy, setPackStrategy] = useState<PackImportStrategy>('new')
const [preview, setPreview] = useState<ImportPreviewResult | null>(null) const [preview, setPreview] = useState<ImportPreviewResult | null>(null)
const [importing, setImporting] = useState(false) 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(() => { useEffect(() => {
if (!open) return if (!open) return
setFilePath('') setFilePath('')
setFileName('') setFileName('')
setFileKind('text')
setBookName('') setBookName('')
setCategory('玄幻') setCategory('玄幻')
setSplitMode('auto') setSplitMode('auto')
setCustomRegex('') setCustomRegex('')
setPackStrategy('new')
setPreview(null) setPreview(null)
setProgress(null) setProgress(null)
}, [open]) }, [open])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const unsub = window.electronAPI.onImportProgress((payload) => { const unsubImport = window.electronAPI.onImportProgress((payload) => {
setProgress(payload) setProgress(payload)
}) })
return unsub const unsubPack = window.electronAPI.onPackProgress((payload) => {
setProgress(payload)
})
return () => {
unsubImport()
unsubPack()
}
}, [open]) }, [open])
const handlePickFile = async (): Promise<void> => { const handlePickFile = async (): Promise<void> => {
const result = await ipcCall(() => window.electronAPI.import.pickFile()) const path = await ipcCall(() => window.electronAPI.pack.pickFile('import'))
if (result.canceled || !result.filePath) return if (!path) return
setFilePath(result.filePath) setFilePath(path)
const name = result.filePath.split(/[/\\]/).pop() ?? '' const name = path.split(/[/\\]/).pop() ?? ''
setFileName(name) setFileName(name)
const baseName = name.replace(/\.(txt|md|docx)$/i, '') const kind = detectFileKind(path)
if (!bookName.trim()) setBookName(baseName) setFileKind(kind)
setPreview(null)
if (kind === 'text') {
const baseName = name.replace(/\.(txt|md|docx)$/i, '')
if (!bookName.trim()) setBookName(baseName)
}
} }
const handlePreview = async (): Promise<void> => { const handlePreview = async (): Promise<void> => {
if (!filePath) return if (!filePath || fileKind !== 'text') return
const result = await ipcCall(() => const result = await ipcCall(() =>
window.electronAPI.import.preview(filePath, splitMode, customRegex || undefined) window.electronAPI.import.preview(filePath, splitMode, customRegex || undefined)
) )
setPreview(result) setPreview(result)
} }
const openImportedBook = async (bookId: string, name: string): Promise<void> => {
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<void> => { const handleImport = async (): Promise<void> => {
if (!filePath || !bookName.trim()) return if (!filePath) return
if (preview && preview.fileSizeBytes > LARGE_FILE_BYTES) { if (fileKind === 'text' && !bookName.trim()) return
if (fileKind === 'text' && preview && preview.fileSizeBytes > LARGE_FILE_BYTES) {
const ok = confirm( const ok = confirm(
t('import.largeFileConfirm', { size: formatFileSize(preview.fileSizeBytes) }) t('import.largeFileConfirm', { size: formatFileSize(preview.fileSizeBytes) })
) )
if (!ok) return if (!ok) return
} }
setImporting(true) setImporting(true)
setProgress(null) setProgress(null)
try { 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() if (!preview) await handlePreview()
const bookId = await ipcCall(() => const bookId = await ipcCall(() =>
window.electronAPI.import.execute({ window.electronAPI.import.execute({
@@ -92,13 +152,7 @@ export function ImportBookModal(): React.JSX.Element | null {
customRegex: customRegex || undefined customRegex: customRegex || undefined
}) })
) )
await loadBooks() await openImportedBook(bookId, bookName.trim())
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)
} catch { } catch {
showToast(t('import.failed')) showToast(t('import.failed'))
} finally { } finally {
@@ -110,6 +164,7 @@ export function ImportBookModal(): React.JSX.Element | null {
if (!open) return null if (!open) return null
const previewTitles = preview?.chapters.slice(0, 5).map((ch) => ch.title) ?? [] const previewTitles = preview?.chapters.slice(0, 5).map((ch) => ch.title) ?? []
const isPack = fileKind === 'novel' || fileKind === 'novel-all'
return ( return (
<div <div
@@ -144,64 +199,89 @@ export function ImportBookModal(): React.JSX.Element | null {
{t('import.pickFile')} {t('import.pickFile')}
</button> </button>
</div> </div>
<label className="form-label">{t('dialog.newBook.name')}</label> {fileKind === 'novel' && (
<input <p className="text-muted" data-testid="import-pack-hint">
className="form-control form-control--wide" {t('import.novelHint')}
data-testid="import-book-name" </p>
value={bookName} )}
onChange={(e) => setBookName(e.target.value)} {fileKind === 'novel-all' && (
/>
<label className="form-label">{t('dialog.newBook.category')}</label>
<input
className="form-control form-control--wide"
data-testid="import-book-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
/>
<label className="form-label">{t('import.splitMode')}</label>
<select
className="form-control form-control--wide"
data-testid="import-split-mode"
value={splitMode}
onChange={(e) => setSplitMode(e.target.value as ImportSplitMode)}
>
<option value="auto">{t('import.splitAuto')}</option>
<option value="paragraph">{t('import.splitParagraph')}</option>
<option value="regex">{t('import.splitRegex')}</option>
</select>
{splitMode === 'regex' && (
<> <>
<label className="form-label">{t('import.customRegex')}</label> <p className="text-muted">{t('import.novelAllHint')}</p>
<input <label className="form-label">{t('pack.importStrategy')}</label>
<select
className="form-control form-control--wide" className="form-control form-control--wide"
data-testid="import-custom-regex" data-testid="import-pack-strategy"
value={customRegex} value={packStrategy}
onChange={(e) => setCustomRegex(e.target.value)} onChange={(e) => setPackStrategy(e.target.value as PackImportStrategy)}
placeholder="^第.+章" >
/> <option value="new">{t('pack.strategyNew')}</option>
<option value="skip">{t('pack.strategySkip')}</option>
<option value="overwrite">{t('pack.strategyOverwrite')}</option>
</select>
</> </>
)} )}
<button {fileKind === 'text' && (
type="button" <>
className="btn" <label className="form-label">{t('dialog.newBook.name')}</label>
data-testid="import-preview-btn" <input
disabled={!filePath} className="form-control form-control--wide"
onClick={() => void handlePreview()} data-testid="import-book-name"
> value={bookName}
{t('import.preview')} onChange={(e) => setBookName(e.target.value)}
</button> />
{preview && ( <label className="form-label">{t('dialog.newBook.category')}</label>
<div className="import-preview" data-testid="import-preview"> <input
<p> className="form-control form-control--wide"
{t('import.chapterCount', { count: preview.chapters.length })} data-testid="import-book-category"
{preview.warnings.length > 0 && ` (${preview.warnings.join(', ')})`} value={category}
</p> onChange={(e) => setCategory(e.target.value)}
<ul className="import-preview-list"> />
{previewTitles.map((title, i) => ( <label className="form-label">{t('import.splitMode')}</label>
<li key={i}>{title}</li> <select
))} className="form-control form-control--wide"
</ul> data-testid="import-split-mode"
</div> value={splitMode}
onChange={(e) => setSplitMode(e.target.value as ImportSplitMode)}
>
<option value="auto">{t('import.splitAuto')}</option>
<option value="paragraph">{t('import.splitParagraph')}</option>
<option value="regex">{t('import.splitRegex')}</option>
</select>
{splitMode === 'regex' && (
<>
<label className="form-label">{t('import.customRegex')}</label>
<input
className="form-control form-control--wide"
data-testid="import-custom-regex"
value={customRegex}
onChange={(e) => setCustomRegex(e.target.value)}
placeholder="^第.+章"
/>
</>
)}
<button
type="button"
className="btn"
data-testid="import-preview-btn"
disabled={!filePath}
onClick={() => void handlePreview()}
>
{t('import.preview')}
</button>
{preview && (
<div className="import-preview" data-testid="import-preview">
<p>
{t('import.chapterCount', { count: preview.chapters.length })}
{preview.warnings.length > 0 && ` (${preview.warnings.join(', ')})`}
</p>
<ul className="import-preview-list">
{previewTitles.map((title, i) => (
<li key={i}>{title}</li>
))}
</ul>
</div>
)}
</>
)} )}
{progress && ( {progress && (
<div className="import-progress" data-testid="import-progress-bar"> <div className="import-progress" data-testid="import-progress-bar">
@@ -209,7 +289,10 @@ export function ImportBookModal(): React.JSX.Element | null {
className="import-progress-fill" className="import-progress-fill"
style={{ width: `${(progress.current / progress.total) * 100}%` }} style={{ width: `${(progress.current / progress.total) * 100}%` }}
/> />
<span>{t('import.progress', { current: progress.current, total: progress.total })}</span> <span>
{progress.message ??
t('import.progress', { current: progress.current, total: progress.total })}
</span>
</div> </div>
)} )}
</div> </div>
@@ -221,7 +304,7 @@ export function ImportBookModal(): React.JSX.Element | null {
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
data-testid="import-execute-btn" data-testid="import-execute-btn"
disabled={!filePath || !bookName.trim() || importing} disabled={!filePath || (!isPack && !bookName.trim()) || importing}
onClick={() => void handleImport()} onClick={() => void handleImport()}
> >
{importing ? t('import.importing') : t('import.start')} {importing ? t('import.importing') : t('import.start')}
@@ -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<PackProgressEvent | null>(null)
useEffect(() => {
const unsub = window.electronAPI.onPackProgress((payload) => {
setProgress(payload)
})
return unsub
}, [])
const handleExportBook = async (): Promise<void> => {
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 (
<div data-testid="backup-settings-tab">
<p style={{ color: 'var(--text-secondary)', marginBottom: 16, lineHeight: 1.6 }}>
{t('backup.desc')}
</p>
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
<span>{t('backup.exportCurrentBook')}</span>
<button
type="button"
className="btn"
data-testid="backup-export-book"
disabled={!bookId || exportingBook}
onClick={() => void handleExportBook()}
>
{exportingBook ? t('pack.exporting') : t('backup.exportBookBtn')}
</button>
{!bookId && <small style={{ color: 'var(--text-muted)' }}>{t('backup.openBookHint')}</small>}
</div>
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
<span>{t('backup.exportAllBooks')}</span>
<button
type="button"
className="btn"
data-testid="backup-export-all"
onClick={() => setExportAllModalOpen(true)}
>
{t('backup.exportAllBtn')}
</button>
</div>
{progress && (
<div className="import-progress" data-testid="backup-progress" style={{ marginTop: 16 }}>
<div className="import-progress-fill" style={{ width: `${progressPct}%` }} />
<span>
{progress.message ??
t('pack.progress', { current: progress.current, total: progress.total })}
</span>
</div>
)}
<p style={{ color: 'var(--text-muted)', fontSize: 12, marginTop: 24 }}>
{t('backup.formatHint')}
</p>
</div>
)
}
@@ -5,6 +5,7 @@ import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor' import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage' import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab' import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
import { BackupSettingsTab } from '@renderer/components/settings/BackupSettingsTab'
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab' import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
const THEMES: { id: ThemeId; key: string }[] = [ const THEMES: { id: ThemeId; key: string }[] = [
@@ -21,7 +22,7 @@ export function SettingsPage(): React.JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const settings = useSettingsStore() const settings = useSettingsStore()
const [section, setSection] = useState< const [section, setSection] = useState<
'general' | 'ai' | 'templates' | 'submission' | 'shortcuts' | 'about' 'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'shortcuts' | 'about'
>('general') >('general')
return ( return (
@@ -62,6 +63,15 @@ export function SettingsPage(): React.JSX.Element {
> >
{t('settings.submission')} {t('settings.submission')}
</div> </div>
<div
className={`settings-nav-item ${section === 'backup' ? 'active' : ''}`}
onClick={() => setSection('backup')}
role="button"
tabIndex={0}
data-testid="settings-nav-backup"
>
{t('settings.backup')}
</div>
<div <div
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`} className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
onClick={() => setSection('shortcuts')} onClick={() => setSection('shortcuts')}
@@ -249,10 +259,11 @@ export function SettingsPage(): React.JSX.Element {
{section === 'ai' && <AiSettingsPage />} {section === 'ai' && <AiSettingsPage />}
{section === 'templates' && <TemplateSettingsTab />} {section === 'templates' && <TemplateSettingsTab />}
{section === 'submission' && <SubmissionPresetTab />} {section === 'submission' && <SubmissionPresetTab />}
{section === 'backup' && <BackupSettingsTab />}
{section === 'shortcuts' && <ShortcutEditor />} {section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && ( {section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}> <p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v1.2.0 {t('app.name')} v1.3.0
<br /> <br />
{t('app.tagline')} {t('app.tagline')}
</p> </p>
+4
View File
@@ -10,6 +10,7 @@ interface AppState {
landmarksModalOpen: boolean landmarksModalOpen: boolean
inspirationModalOpen: boolean inspirationModalOpen: boolean
importBookModalOpen: boolean importBookModalOpen: boolean
exportAllModalOpen: boolean
focusMode: boolean focusMode: boolean
namingCheckOpen: boolean namingCheckOpen: boolean
toast: string | null toast: string | null
@@ -20,6 +21,7 @@ interface AppState {
setLandmarksModalOpen: (open: boolean) => void setLandmarksModalOpen: (open: boolean) => void
setInspirationModalOpen: (open: boolean) => void setInspirationModalOpen: (open: boolean) => void
setImportBookModalOpen: (open: boolean) => void setImportBookModalOpen: (open: boolean) => void
setExportAllModalOpen: (open: boolean) => void
setFocusMode: (on: boolean) => void setFocusMode: (on: boolean) => void
setNamingCheckOpen: (open: boolean) => void setNamingCheckOpen: (open: boolean) => void
showToast: (message: string) => void showToast: (message: string) => void
@@ -34,6 +36,7 @@ export const useAppStore = create<AppState>((set) => ({
landmarksModalOpen: false, landmarksModalOpen: false,
inspirationModalOpen: false, inspirationModalOpen: false,
importBookModalOpen: false, importBookModalOpen: false,
exportAllModalOpen: false,
focusMode: false, focusMode: false,
namingCheckOpen: false, namingCheckOpen: false,
toast: null, toast: null,
@@ -44,6 +47,7 @@ export const useAppStore = create<AppState>((set) => ({
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }), setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }), setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }), setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
setExportAllModalOpen: (exportAllModalOpen) => set({ exportAllModalOpen }),
setFocusMode: (focusMode) => set({ focusMode }), setFocusMode: (focusMode) => set({ focusMode }),
setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }), setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }),
showToast: (toast) => { showToast: (toast) => {
+13
View File
@@ -59,6 +59,7 @@ import type {
ImportExecuteParams, ImportExecuteParams,
ImportSplitMode ImportSplitMode
} from './types' } from './types'
import type { PackImportStrategy, PackImportReport, PackProgressEvent } from './novel-pack'
export interface ElectronAPI { export interface ElectronAPI {
window: { window: {
@@ -445,6 +446,17 @@ export interface ElectronAPI {
copyClipboard: (text: string) => Promise<IpcResult<void>> copyClipboard: (text: string) => Promise<IpcResult<void>>
saveTxt: (text: string, defaultName: string) => Promise<IpcResult<string | null>> saveTxt: (text: string, defaultName: string) => Promise<IpcResult<string | null>>
} }
pack: {
pickFile: (
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
) => Promise<IpcResult<string | null>>
exportBook: (bookId: string, destPath: string) => Promise<IpcResult<void>>
importBook: (filePath: string) => Promise<IpcResult<string>>
exportAll: (destPath: string) => Promise<IpcResult<void>>
importAll: (filePath: string, strategy: PackImportStrategy) => Promise<IpcResult<PackImportReport>>
estimateExportAll: () => Promise<IpcResult<{ bytes: number; needsConfirm: boolean }>>
cancel: () => Promise<IpcResult<void>>
}
bridge: { bridge: {
get: ( get: (
bookId: string, bookId: string,
@@ -471,6 +483,7 @@ export interface ElectronAPI {
onPomodoroTick: (callback: (state: PomodoroState) => void) => () => void onPomodoroTick: (callback: (state: PomodoroState) => void) => () => void
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void
onImportProgress: (callback: (payload: { current: number; total: number }) => void) => () => void onImportProgress: (callback: (payload: { current: number; total: number }) => void) => () => void
onPackProgress: (callback: (payload: PackProgressEvent) => void) => () => void
} }
declare global { declare global {