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
+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 () => {
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 }]
+31
View File
@@ -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<IpcResult<string | null>> =>
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: {
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)
}
}
+2
View File
@@ -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 {
<SearchModal />
<InspirationModal />
<ImportBookModal />
<ExportAllModal />
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
{toast && <div className="toast">{toast}</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 { 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<ExportKind>('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<void> => {
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
return (
@@ -80,53 +102,86 @@ export function ExportModal(): React.JSX.Element | null {
<div className="modal-header">
<h3>
{t('export.title')}
{chapter ? `${chapter.title}` : ''}
{exportKind === 'submission' && chapter ? `${chapter.title}` : ''}
{exportKind === 'novel' && bookName ? `${bookName}` : ''}
</h3>
<button type="button" className="modal-close" onClick={close}>
</button>
</div>
<div className="modal-body">
<label className="form-label">{t('export.submissionPresets')}</label>
<label className="form-label">{t('export.kind')}</label>
<select
className="form-control form-control--wide"
data-testid="export-preset-select"
value={presetId}
onChange={(e) => setPresetId(e.target.value)}
data-testid="export-kind-select"
value={exportKind}
onChange={(e) => setExportKind(e.target.value as ExportKind)}
>
{presets.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
<option value="submission">{t('export.kindSubmission')}</option>
<option value="novel">{t('export.kindNovel')}</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>
{exportKind === 'submission' ? (
<>
<label className="form-label">{t('export.submissionPresets')}</label>
<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 className="modal-footer">
<button type="button" className="btn" onClick={close}>
{t('dialog.cancel')}
</button>
<button
type="button"
className="btn"
data-testid="export-save-txt"
disabled={!chapterId}
onClick={() => void handleSaveTxt()}
>
{t('export.saveTxt')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="export-copy-clipboard"
disabled={!chapterId}
onClick={() => void handleCopy()}
>
{t('export.copyClipboard')}
</button>
{exportKind === 'submission' ? (
<>
<button
type="button"
className="btn"
data-testid="export-save-txt"
disabled={!chapterId}
onClick={() => void handleSaveTxt()}
>
{t('export.saveTxt')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="export-copy-clipboard"
disabled={!chapterId}
onClick={() => void handleCopy()}
>
{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>
+2 -2
View File
@@ -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')}
</button>
@@ -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<ImportSplitMode>('auto')
const [customRegex, setCustomRegex] = useState('')
const [packStrategy, setPackStrategy] = useState<PackImportStrategy>('new')
const [preview, setPreview] = useState<ImportPreviewResult | null>(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<void> => {
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<void> => {
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<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> => {
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 (
<div
@@ -144,64 +199,89 @@ export function ImportBookModal(): React.JSX.Element | null {
{t('import.pickFile')}
</button>
</div>
<label className="form-label">{t('dialog.newBook.name')}</label>
<input
className="form-control form-control--wide"
data-testid="import-book-name"
value={bookName}
onChange={(e) => setBookName(e.target.value)}
/>
<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' && (
{fileKind === 'novel' && (
<p className="text-muted" data-testid="import-pack-hint">
{t('import.novelHint')}
</p>
)}
{fileKind === 'novel-all' && (
<>
<label className="form-label">{t('import.customRegex')}</label>
<input
<p className="text-muted">{t('import.novelAllHint')}</p>
<label className="form-label">{t('pack.importStrategy')}</label>
<select
className="form-control form-control--wide"
data-testid="import-custom-regex"
value={customRegex}
onChange={(e) => setCustomRegex(e.target.value)}
placeholder="^第.+章"
/>
data-testid="import-pack-strategy"
value={packStrategy}
onChange={(e) => setPackStrategy(e.target.value as PackImportStrategy)}
>
<option value="new">{t('pack.strategyNew')}</option>
<option value="skip">{t('pack.strategySkip')}</option>
<option value="overwrite">{t('pack.strategyOverwrite')}</option>
</select>
</>
)}
<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>
{fileKind === 'text' && (
<>
<label className="form-label">{t('dialog.newBook.name')}</label>
<input
className="form-control form-control--wide"
data-testid="import-book-name"
value={bookName}
onChange={(e) => setBookName(e.target.value)}
/>
<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>
<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 && (
<div className="import-progress" data-testid="import-progress-bar">
@@ -209,7 +289,10 @@ export function ImportBookModal(): React.JSX.Element | null {
className="import-progress-fill"
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>
@@ -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')}
@@ -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 { 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')}
</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
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
onClick={() => setSection('shortcuts')}
@@ -249,10 +259,11 @@ export function SettingsPage(): React.JSX.Element {
{section === 'ai' && <AiSettingsPage />}
{section === 'templates' && <TemplateSettingsTab />}
{section === 'submission' && <SubmissionPresetTab />}
{section === 'backup' && <BackupSettingsTab />}
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v1.2.0
{t('app.name')} v1.3.0
<br />
{t('app.tagline')}
</p>
+4
View File
@@ -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<AppState>((set) => ({
landmarksModalOpen: false,
inspirationModalOpen: false,
importBookModalOpen: false,
exportAllModalOpen: false,
focusMode: false,
namingCheckOpen: false,
toast: null,
@@ -44,6 +47,7 @@ export const useAppStore = create<AppState>((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) => {
+13
View File
@@ -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<IpcResult<void>>
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: {
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 {