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>
This commit is contained in:
2026-07-06 10:39:20 +08:00
parent a06038035b
commit 011bd6d4ca
70 changed files with 14245 additions and 84 deletions
+25
View File
@@ -0,0 +1,25 @@
import { DatabaseSync } from 'node:sqlite'
import { existsSync, mkdirSync } from 'fs'
import { dirname } from 'path'
import { migrate } from './migrate'
import type { SqliteDb } from './types'
const pools = new Map<string, SqliteDb>()
export function openBookDb(dbPath: string): SqliteDb {
const existing = pools.get(dbPath)
if (existing) return existing
const dir = dirname(dbPath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
const db = new DatabaseSync(dbPath)
migrate(db)
pools.set(dbPath, db)
return db
}
export function closeAllBookDbs(): void {
for (const db of pools.values()) db.close()
pools.clear()
}
+22
View File
@@ -0,0 +1,22 @@
import type { SqliteDb } from './types'
import schemaV1 from './schema-v1.sql?raw'
const CURRENT_VERSION = 1
export function migrate(db: SqliteDb): 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
db.exec(schemaV1)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(
CURRENT_VERSION,
'initial schema'
)
}
+91
View File
@@ -0,0 +1,91 @@
import { randomUUID } from 'crypto'
import { countWords } from '../../services/word-count'
import type { Chapter, ChapterStatus } from '../../../shared/types'
import type { SqliteDb } from '../types'
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 ChapterStatus,
wordCount: row.word_count as number,
sortOrder: row.sort_order as number,
cursorOffset: (row.cursor_offset as number) ?? 0
}
}
export class ChapterRepository {
constructor(private db: SqliteDb) {}
create(volumeId: string, title: string, sortOrder: number, content = ''): Chapter {
const id = randomUUID()
const wordCount = countWords(content)
this.db
.prepare(
`INSERT INTO chapters (id, volume_id, title, content, word_count, sort_order)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(id, volumeId, title, content, wordCount, 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
return row ? mapRow(row) : null
}
list(): Chapter[] {
return (
this.db.prepare('SELECT * FROM chapters ORDER BY sort_order').all() as Record<string, unknown>[]
).map(mapRow)
}
listByVolume(volumeId: string): Chapter[] {
return (
this.db
.prepare('SELECT * FROM chapters WHERE volume_id = ? ORDER BY sort_order')
.all(volumeId) as Record<string, unknown>[]
).map(mapRow)
}
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)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM chapters WHERE id = ?').run(id)
}
nextSortOrder(volumeId: string): number {
const row = this.db
.prepare('SELECT MAX(sort_order) AS maxOrder FROM chapters WHERE volume_id = ?')
.get(volumeId) as { maxOrder: number | null }
return (row?.maxOrder ?? -1) + 1
}
}
+52
View File
@@ -0,0 +1,52 @@
import { randomUUID } from 'crypto'
import type { Volume } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): Volume {
return {
id: row.id as string,
name: row.name as string,
description: row.description as string,
sortOrder: row.sort_order as number
}
}
export class VolumeRepository {
constructor(private db: SqliteDb) {}
create(name: string, sortOrder: number): Volume {
const id = randomUUID()
this.db
.prepare(
`INSERT INTO volumes (id, name, sort_order) VALUES (?, ?, ?)`
)
.run(id, name, sortOrder)
return this.get(id)!
}
get(id: string): Volume | null {
const row = this.db.prepare('SELECT * FROM volumes WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
list(): Volume[] {
return (
this.db.prepare('SELECT * FROM volumes ORDER BY sort_order').all() as Record<string, unknown>[]
).map(mapRow)
}
update(id: string, patch: { name?: string; description?: string }): Volume {
const existing = this.get(id)
if (!existing) throw new Error('Volume not found')
this.db
.prepare(
`UPDATE volumes SET name = ?, description = ?, updated_at = datetime('now') WHERE id = ?`
)
.run(patch.name ?? existing.name, patch.description ?? existing.description, id)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM volumes WHERE id = ?').run(id)
}
}
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
description TEXT
);
CREATE TABLE IF NOT EXISTS volumes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS chapters (
id TEXT PRIMARY KEY,
volume_id TEXT,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
word_count INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
cursor_offset INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (volume_id) REFERENCES volumes(id) ON DELETE SET NULL
);
+3
View File
@@ -0,0 +1,3 @@
import type { DatabaseSync } from 'node:sqlite'
export type SqliteDb = DatabaseSync