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

1333 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 笔临 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(
<React.StrictMode><App /></React.StrictMode>
)
```
`src/renderer/App.tsx`:
```tsx
export default function App() {
return <div id="app"> loading...</div>
}
```
- [ ] **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<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`:
```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<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`:
```typescript
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**
```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>): 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<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(核心方法)**
```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<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**
```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<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**
```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` 行 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 初始化**
```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<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**
```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** — LogogoHome)、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<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**
```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<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**
```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 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 | done`IPC 参数使用 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 顺序直接实现,批次间设检查点
你选哪种?