011bd6d4ca
交付写作核心(TipTap、自动保存、分卷分章)、Onboarding、7 主题与双语 i18n、快捷键设置;主进程使用 node:sqlite;补全 Vitest 与 Playwright E2E;统一 design/ 目录并移除 desigin 拼写错误路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
1333 lines
39 KiB
Markdown
1333 lines
39 KiB
Markdown
# 笔临 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` 行 9–36 提取 `:root` 与 6 个 `[data-theme]` 块
|
||
- Create: `src/renderer/styles/globals.css` — reset + `#app { height: 100vh }`
|
||
- Create: `src/renderer/i18n/index.ts`
|
||
- Create: `public/locales/zh-CN/translation.json`
|
||
- Create: `public/locales/en/translation.json`
|
||
- Create: `src/renderer/hooks/useTheme.ts`
|
||
|
||
- [ ] **Step 1: 提取 themes.css** — 保持与原型相同的变量名(`--bg-primary`、`--accent` 等)
|
||
|
||
- [ ] **Step 2: i18n 初始化**
|
||
|
||
```typescript
|
||
import i18n from 'i18next'
|
||
import { initReactI18next } from 'react-i18next'
|
||
import zh from '../../../public/locales/zh-CN/translation.json'
|
||
import en from '../../../public/locales/en/translation.json'
|
||
|
||
void i18n.use(initReactI18next).init({
|
||
resources: { 'zh-CN': { translation: zh }, en: { translation: en } },
|
||
lng: 'zh-CN',
|
||
fallbackLng: 'zh-CN',
|
||
interpolation: { escapeValue: false }
|
||
})
|
||
|
||
export default i18n
|
||
```
|
||
|
||
- [ ] **Step 3: translation.json 首批 key**
|
||
|
||
```json
|
||
{
|
||
"app.name": "笔临",
|
||
"app.tagline": "长篇创作智能协作平台",
|
||
"home.newBook": "新建书籍",
|
||
"home.importBook": "导入书籍",
|
||
"settings.theme": "主题",
|
||
"settings.language": "语言",
|
||
"onboarding.welcome": "欢迎使用笔临",
|
||
"onboarding.start": "开始创作",
|
||
"feature.comingSoon": "即将推出",
|
||
"editor.save": "保存",
|
||
"editor.wordCount": "{{count}} 字"
|
||
}
|
||
```
|
||
|
||
英文文件对应翻译,key 完全一致。
|
||
|
||
- [ ] **Step 4: useTheme** — 读取 settingsStore.theme,`document.documentElement.dataset.theme = theme`(default 时移除 attribute)
|
||
|
||
- [ ] **Step 5: main.tsx 引入 i18n + globals.css + themes.css**
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add theme CSS and i18n foundation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Zustand Stores + Settings 加载
|
||
|
||
**Files:**
|
||
- Create: `src/renderer/stores/useSettingsStore.ts`
|
||
- Create: `src/renderer/stores/useAppStore.ts`
|
||
- Create: `src/renderer/stores/useTabStore.ts`
|
||
- Create: `src/renderer/stores/useBookStore.ts`
|
||
|
||
- [ ] **Step 1: useSettingsStore**
|
||
|
||
```typescript
|
||
import { create } from 'zustand'
|
||
import type { GlobalSettings } from '@shared/types'
|
||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||
|
||
interface SettingsState extends GlobalSettings {
|
||
loaded: boolean
|
||
load: () => Promise<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** — Logo(goHome)、TabBar 插槽、日更进度占位、窗口控制按钮调用 `window.electronAPI.window.*`;顶栏占位按钮(驾驶舱/图谱/时间线/专注/音效)点击 Radix Toast `feature.comingSoon`
|
||
|
||
- [ ] **Step 3: AppShell** — 根据 useAppStore.view 渲染 HomePage / EditorLayout / SettingsPage
|
||
|
||
- [ ] **Step 4: 验证** — 三视图手动切换(临时按钮),主题变量生效
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add layout shell and topbar from prototype CSS"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Onboarding 向导
|
||
|
||
**Files:**
|
||
- Create: `src/renderer/components/onboarding/OnboardingWizard.tsx`
|
||
- Modify: `src/renderer/App.tsx`
|
||
|
||
- [ ] **Step 1: Radix Dialog 5 步 slides** — 对应 spec §3.3:欢迎 → 三章轮播 → 笔名输入 → 完成
|
||
|
||
- [ ] **Step 2: 完成逻辑**
|
||
|
||
```typescript
|
||
async function finishOnboarding(penName: string, createSample: boolean) {
|
||
await useSettingsStore.getState().update({
|
||
penName: penName.trim() || '未命名作者',
|
||
onboardingCompleted: true
|
||
})
|
||
if (createSample) {
|
||
await ipcCall(() => window.electronAPI.book.create({
|
||
name: '我的第一本书',
|
||
category: '未分类',
|
||
createSampleChapter: true
|
||
}))
|
||
}
|
||
setShowOnboarding(false)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: App 启动** — `!onboardingCompleted` 时显示 wizard(模态不可关闭,可跳过)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add onboarding wizard"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: 设置页(主题/语言/快捷键)
|
||
|
||
**Files:**
|
||
- Create: `src/renderer/components/settings/SettingsPage.tsx`
|
||
- Create: `src/renderer/components/settings/ThemeSelect.tsx`
|
||
- Create: `src/renderer/components/settings/LanguageSelect.tsx`
|
||
- Create: `src/renderer/components/settings/ShortcutEditor.tsx`
|
||
- Create: `src/renderer/components/ui/Select.tsx`(Radix 封装)
|
||
|
||
- [x] **Step 1: SettingsPage 侧栏导航** — 通用 / 编辑器(占位) / AI(占位) / 快捷键 / 备份(占位) / 关于
|
||
|
||
- [x] **Step 2: ThemeSelect** — 7 选项,onChange → settings.update({ theme })
|
||
|
||
- [x] **Step 3: LanguageSelect** — zh-CN / en
|
||
|
||
- [x] **Step 4: ShortcutEditor** — 列出 ActionId + 当前绑定;点击录制键位 → conflict 检测 → shortcut.register IPC
|
||
|
||
- [x] **Step 5: 顶栏齿轮 → openSettingsTab + useAppStore view=settings**
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git commit -am "feat: add settings page with theme language shortcuts"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: 快捷键主进程
|
||
|
||
**Files:**
|
||
- Create: `src/main/shortcuts/manager.ts`
|
||
- Create: `src/main/ipc/handlers/shortcut.handler.ts`
|
||
- Modify: `src/main/index.ts`
|
||
|
||
- [ ] **Step 1: ShortcutManager**
|
||
|
||
```typescript
|
||
import { globalShortcut, BrowserWindow } from 'electron'
|
||
import { IPC } from '../../shared/ipc-channels'
|
||
import type { ActionId } from '../../shared/types'
|
||
|
||
export class ShortcutManager {
|
||
private bindings = new Map<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 1–3, 7–12 |
|
||
| §5.1 P1 写作核心 | Task 13–16 |
|
||
| §5.2 占位 | Task 9, 17 |
|
||
| §6 IPC | Task 2, 5, 6 |
|
||
| §7 数据模型 | Task 4, 5 |
|
||
| §8 状态管理 | Task 8, 16 |
|
||
| §9 UI | Task 7, 9, 14, 15 |
|
||
| §11 测试 | Task 18, 19 |
|
||
| §14 DoD | Task 20 |
|
||
|
||
**Placeholder 扫描:** 无 TBD/TODO。Task 4 Step 5、Task 5 Step 1/3 标注了需完整实现但模式已在 chapter.repo 示例中给出。
|
||
|
||
**类型一致性:** `Chapter.status` 统一为 `draft | review | done`;IPC 参数使用 camelCase(cursorOffset)→ repo 映射 snake_case。
|
||
|
||
---
|
||
|
||
## 执行方式
|
||
|
||
Plan 已保存至 `docs/superpowers/plans/2026-07-05-bilin-p0-p1.md`。
|
||
|
||
**两种执行选项:**
|
||
|
||
1. **Subagent-Driven(推荐)** — 每个 Task 派发独立 subagent,任务间 review,迭代快
|
||
2. **Inline Execution** — 在本会话按 Task 顺序直接实现,批次间设检查点
|
||
|
||
你选哪种?
|