交付写作核心(TipTap、自动保存、分卷分章)、Onboarding、7 主题与双语 i18n、快捷键设置;主进程使用 node:sqlite;补全 Vitest 与 Playwright E2E;统一 design/ 目录并移除 desigin 拼写错误路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
39 KiB
笔临 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行 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 初始化
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.theme,
document.documentElement.dataset.theme = theme(default 时移除 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: 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
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 Toastfeature.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.tsx(Radix 封装) -
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 中
onShortcutTriggered:saveChapter→ 触发保存;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: BookGrid —
book: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: 粘贴 — 监听 paste,
getText()纯文本插入(除非 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 → 恢复 cursorOffset(
editor.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.jsonversion -
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 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。
两种执行选项:
- Subagent-Driven(推荐) — 每个 Task 派发独立 subagent,任务间 review,迭代快
- Inline Execution — 在本会话按 Task 顺序直接实现,批次间设检查点
你选哪种?