feat: 实现笔临 P0/P1 Electron 桌面应用 v0.1.0

交付写作核心(TipTap、自动保存、分卷分章)、Onboarding、7 主题与双语 i18n、快捷键设置;主进程使用 node:sqlite;补全 Vitest 与 Playwright E2E;统一 design/ 目录并移除 desigin 拼写错误路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 10:39:20 +08:00
parent a06038035b
commit 011bd6d4ca
70 changed files with 14245 additions and 84 deletions
+39
View File
@@ -0,0 +1,39 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { BookMeta, CreateBookParams } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerBookHandlers(registry: BookRegistryService): void {
ipcMain.handle(IPC.BOOK_LIST, () => wrap(() => registry.list()))
ipcMain.handle(IPC.BOOK_CREATE, (_event, params: CreateBookParams) =>
wrap(() => registry.create(params))
)
ipcMain.handle(IPC.BOOK_DELETE, (_event, { bookId }: { bookId: string }) =>
wrap(() => {
registry.delete(bookId)
})
)
ipcMain.handle(IPC.BOOK_OPEN, (_event, { bookId }: { bookId: string }) =>
wrap(() => registry.open(bookId))
)
ipcMain.handle(
IPC.BOOK_UPDATE_META,
(
_event,
{
bookId,
...patch
}: {
bookId: string
lastChapterId?: string | null
lastOpenedAt?: string
status?: BookMeta['status']
}
) => wrap(() => registry.updateMeta(bookId, patch))
)
}
+96
View File
@@ -0,0 +1,96 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ChapterStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerChapterHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.VOLUME_CREATE,
(_event, { bookId, name }: { bookId: string; name: string }) =>
wrap(() => {
const repo = registry.getVolumeRepo(bookId)
const volumes = repo.list()
return repo.create(name, volumes.length)
})
)
ipcMain.handle(
IPC.VOLUME_UPDATE,
(
_event,
{ bookId, volumeId, name, description }: { bookId: string; volumeId: string; name?: string; description?: string }
) => wrap(() => registry.getVolumeRepo(bookId).update(volumeId, { name, description }))
)
ipcMain.handle(
IPC.VOLUME_DELETE,
(_event, { bookId, volumeId }: { bookId: string; volumeId: string }) =>
wrap(() => {
registry.getVolumeRepo(bookId).delete(volumeId)
})
)
ipcMain.handle(
IPC.CHAPTER_CREATE,
(
_event,
{ bookId, volumeId, title }: { bookId: string; volumeId: string; title: string }
) =>
wrap(() => {
const repo = registry.getChapterRepo(bookId)
const sortOrder = repo.nextSortOrder(volumeId)
return repo.create(volumeId, title, sortOrder)
})
)
ipcMain.handle(
IPC.CHAPTER_GET,
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => {
const ch = registry.getChapterRepo(bookId).get(chapterId)
if (!ch) throw new Error('Chapter not found')
return ch
})
)
ipcMain.handle(
IPC.CHAPTER_UPDATE,
(
_event,
{
bookId,
chapterId,
title,
content,
status,
cursorOffset
}: {
bookId: string
chapterId: string
title?: string
content?: string
status?: ChapterStatus
cursorOffset?: number
}
) =>
wrap(() => {
const chapter = registry.getChapterRepo(bookId).update(chapterId, {
title,
content,
status,
cursorOffset
})
registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter
})
)
ipcMain.handle(
IPC.CHAPTER_DELETE,
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => {
registry.getChapterRepo(bookId).delete(chapterId)
})
)
}
+11
View File
@@ -0,0 +1,11 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { GlobalSettingsService } from '../services/global-settings'
import { wrap } from '../result'
export function registerSettingsHandlers(settings: GlobalSettingsService): void {
ipcMain.handle(IPC.SETTINGS_GET, () => wrap(() => settings.get()))
ipcMain.handle(IPC.SETTINGS_UPDATE, (_event, partial) =>
wrap(() => settings.update(partial))
)
}
+18
View File
@@ -0,0 +1,18 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ActionId } from '../../../shared/types'
import { ShortcutManager } from '../shortcuts/manager'
import { wrap } from '../result'
export function registerShortcutHandlers(shortcuts: ShortcutManager): void {
ipcMain.handle(IPC.SHORTCUT_GET_ALL, () => wrap(() => shortcuts.getAll()))
ipcMain.handle(
IPC.SHORTCUT_REGISTER,
(_event, { action, accelerator }: { action: ActionId; accelerator: string }) =>
wrap(() => {
const ok = shortcuts.save(action, accelerator)
if (!ok) throw new Error('快捷键注册失败,可能与其他应用冲突')
})
)
}
+30
View File
@@ -0,0 +1,30 @@
import { join } from 'path'
import { app } from 'electron'
import { GlobalSettingsService } from '../services/global-settings'
import { BookRegistryService } from '../services/book-registry'
import { ShortcutManager } from '../shortcuts/manager'
import { registerSettingsHandlers } from './handlers/settings.handler'
import { registerBookHandlers } from './handlers/book.handler'
import { registerChapterHandlers } from './handlers/chapter.handler'
import { registerShortcutHandlers } from './handlers/shortcut.handler'
let shortcutManager: ShortcutManager | null = null
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
const userData = app.getPath('userData')
const settings = new GlobalSettingsService(userData)
const books = new BookRegistryService(userData)
shortcutManager = new ShortcutManager(settings)
registerSettingsHandlers(settings)
registerBookHandlers(books)
registerChapterHandlers(books)
registerShortcutHandlers(shortcutManager)
return { settings, books, shortcuts: shortcutManager }
}
export function getShortcutManager(): ShortcutManager | null {
return shortcutManager
}
+18
View File
@@ -0,0 +1,18 @@
import { ErrorCode, type IpcError, type IpcResult } from '../../shared/types'
export function ok<T>(data: T): IpcResult<T> {
return { ok: true, data }
}
export function fail(code: ErrorCode, message: string, recoverable: boolean): IpcResult<never> {
return { ok: false, error: { code, message, recoverable } }
}
export function wrap<T>(fn: () => T, recoverable = true): IpcResult<T> {
try {
return ok(fn())
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
return fail(ErrorCode.UNKNOWN, message, recoverable)
}
}