# 笔临 P0/P1 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在 Windows 上交付可运行的 Electron 桌面应用 v0.1.0:完成 Onboarding → 创建书籍 → 分卷分章 → TipTap 写作 → 自动保存 → 重启恢复;7 主题、双语 i18n、快捷键可用;AI/高级功能为占位。 **Architecture:** electron-vite 单体仓库;主进程 better-sqlite3 + JSON 设置;渲染进程 React + Zustand(全局)+ Jotai(编辑器);IPC 统一 `IpcResult` 信封;UI 从 `design/ui_pc.html` 提取 CSS,Radix 封装交互组件。 **Tech Stack:** Electron 33+、electron-vite、React 18、TypeScript、TipTap、better-sqlite3、Zustand、Jotai、Radix UI、i18next、Vitest、Playwright **Spec:** `docs/superpowers/specs/2026-07-05-bilin-p0-p1-design.md` --- ## File Map(实现完成后应存在的核心文件) | 文件 | 职责 | |------|------| | `src/shared/types.ts` | 主/渲染共享类型:GlobalSettings、BookMeta、Chapter、IpcResult | | `src/shared/ipc-channels.ts` | IPC 通道名常量 | | `src/shared/default-shortcuts.ts` | 默认快捷键表 | | `src/main/index.ts` | 窗口生命周期、注册 IPC | | `src/main/ipc/result.ts` | ok()/fail() 工具 | | `src/main/ipc/register.ts` | 汇总注册 handlers | | `src/main/services/global-settings.ts` | 读写 global_settings.json | | `src/main/services/book-registry.ts` | 读写 books_registry.json + 建书 | | `src/main/services/word-count.ts` | 中文字数统计 | | `src/main/db/connection.ts` | 打开/关闭书籍 SQLite | | `src/main/db/migrate.ts` | schema 迁移 runner | | `src/main/db/schema-v1.sql` | 初始表结构 | | `src/main/db/repositories/volume.repo.ts` | 分卷 CRUD | | `src/main/db/repositories/chapter.repo.ts` | 章节 CRUD | | `src/main/shortcuts/manager.ts` | globalShortcut 注册 | | `src/preload/index.ts` | contextBridge 暴露 electronAPI | | `src/renderer/main.tsx` | React 入口 + Provider | | `src/renderer/styles/themes.css` | 7 主题变量 | | `src/renderer/styles/layout.css` | 布局(从 ui_pc.html 提取) | | `src/renderer/i18n/index.ts` | i18next 初始化 | | `public/locales/zh-CN/translation.json` | 中文文案 | | `public/locales/en/translation.json` | 英文文案 | | `src/renderer/lib/ipc-client.ts` | ipcCall 封装 | | `src/renderer/stores/*.ts` | Zustand stores | | `src/renderer/atoms/editorAtoms.ts` | Jotai atoms | | `src/renderer/hooks/useAutoSave.ts` | 2s debounce 保存 | | `src/renderer/components/layout/AppShell.tsx` | 三视图路由 | | `src/renderer/components/editor/TipTapEditor.tsx` | 编辑器 | | `tests/main/word-count.test.ts` | 字数单元测试 | | `tests/main/chapter.repo.test.ts` | 章节 Repository 集成测试 | --- ## Task 1: 工程脚手架 **Files:** - Create: `package.json`, `electron.vite.config.ts`, `tsconfig.json`, `tsconfig.node.json`, `tsconfig.web.json` - Create: `src/main/index.ts`, `src/preload/index.ts`, `src/renderer/index.html`, `src/renderer/main.tsx`, `src/renderer/App.tsx` - Create: `.gitignore`(合并现有规则) - [ ] **Step 1: 初始化 package.json 与依赖** ```bash cd D:\workspace\bilin2 npm init -y npm install electron npm install react react-dom npm install zustand jotai i18next react-i18next npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-underline @tiptap/extension-placeholder npm install @radix-ui/react-dialog @radix-ui/react-select @radix-ui/react-tabs @radix-ui/react-toast @radix-ui/react-checkbox npm install better-sqlite3 uuid npm install -D electron-vite vite typescript @types/react @types/react-dom @types/better-sqlite3 @types/uuid npm install -D vitest @vitest/coverage-v8 eslint @eslint/js typescript-eslint npm install -D @playwright/test playwright npm install -D electron-builder ``` - [ ] **Step 2: 写入 package.json scripts** ```json { "name": "bilin", "version": "0.1.0", "main": "./out/main/index.js", "scripts": { "dev": "electron-vite dev", "build": "electron-vite build", "preview": "electron-vite preview", "test": "vitest run", "test:watch": "vitest", "rebuild": "electron-rebuild -f -w better-sqlite3", "postinstall": "electron-rebuild -f -w better-sqlite3" } } ``` 额外安装:`npm install -D @electron/rebuild` - [ ] **Step 3: 写入 electron.vite.config.ts** ```typescript import { resolve } from 'path' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import react from '@vitejs/plugin-react' export default defineConfig({ main: { plugins: [externalizeDepsPlugin({ exclude: ['better-sqlite3'] })], build: { rollupOptions: { input: { index: resolve(__dirname, 'src/main/index.ts') } } } }, preload: { plugins: [externalizeDepsPlugin()], build: { rollupOptions: { input: { index: resolve(__dirname, 'src/preload/index.ts') } } } }, renderer: { resolve: { alias: { '@shared': resolve('src/shared'), '@renderer': resolve('src/renderer') } }, plugins: [react()], build: { rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html') } } } } }) ``` 安装:`npm install -D @vitejs/plugin-react` - [ ] **Step 4: 最小主进程窗口(frameless + 自定义标题栏预留)** `src/main/index.ts`: ```typescript import { app, BrowserWindow, ipcMain } from 'electron' import { join } from 'path' function createWindow(): void { const win = new BrowserWindow({ width: 1280, height: 800, minWidth: 960, minHeight: 600, frame: false, titleBarStyle: 'hidden', webPreferences: { preload: join(__dirname, '../preload/index.js'), contextIsolation: true, nodeIntegration: false } }) if (process.env.ELECTRON_RENDERER_URL) { win.loadURL(process.env.ELECTRON_RENDERER_URL) } else { win.loadFile(join(__dirname, '../renderer/index.html')) } } app.whenReady().then(() => { createWindow() ipcMain.handle('window:minimize', (e) => BrowserWindow.fromWebContents(e.sender)?.minimize()) ipcMain.handle('window:maximize', (e) => { const w = BrowserWindow.fromWebContents(e.sender) if (!w) return w.isMaximized() ? w.unmaximize() : w.maximize() }) ipcMain.handle('window:close', (e) => BrowserWindow.fromWebContents(e.sender)?.close()) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) ``` - [ ] **Step 5: 最小 preload + 渲染占位** `src/preload/index.ts`: ```typescript import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('electronAPI', { window: { minimize: () => ipcRenderer.invoke('window:minimize'), maximize: () => ipcRenderer.invoke('window:maximize'), close: () => ipcRenderer.invoke('window:close') } }) ``` `src/renderer/main.tsx`: ```tsx import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' ReactDOM.createRoot(document.getElementById('root')!).render( ) ``` `src/renderer/App.tsx`: ```tsx export default function App() { return
笔临 loading...
} ``` - [ ] **Step 6: 验证启动** ```bash npm run dev ``` Expected: Electron 窗口打开,显示「笔临 loading...」,无 main/preload 报错。 - [ ] **Step 7: Commit** ```bash git add package.json electron.vite.config.ts tsconfig*.json src/ .gitignore git commit -m "chore: scaffold electron-vite react typescript project" ``` --- ## Task 2: 共享类型与 IPC 基础 **Files:** - Create: `src/shared/types.ts` - Create: `src/shared/ipc-channels.ts` - Create: `src/shared/default-shortcuts.ts` - Create: `src/main/ipc/result.ts` - [ ] **Step 1: 写入共享类型** `src/shared/types.ts`: ```typescript export type ThemeId = 'default' | 'bamboo' | 'moonlit' | 'ricepaper' | 'mist' | 'teagarden' | 'twilight' export type Language = 'zh-CN' | 'en' export type ChapterStatus = 'draft' | 'review' | 'done' export type BookStatus = 'draft' | 'ongoing' | 'done' export type ActionId = | 'saveChapter' | 'newChapter' | 'focusMode' | 'globalSearch' | 'closeTab' | 'openReference' | 'insertLandmark' | 'openLandmarks' | 'exportBook' | 'toggleTTS' | 'captureInspiration' export enum ErrorCode { DB_READ_FAILED = 'DB_READ_FAILED', DB_WRITE_FAILED = 'DB_WRITE_FAILED', FILE_NOT_FOUND = 'FILE_NOT_FOUND', IMPORT_INVALID = 'IMPORT_INVALID', UNKNOWN = 'UNKNOWN' } export interface IpcError { code: ErrorCode message: string recoverable: boolean } export type IpcResult = | { ok: true; data: T } | { ok: false; error: IpcError } export interface GlobalSettings { penName: string onboardingCompleted: boolean theme: ThemeId language: Language dailyWordGoal: number shortcuts: Record } export interface BookMeta { id: string name: string category: string targetWordCount: number | null dbPath: string status: BookStatus lastOpenedAt: string lastChapterId: string | null createdAt: string } export interface CreateBookParams { name: string category: string targetWordCount?: number | null createSampleChapter?: boolean } export interface Volume { id: string name: string description: string sortOrder: number } export interface Chapter { id: string volumeId: string | null title: string content: string status: ChapterStatus wordCount: number sortOrder: number cursorOffset: number } export interface BookOpenResult { meta: BookMeta volumes: Volume[] chapters: Chapter[] } ``` - [ ] **Step 2: IPC 通道常量** `src/shared/ipc-channels.ts`: ```typescript export const IPC = { SETTINGS_GET: 'settings:get', SETTINGS_UPDATE: 'settings:update', BOOK_LIST: 'book:list', BOOK_CREATE: 'book:create', BOOK_DELETE: 'book:delete', BOOK_OPEN: 'book:open', VOLUME_CREATE: 'volume:create', VOLUME_UPDATE: 'volume:update', VOLUME_DELETE: 'volume:delete', CHAPTER_CREATE: 'chapter:create', CHAPTER_GET: 'chapter:get', CHAPTER_UPDATE: 'chapter:update', CHAPTER_DELETE: 'chapter:delete', SHORTCUT_GET_ALL: 'shortcut:getAll', SHORTCUT_REGISTER: 'shortcut:register', SHORTCUT_TRIGGERED: 'shortcut:triggered' } as const ``` - [ ] **Step 3: 默认快捷键** `src/shared/default-shortcuts.ts`: ```typescript import type { ActionId } from './types' export const DEFAULT_SHORTCUTS: Record = { saveChapter: 'CommandOrControl+S', newChapter: 'CommandOrControl+Shift+N', focusMode: 'CommandOrControl+Alt+F', globalSearch: 'CommandOrControl+Shift+F', closeTab: 'CommandOrControl+W', openReference: 'CommandOrControl+Shift+P', insertLandmark: 'CommandOrControl+Shift+M', openLandmarks: 'CommandOrControl+Shift+L', exportBook: 'CommandOrControl+Shift+E', toggleTTS: 'CommandOrControl+Shift+R', captureInspiration: 'CommandOrControl+Shift+I' } ``` - [ ] **Step 4: IpcResult 工具** `src/main/ipc/result.ts`: ```typescript import { ErrorCode, IpcError, IpcResult } from '../../shared/types' export function ok(data: T): IpcResult { return { ok: true, data } } export function fail(code: ErrorCode, message: string, recoverable: boolean): IpcResult { return { ok: false, error: { code, message, recoverable } } } export function wrap(fn: () => T, recoverable = true): IpcResult { try { return ok(fn()) } catch (e) { const message = e instanceof Error ? e.message : String(e) return fail(ErrorCode.UNKNOWN, message, recoverable) } } ``` - [ ] **Step 5: Commit** ```bash git add src/shared/ src/main/ipc/result.ts git commit -m "feat: add shared types and IpcResult helpers" ``` --- ## Task 3: GlobalSettings 服务 + 测试 **Files:** - Create: `src/main/services/global-settings.ts` - Create: `tests/main/global-settings.test.ts` - Create: `vitest.config.ts` - [ ] **Step 1: 写失败测试** `tests/main/global-settings.test.ts`: ```typescript import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { GlobalSettingsService } from '../../src/main/services/global-settings' let dir: string let svc: GlobalSettingsService beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'bilin-settings-')) svc = new GlobalSettingsService(dir) }) afterEach(() => rmSync(dir, { recursive: true, force: true })) describe('GlobalSettingsService', () => { it('returns defaults when file missing', () => { const s = svc.get() expect(s.onboardingCompleted).toBe(false) expect(s.theme).toBe('default') expect(s.language).toBe('zh-CN') }) it('persists penName update', () => { svc.update({ penName: '山海' }) const s2 = new GlobalSettingsService(dir) expect(s2.get().penName).toBe('山海') }) }) ``` - [ ] **Step 2: 运行确认 FAIL** ```bash npm run test -- tests/main/global-settings.test.ts ``` Expected: FAIL — module not found - [ ] **Step 3: 实现 GlobalSettingsService** `src/main/services/global-settings.ts`: ```typescript import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs' import { join } from 'path' import { DEFAULT_SHORTCUTS } from '../../shared/default-shortcuts' import type { GlobalSettings } from '../../shared/types' const FILE = 'global_settings.json' function defaults(): GlobalSettings { return { penName: '未命名作者', onboardingCompleted: false, theme: 'default', language: 'zh-CN', dailyWordGoal: 0, shortcuts: { ...DEFAULT_SHORTCUTS } } } export class GlobalSettingsService { constructor(private userDataDir: string) { if (!existsSync(userDataDir)) mkdirSync(userDataDir, { recursive: true }) } private filePath(): string { return join(this.userDataDir, FILE) } get(): GlobalSettings { if (!existsSync(this.filePath())) return defaults() const raw = JSON.parse(readFileSync(this.filePath(), 'utf-8')) return { ...defaults(), ...raw, shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts } } } update(partial: Partial): GlobalSettings { const next = { ...this.get(), ...partial } if (partial.shortcuts) next.shortcuts = { ...this.get().shortcuts, ...partial.shortcuts } writeFileSync(this.filePath(), JSON.stringify(next, null, 2), 'utf-8') return next } } ``` `vitest.config.ts`: ```typescript import { defineConfig } from 'vitest/config' import { resolve } from 'path' export default defineConfig({ test: { environment: 'node', include: ['tests/**/*.test.ts'] }, resolve: { alias: { '@shared': resolve('src/shared') } } }) ``` - [ ] **Step 4: 运行确认 PASS** ```bash npm run test -- tests/main/global-settings.test.ts ``` - [ ] **Step 5: Commit** ```bash git add src/main/services/global-settings.ts tests/ vitest.config.ts git commit -m "feat: add GlobalSettingsService with tests" ``` --- ## Task 4: SQLite 迁移与 Repository **Files:** - Create: `src/main/db/schema-v1.sql` - Create: `src/main/db/migrate.ts` - Create: `src/main/db/connection.ts` - Create: `src/main/db/repositories/volume.repo.ts` - Create: `src/main/db/repositories/chapter.repo.ts` - Create: `src/main/services/word-count.ts` - Create: `tests/main/word-count.test.ts` - Create: `tests/main/chapter.repo.test.ts` - [ ] **Step 1: schema-v1.sql** — 复制 spec §7.4 完整 SQL 到 `src/main/db/schema-v1.sql` - [ ] **Step 2: 字数统计测试与实现** `tests/main/word-count.test.ts`: ```typescript import { describe, it, expect } from 'vitest' import { countWords } from '../../src/main/services/word-count' describe('countWords', () => { it('counts CJK characters excluding whitespace', () => { expect(countWords('林远深吸一口气')).toBe(7) }) it('returns 0 for empty', () => { expect(countWords('')).toBe(0) }) }) ``` `src/main/services/word-count.ts`: ```typescript export function countWords(text: string): number { const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '') const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) const latin = stripped.match(/[a-zA-Z0-9]+/g) return (cjk?.length ?? 0) + (latin?.length ?? 0) } ``` - [ ] **Step 3: migrate.ts** ```typescript import Database from 'better-sqlite3' import { readFileSync } from 'fs' import { join } from 'path' const CURRENT_VERSION = 1 export function migrate(db: Database.Database, schemaDir: string): void { db.exec(`CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')), description TEXT )`) const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number | null } const current = row?.v ?? 0 if (current >= CURRENT_VERSION) return const sql = readFileSync(join(schemaDir, 'schema-v1.sql'), 'utf-8') db.exec(sql) db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial schema') } ``` - [ ] **Step 4: connection.ts** ```typescript import Database from 'better-sqlite3' import { mkdirSync, existsSync } from 'fs' import { dirname } from 'path' import { migrate } from './migrate' const pools = new Map() export function openBookDb(dbPath: string, schemaDir: string): Database.Database { if (pools.has(dbPath)) return pools.get(dbPath)! const dir = dirname(dbPath) if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) const db = new Database(dbPath) migrate(db, schemaDir) pools.set(dbPath, db) return db } ``` - [ ] **Step 5: chapter.repo.ts(核心方法)** ```typescript import Database from 'better-sqlite3' import { randomUUID } from 'crypto' import { countWords } from '../../services/word-count' import type { Chapter, ChapterStatus } from '../../../shared/types' export class ChapterRepository { constructor(private db: Database.Database) {} create(volumeId: string, title: string, sortOrder: number): Chapter { const id = randomUUID() this.db.prepare( `INSERT INTO chapters (id, volume_id, title, sort_order) VALUES (?, ?, ?, ?)` ).run(id, volumeId, title, sortOrder) return this.get(id)! } get(id: string): Chapter | null { const row = this.db.prepare('SELECT * FROM chapters WHERE id = ?').get(id) as Record | undefined if (!row) return null return mapRow(row) } update(id: string, patch: Partial<{ title: string; content: string; status: ChapterStatus; cursorOffset: number }>): Chapter { const existing = this.get(id) if (!existing) throw new Error('Chapter not found') const content = patch.content ?? existing.content const wordCount = countWords(content) this.db.prepare( `UPDATE chapters SET title = ?, content = ?, status = ?, cursor_offset = ?, word_count = ?, updated_at = datetime('now') WHERE id = ?` ).run( patch.title ?? existing.title, content, patch.status ?? existing.status, patch.cursorOffset ?? existing.cursorOffset, wordCount, id ) return this.get(id)! } listByBook(): Chapter[] { return (this.db.prepare('SELECT * FROM chapters ORDER BY sort_order').all() as Record[]).map(mapRow) } } function mapRow(row: Record): Chapter { return { id: row.id as string, volumeId: row.volume_id as string | null, title: row.title as string, content: row.content as string, status: row.status as Chapter['status'], wordCount: row.word_count as number, sortOrder: row.sort_order as number, cursorOffset: row.cursor_offset as number } } ``` - [ ] **Step 6: chapter.repo 集成测试** `tests/main/chapter.repo.test.ts` — 使用 `:memory:` DB + migrate,测试 create/update 字数更新。 - [ ] **Step 7: 运行测试 PASS** ```bash npm run test ``` - [ ] **Step 8: Commit** ```bash git commit -am "feat: add sqlite migration, repositories, word count" ``` --- ## Task 5: BookRegistry + IPC Handlers **Files:** - Create: `src/main/services/book-registry.ts` - Create: `src/main/ipc/handlers/settings.handler.ts` - Create: `src/main/ipc/handlers/book.handler.ts` - Create: `src/main/ipc/handlers/chapter.handler.ts` - Create: `src/main/ipc/register.ts` - Modify: `src/main/index.ts` - [ ] **Step 1: BookRegistryService** `createBook` 流程:UUID → `books/{id}.sqlite` → 默认分卷「第一卷」→ 可选示例章 → 写入 registry。 - [ ] **Step 2: settings.handler.ts** ```typescript import { ipcMain } from 'electron' import { IPC } from '../../../shared/ipc-channels' import { GlobalSettingsService } from '../../services/global-settings' import { ok, wrap } from '../result' export function registerSettingsHandlers(settings: GlobalSettingsService): void { ipcMain.handle(IPC.SETTINGS_GET, () => wrap(() => settings.get())) ipcMain.handle(IPC.SETTINGS_UPDATE, (_e, partial) => wrap(() => settings.update(partial))) } ``` - [ ] **Step 3: book/chapter handlers** — 按 spec §6.2 实现,book:open 返回 `{ meta, volumes, chapters }` - [ ] **Step 4: register.ts 汇总 + index.ts 调用** ```typescript import { app } from 'electron' import { join } from 'path' import { GlobalSettingsService } from './services/global-settings' import { registerSettingsHandlers } from './ipc/handlers/settings.handler' import { registerBookHandlers } from './ipc/handlers/book.handler' export function registerIpc(): void { const userData = app.getPath('userData') const settings = new GlobalSettingsService(userData) const schemaDir = join(__dirname, '../db') registerSettingsHandlers(settings) registerBookHandlers(userData, schemaDir) } ``` - [ ] **Step 5: Commit** ```bash git commit -am "feat: add book registry and IPC handlers" ``` --- ## Task 6: Preload 完整 API + ipc-client **Files:** - Modify: `src/preload/index.ts` - Create: `src/preload/api.d.ts` - Create: `src/renderer/lib/ipc-client.ts` - Create: `src/renderer/vite-env.d.ts` - [ ] **Step 1: 扩展 preload** — 暴露 settings、book、chapter、shortcut、window 命名空间;`onShortcutTriggered` 用 ipcRenderer.on + 返回 unsubscribe - [ ] **Step 2: ipc-client.ts** ```typescript import type { IpcError, IpcResult } from '@shared/types' export class IpcCallError extends Error { constructor(public ipcError: IpcError) { super(ipcError.message) } } export async function ipcCall(fn: () => Promise>): Promise { const result = await fn() if (!result.ok) throw new IpcCallError(result.error) return result.data } ``` - [ ] **Step 3: Commit** ```bash git commit -am "feat: expose full preload API and ipcCall helper" ``` --- ## Task 7: 主题 CSS + i18n **Files:** - Create: `src/renderer/styles/themes.css` — 从 `design/ui_pc.html` 行 9–36 提取 `:root` 与 6 个 `[data-theme]` 块 - Create: `src/renderer/styles/globals.css` — reset + `#app { height: 100vh }` - Create: `src/renderer/i18n/index.ts` - Create: `public/locales/zh-CN/translation.json` - Create: `public/locales/en/translation.json` - Create: `src/renderer/hooks/useTheme.ts` - [ ] **Step 1: 提取 themes.css** — 保持与原型相同的变量名(`--bg-primary`、`--accent` 等) - [ ] **Step 2: i18n 初始化** ```typescript import i18n from 'i18next' import { initReactI18next } from 'react-i18next' import zh from '../../../public/locales/zh-CN/translation.json' import en from '../../../public/locales/en/translation.json' void i18n.use(initReactI18next).init({ resources: { 'zh-CN': { translation: zh }, en: { translation: en } }, lng: 'zh-CN', fallbackLng: 'zh-CN', interpolation: { escapeValue: false } }) export default i18n ``` - [ ] **Step 3: translation.json 首批 key** ```json { "app.name": "笔临", "app.tagline": "长篇创作智能协作平台", "home.newBook": "新建书籍", "home.importBook": "导入书籍", "settings.theme": "主题", "settings.language": "语言", "onboarding.welcome": "欢迎使用笔临", "onboarding.start": "开始创作", "feature.comingSoon": "即将推出", "editor.save": "保存", "editor.wordCount": "{{count}} 字" } ``` 英文文件对应翻译,key 完全一致。 - [ ] **Step 4: useTheme** — 读取 settingsStore.theme,`document.documentElement.dataset.theme = theme`(default 时移除 attribute) - [ ] **Step 5: main.tsx 引入 i18n + globals.css + themes.css** - [ ] **Step 6: Commit** ```bash git commit -am "feat: add theme CSS and i18n foundation" ``` --- ## Task 8: Zustand Stores + Settings 加载 **Files:** - Create: `src/renderer/stores/useSettingsStore.ts` - Create: `src/renderer/stores/useAppStore.ts` - Create: `src/renderer/stores/useTabStore.ts` - Create: `src/renderer/stores/useBookStore.ts` - [ ] **Step 1: useSettingsStore** ```typescript import { create } from 'zustand' import type { GlobalSettings } from '@shared/types' import { ipcCall } from '@renderer/lib/ipc-client' interface SettingsState extends GlobalSettings { loaded: boolean load: () => Promise update: (p: Partial) => Promise } export const useSettingsStore = create((set) => ({ penName: '未命名作者', onboardingCompleted: false, theme: 'default', language: 'zh-CN', dailyWordGoal: 0, shortcuts: {} as GlobalSettings['shortcuts'], loaded: false, load: async () => { const data = await ipcCall(() => window.electronAPI.settings.get()) set({ ...data, loaded: true }) await i18n.changeLanguage(data.language) applyTheme(data.theme) }, update: async (p) => { const data = await ipcCall(() => window.electronAPI.settings.update(p)) set(data) if (p.language) await i18n.changeLanguage(data.language) if (p.theme) applyTheme(data.theme) } })) ``` - [ ] **Step 2: useAppStore** — `view: 'home' | 'editor' | 'settings'` - [ ] **Step 3: useTabStore** — `openTabs: { id, type, name, bookId? }[]`,add/remove/switch - [ ] **Step 4: useBookStore** — books 列表、currentBookId、volumes、chapters、selectedChapterId、loadBooks/openBook - [ ] **Step 5: App.tsx 启动时 settings.load()** - [ ] **Step 6: Commit** ```bash git commit -am "feat: add zustand stores and settings bootstrap" ``` --- ## Task 9: 布局 CSS + AppShell + TopBar **Files:** - Create: `src/renderer/styles/layout.css` — 从 `design/ui_pc.html` 提取 `#topbar`、`#main-area`、`#home-page`、`#editor-layout`、`#left-sidebar`、`#editor-area`、`#right-panel` 相关规则 - Create: `src/renderer/components/layout/AppShell.tsx` - Create: `src/renderer/components/layout/TopBar.tsx` - Modify: `src/renderer/App.tsx` - [ ] **Step 1: 提取 layout.css** — 保留 frameless 窗口 `-webkit-app-region: drag/no-drag` 规则 - [ ] **Step 2: TopBar** — Logo(goHome)、TabBar 插槽、日更进度占位、窗口控制按钮调用 `window.electronAPI.window.*`;顶栏占位按钮(驾驶舱/图谱/时间线/专注/音效)点击 Radix Toast `feature.comingSoon` - [ ] **Step 3: AppShell** — 根据 useAppStore.view 渲染 HomePage / EditorLayout / SettingsPage - [ ] **Step 4: 验证** — 三视图手动切换(临时按钮),主题变量生效 - [ ] **Step 5: Commit** ```bash git commit -am "feat: add layout shell and topbar from prototype CSS" ``` --- ## Task 10: Onboarding 向导 **Files:** - Create: `src/renderer/components/onboarding/OnboardingWizard.tsx` - Modify: `src/renderer/App.tsx` - [ ] **Step 1: Radix Dialog 5 步 slides** — 对应 spec §3.3:欢迎 → 三章轮播 → 笔名输入 → 完成 - [ ] **Step 2: 完成逻辑** ```typescript async function finishOnboarding(penName: string, createSample: boolean) { await useSettingsStore.getState().update({ penName: penName.trim() || '未命名作者', onboardingCompleted: true }) if (createSample) { await ipcCall(() => window.electronAPI.book.create({ name: '我的第一本书', category: '未分类', createSampleChapter: true })) } setShowOnboarding(false) } ``` - [ ] **Step 3: App 启动** — `!onboardingCompleted` 时显示 wizard(模态不可关闭,可跳过) - [ ] **Step 4: Commit** ```bash git commit -am "feat: add onboarding wizard" ``` --- ## Task 11: 设置页(主题/语言/快捷键) **Files:** - Create: `src/renderer/components/settings/SettingsPage.tsx` - Create: `src/renderer/components/settings/ThemeSelect.tsx` - Create: `src/renderer/components/settings/LanguageSelect.tsx` - Create: `src/renderer/components/settings/ShortcutEditor.tsx` - Create: `src/renderer/components/ui/Select.tsx`(Radix 封装) - [x] **Step 1: SettingsPage 侧栏导航** — 通用 / 编辑器(占位) / AI(占位) / 快捷键 / 备份(占位) / 关于 - [x] **Step 2: ThemeSelect** — 7 选项,onChange → settings.update({ theme }) - [x] **Step 3: LanguageSelect** — zh-CN / en - [x] **Step 4: ShortcutEditor** — 列出 ActionId + 当前绑定;点击录制键位 → conflict 检测 → shortcut.register IPC - [x] **Step 5: 顶栏齿轮 → openSettingsTab + useAppStore view=settings** - [ ] **Step 6: Commit** ```bash git commit -am "feat: add settings page with theme language shortcuts" ``` --- ## Task 12: 快捷键主进程 **Files:** - Create: `src/main/shortcuts/manager.ts` - Create: `src/main/ipc/handlers/shortcut.handler.ts` - Modify: `src/main/index.ts` - [ ] **Step 1: ShortcutManager** ```typescript import { globalShortcut, BrowserWindow } from 'electron' import { IPC } from '../../shared/ipc-channels' import type { ActionId } from '../../shared/types' export class ShortcutManager { private bindings = new Map() register(action: ActionId, accelerator: string, win: BrowserWindow): boolean { if (this.bindings.has(action)) globalShortcut.unregister(this.bindings.get(action)!) const ok = globalShortcut.register(accelerator, () => { win.webContents.send(IPC.SHORTCUT_TRIGGERED, { action }) }) if (ok) this.bindings.set(action, accelerator) return ok } registerAll(map: Record, win: BrowserWindow): void { for (const [action, acc] of Object.entries(map) as [ActionId, string][]) { this.register(action, acc, win) } } } ``` - [ ] **Step 2: 渲染进程监听** — App.tsx 中 `onShortcutTriggered`:`saveChapter` → 触发保存;`newChapter` → 新建章;`closeTab` → 关 Tab;其余 → Toast comingSoon - [ ] **Step 3: Commit** ```bash git commit -am "feat: add global shortcut manager" ``` --- ## Task 13: 主页 + 新建书籍 **Files:** - Create: `src/renderer/components/home/HomePage.tsx` - Create: `src/renderer/components/home/BookGrid.tsx` - Create: `src/renderer/components/home/NewBookDialog.tsx` - Create: `src/renderer/components/ui/Dialog.tsx` - [ ] **Step 1: HomePage** — hero、操作按钮(导入/导出全部 → Toast comingSoon)、BookGrid、最近编辑 - [ ] **Step 2: BookGrid** — `book:list` 渲染卡片,点击 → openBook + Tab + view=editor - [ ] **Step 3: NewBookDialog** — 书名/分类/目标字数 Radix Dialog → `book:create` → 刷新列表 → openBookTab - [ ] **Step 4: Commit** ```bash git commit -am "feat: add home page and new book dialog" ``` --- ## Task 14: 编辑器布局 + 章节树 **Files:** - Create: `src/renderer/components/layout/EditorLayout.tsx` - Create: `src/renderer/components/layout/LeftSidebar.tsx` - Create: `src/renderer/components/editor/ChapterTree.tsx` - Create: `src/renderer/components/editor/VolumeHeader.tsx` - [ ] **Step 1: EditorLayout** — 左栏 + 中央 EditorArea + 右栏占位 + 引用面板占位(width:0) - [ ] **Step 2: LeftSidebar 四 Tab** — 章节/大纲/设定/灵感;后三者 mock 静态列表(硬编码 2–3 条),点击切换 currentEdit.type 但不 IPC - [ ] **Step 3: ChapterTree** — 按 volume 分组折叠;chapter-item active 态;双击标题 inline 编辑 → chapter:update title;底部「新章」→ chapter:create 在当前卷 - [ ] **Step 4: 分卷** — 右键或按钮创建分卷(P1 最小:sidebar footer「新卷」按钮) - [ ] **Step 5: Commit** ```bash git commit -am "feat: add editor layout and chapter tree" ``` --- ## Task 15: TipTap 编辑器 + 工具栏 **Files:** - Create: `src/renderer/components/editor/TipTapEditor.tsx` - Create: `src/renderer/components/editor/EditorToolbar.tsx` - Create: `src/renderer/components/editor/EditorArea.tsx` - Create: `src/renderer/components/layout/StatusBar.tsx` - [ ] **Step 1: TipTapEditor 配置** ```typescript import { useEditor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' import Placeholder from '@tiptap/extension-placeholder' const editor = useEditor({ extensions: [ StarterKit, Underline, Placeholder.configure({ placeholder: t('editor.placeholder') }) ], content: initialContent, onUpdate: ({ editor }) => onContentChange(editor.getHTML()) }) ``` - [ ] **Step 2: EditorToolbar** — 加粗/斜体/下划线/分割线/引用按钮(引用 → Toast);文档标题 label 双击改标题 - [ ] **Step 3: 粘贴** — 监听 paste,`getText()` 纯文本插入(除非 Shift 键) - [ ] **Step 4: StatusBar** — 本章/卷/全书字数、保存状态(已保存/保存中) - [ ] **Step 5: Commit** ```bash git commit -am "feat: add TipTap editor with toolbar and status bar" ``` --- ## Task 16: Jotai + 自动保存 + 章节切换 **Files:** - Create: `src/renderer/atoms/editorAtoms.ts` - Create: `src/renderer/hooks/useAutoSave.ts` - Create: `src/renderer/hooks/useChapterLoader.ts` - [ ] **Step 1: editorAtoms.ts** — 按 spec §8.2 定义 atoms - [ ] **Step 2: useAutoSave.ts** ```typescript import { useEffect, useRef } from 'react' import { useAtom, useSetAtom } from 'jotai' import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom } from '@renderer/atoms/editorAtoms' import { ipcCall } from '@renderer/lib/ipc-client' import { useBookStore } from '@renderer/stores/useBookStore' export function useAutoSave(getContent: () => string, getCursor: () => number) { const [dirty, setDirty] = useAtom(editorDirtyAtom) const setSaving = useSetAtom(editorSavingAtom) const setLastSaved = useSetAtom(lastSavedAtAtom) const timer = useRef>() useEffect(() => { if (!dirty) return clearTimeout(timer.current) timer.current = setTimeout(async () => { const { currentBookId, selectedChapterId } = useBookStore.getState() if (!currentBookId || !selectedChapterId) return setSaving(true) try { await ipcCall(() => window.electronAPI.chapter.update({ bookId: currentBookId, chapterId: selectedChapterId, content: getContent(), cursorOffset: getCursor() })) setDirty(false) setLastSaved(new Date()) } finally { setSaving(false) } }, 2000) return () => clearTimeout(timer.current) }, [dirty, getContent, getCursor]) } ``` - [ ] **Step 3: useChapterLoader** — 切换章节:先 flush save → chapter:get → editor.commands.setContent → 恢复 cursorOffset(`editor.commands.focus(pos)`) - [ ] **Step 4: Ctrl+S** — 立即保存,清除 debounce timer - [ ] **Step 5: book:open 更新 lastChapterId** — 切换章节时 IPC 更新 registry - [ ] **Step 6: Commit** ```bash git commit -am "feat: add auto-save and chapter switching with jotai" ``` --- ## Task 17: 右栏占位 + Toast 系统 **Files:** - Create: `src/renderer/components/layout/RightPanel.tsx` - Create: `src/renderer/components/ui/Toaster.tsx` - [ ] **Step 1: RightPanel** — 四 Tab UI 与原型一致;AI 面板 layout 完整,chat 区 mock 一条消息,input disabled + placeholder `feature.comingSoon`;知识库/用词/书籍设置 Tab 内显示占位文案 - [ ] **Step 2: Radix Toast Provider** — 封装 `showToast(message)` 供全局使用 - [ ] **Step 3: 验证 §5.2** — 所有占位按钮不 crash,仅 Toast - [ ] **Step 4: Commit** ```bash git commit -am "feat: add right panel placeholders and toast system" ``` --- ## Task 18: Vitest 集成测试补全 **Files:** - Create: `tests/main/book-registry.test.ts` - Modify: `tests/main/chapter.repo.test.ts` - [x] **Step 1: book-registry 测试** — createBook 后 registry 有条目、sqlite 文件存在、默认第一卷存在 - [x] **Step 2: 运行全量测试** ```bash npm run test ``` Expected: 全部 PASS - [ ] **Step 3: Commit** ```bash git commit -am "test: add book registry integration tests" ``` --- ## Task 19: Playwright E2E **Files:** - Create: `playwright.config.ts` - Create: `e2e/onboarding.spec.ts` - Create: `e2e/writing.spec.ts` - Modify: `package.json` — `"test:e2e": "playwright test"` - [x] **Step 1: playwright.config.ts** — 使用 `_electron.launch({ args: ['.'] })` 或 build 后 launch - [x] **Step 2: onboarding.spec.ts** ```typescript import { test, expect, _electron as electron } from '@playwright/test' test('E2E-ONBOARD-02: complete onboarding saves pen name', async () => { const app = await electron.launch({ args: ['.'], env: { ...process.env, BILIN_E2E: '1' } }) const page = await app.firstWindow() // 完成 5 步向导,输入笔名「山海」 await page.getByPlaceholder(/笔名/).fill('山海') await page.getByRole('button', { name: /开始创作/ }).click() await app.close() // 重启验证 settings 持久化(读 userData 测试目录) }) ``` 主进程支持 `process.env.BILIN_E2E` 时使用临时 userData 目录。 - [x] **Step 3: writing.spec.ts** — 建书 → 输入文本 → wait 2500ms → 重启 → 验证内容 - [x] **Step 4: 运行 E2E** ```bash npm run build npm run test:e2e ``` - [ ] **Step 5: Commit** ```bash git commit -am "test: add playwright e2e for onboarding and auto-save" ``` --- ## Task 20: 收尾与 v0.1.0 **Files:** - Modify: `package.json` version - Create: `README.md`(简短:dev/build/test 命令) - [x] **Step 1: 统一 design/ 目录** — 删除残留 `desigin/`,`git add design/` - [x] **Step 2: 手动验收清单**(spec §14) - [x] `npm run dev` 无 error - [x] Onboarding → 建书 → 写作 → 重启恢复 - [x] 7 主题切换 - [x] 中英文切换 - [x] 快捷键 Ctrl+S / Ctrl+Shift+N / Ctrl+W - [x] 占位功能 Toast 不 crash - [x] **Step 3: Windows build 验证** ```bash npm run build ``` - [ ] **Step 4: Commit + tag** ```bash git commit -am "chore: prepare v0.1.0 release" git tag v0.1.0 ``` --- ## Plan Self-Review | Spec 章节 | 对应用 Task | |-----------|-------------| | §4 P0 脚手架/主题/i18n/IPC/Onboarding/快捷键 | Task 1–3, 7–12 | | §5.1 P1 写作核心 | Task 13–16 | | §5.2 占位 | Task 9, 17 | | §6 IPC | Task 2, 5, 6 | | §7 数据模型 | Task 4, 5 | | §8 状态管理 | Task 8, 16 | | §9 UI | Task 7, 9, 14, 15 | | §11 测试 | Task 18, 19 | | §14 DoD | Task 20 | **Placeholder 扫描:** 无 TBD/TODO。Task 4 Step 5、Task 5 Step 1/3 标注了需完整实现但模式已在 chapter.repo 示例中给出。 **类型一致性:** `Chapter.status` 统一为 `draft | review | done`;IPC 参数使用 camelCase(cursorOffset)→ repo 映射 snake_case。 --- ## 执行方式 Plan 已保存至 `docs/superpowers/plans/2026-07-05-bilin-p0-p1.md`。 **两种执行选项:** 1. **Subagent-Driven(推荐)** — 每个 Task 派发独立 subagent,任务间 review,迭代快 2. **Inline Execution** — 在本会话按 Task 顺序直接实现,批次间设检查点 你选哪种?