Files
bilin/docs/superpowers/plans/2026-07-05-bilin-p0-p1.md
T
bing 011bd6d4ca 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>
2026-07-06 10:39:20 +08:00

39 KiB
Raw Blame History

笔临 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 与依赖

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
{
  "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
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:

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:

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:

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode><App /></React.StrictMode>
)

src/renderer/App.tsx:

export default function App() {
  return <div id="app">笔临 loading...</div>
}
  • Step 6: 验证启动
npm run dev

Expected: Electron 窗口打开,显示「笔临 loading...」,无 main/preload 报错。

  • Step 7: Commit
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:

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<T> =
  | { ok: true; data: T }
  | { ok: false; error: IpcError }

export interface GlobalSettings {
  penName: string
  onboardingCompleted: boolean
  theme: ThemeId
  language: Language
  dailyWordGoal: number
  shortcuts: Record<ActionId, string>
}

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:

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:

import type { ActionId } from './types'

export const DEFAULT_SHORTCUTS: Record<ActionId, string> = {
  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:

import { ErrorCode, IpcError, 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)
  }
}
  • Step 5: Commit
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:

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
npm run test -- tests/main/global-settings.test.ts

Expected: FAIL — module not found

  • Step 3: 实现 GlobalSettingsService

src/main/services/global-settings.ts:

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>): 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:

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
npm run test -- tests/main/global-settings.test.ts
  • Step 5: Commit
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:

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:

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
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
import Database from 'better-sqlite3'
import { mkdirSync, existsSync } from 'fs'
import { dirname } from 'path'
import { migrate } from './migrate'

const pools = new Map<string, Database.Database>()

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(核心方法)
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<string, unknown> | 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<string, unknown>[]).map(mapRow)
  }
}

function mapRow(row: Record<string, unknown>): 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
npm run test
  • Step 8: Commit
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
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 调用

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
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

import type { IpcError, IpcResult } from '@shared/types'

export class IpcCallError extends Error {
  constructor(public ipcError: IpcError) {
    super(ipcError.message)
  }
}

export async function ipcCall<T>(fn: () => Promise<IpcResult<T>>): Promise<T> {
  const result = await fn()
  if (!result.ok) throw new IpcCallError(result.error)
  return result.data
}
  • Step 3: Commit
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 行 936 提取 :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 初始化

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
{
  "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.themedocument.documentElement.dataset.theme = themedefault 时移除 attribute

  • Step 5: main.tsx 引入 i18n + globals.css + themes.css

  • Step 6: Commit

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

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<void>
  update: (p: Partial<GlobalSettings>) => Promise<void>
}

export const useSettingsStore = create<SettingsState>((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: useAppStoreview: 'home' | 'editor' | 'settings'

  • Step 3: useTabStoreopenTabs: { 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

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 — LogogoHome)、TabBar 插槽、日更进度占位、窗口控制按钮调用 window.electronAPI.window.*;顶栏占位按钮(驾驶舱/图谱/时间线/专注/音效)点击 Radix Toast feature.comingSoon

  • Step 3: AppShell — 根据 useAppStore.view 渲染 HomePage / EditorLayout / SettingsPage

  • Step 4: 验证 — 三视图手动切换(临时按钮),主题变量生效

  • Step 5: Commit

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: 完成逻辑

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

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.tsxRadix 封装)

  • Step 1: SettingsPage 侧栏导航 — 通用 / 编辑器(占位) / AI(占位) / 快捷键 / 备份(占位) / 关于

  • Step 2: ThemeSelect — 7 选项,onChange → settings.update({ theme })

  • Step 3: LanguageSelect — zh-CN / en

  • Step 4: ShortcutEditor — 列出 ActionId + 当前绑定;点击录制键位 → conflict 检测 → shortcut.register IPC

  • Step 5: 顶栏齿轮 → openSettingsTab + useAppStore view=settings

  • Step 6: Commit

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

import { globalShortcut, BrowserWindow } from 'electron'
import { IPC } from '../../shared/ipc-channels'
import type { ActionId } from '../../shared/types'

export class ShortcutManager {
  private bindings = new Map<ActionId, string>()

  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<ActionId, string>, win: BrowserWindow): void {
    for (const [action, acc] of Object.entries(map) as [ActionId, string][]) {
      this.register(action, acc, win)
    }
  }
}
  • Step 2: 渲染进程监听 — App.tsx 中 onShortcutTriggeredsaveChapter → 触发保存;newChapter → 新建章;closeTab → 关 Tab;其余 → Toast comingSoon

  • Step 3: Commit

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: BookGridbook:list 渲染卡片,点击 → openBook + Tab + view=editor

  • Step 3: NewBookDialog — 书名/分类/目标字数 Radix Dialog → book:create → 刷新列表 → openBookTab

  • Step 4: Commit

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

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 配置

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: 粘贴 — 监听 pastegetText() 纯文本插入(除非 Shift 键)

  • Step 4: StatusBar — 本章/卷/全书字数、保存状态(已保存/保存中)

  • Step 5: Commit

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

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<ReturnType<typeof setTimeout>>()

  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 → 恢复 cursorOffseteditor.commands.focus(pos)

  • Step 4: Ctrl+S — 立即保存,清除 debounce timer

  • Step 5: book:open 更新 lastChapterId — 切换章节时 IPC 更新 registry

  • Step 6: Commit

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

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

  • Step 1: book-registry 测试 — createBook 后 registry 有条目、sqlite 文件存在、默认第一卷存在

  • Step 2: 运行全量测试

npm run test

Expected: 全部 PASS

  • Step 3: Commit
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"

  • Step 1: playwright.config.ts — 使用 _electron.launch({ args: ['.'] }) 或 build 后 launch

  • Step 2: onboarding.spec.ts

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 目录。

  • Step 3: writing.spec.ts — 建书 → 输入文本 → wait 2500ms → 重启 → 验证内容

  • Step 4: 运行 E2E

npm run build
npm run test:e2e
  • Step 5: Commit
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 命令)

  • Step 1: 统一 design/ 目录 — 删除残留 desigin/git add design/

  • Step 2: 手动验收清单spec §14

    • npm run dev 无 error
    • Onboarding → 建书 → 写作 → 重启恢复
    • 7 主题切换
    • 中英文切换
    • 快捷键 Ctrl+S / Ctrl+Shift+N / Ctrl+W
    • 占位功能 Toast 不 crash
  • Step 3: Windows build 验证

npm run build
  • Step 4: Commit + tag
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 13, 712
§5.1 P1 写作核心 Task 1316
§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 | doneIPC 参数使用 camelCasecursorOffset)→ repo 映射 snake_case。


执行方式

Plan 已保存至 docs/superpowers/plans/2026-07-05-bilin-p0-p1.md

两种执行选项:

  1. Subagent-Driven(推荐) — 每个 Task 派发独立 subagent,任务间 review,迭代快
  2. Inline Execution — 在本会话按 Task 顺序直接实现,批次间设检查点

你选哪种?