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
+79
View File
@@ -0,0 +1,79 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import { existsSync } from 'fs'
import { join } from 'path'
import { IPC } from '../shared/ipc-channels'
import { getShortcutManager, registerIpc } from './ipc/register'
import { closeAllBookDbs } from './db/connection'
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_USER_DATA) {
app.setPath('userData', process.env.BILIN_E2E_USER_DATA)
}
let mainWindow: BrowserWindow | null = null
function preloadPath(): string {
const dir = join(__dirname, '../preload')
const mjs = join(dir, 'index.mjs')
if (existsSync(mjs)) return mjs
return join(dir, 'index.js')
}
function createWindow(): void { mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
frame: false,
titleBarStyle: 'hidden',
webPreferences: {
preload: preloadPath(),
contextIsolation: true,
nodeIntegration: false
}
})
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
mainWindow.on('closed', () => {
mainWindow = null
})
getShortcutManager()?.setWindow(mainWindow)
}
app.whenReady().then(() => {
registerIpc()
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
})
ipcMain.handle(IPC.WINDOW_MAXIMIZE, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
if (win.isMaximized()) win.unmaximize()
else win.maximize()
})
ipcMain.handle(IPC.WINDOW_CLOSE, (event) => {
BrowserWindow.fromWebContents(event.sender)?.close()
})
createWindow()
})
app.on('window-all-closed', () => {
getShortcutManager()?.unregisterAll()
closeAllBookDbs()
if (process.platform !== 'darwin') app.quit()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
app.on('will-quit', () => {
getShortcutManager()?.unregisterAll()
})
+39
View File
@@ -0,0 +1,39 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { BookMeta, CreateBookParams } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerBookHandlers(registry: BookRegistryService): void {
ipcMain.handle(IPC.BOOK_LIST, () => wrap(() => registry.list()))
ipcMain.handle(IPC.BOOK_CREATE, (_event, params: CreateBookParams) =>
wrap(() => registry.create(params))
)
ipcMain.handle(IPC.BOOK_DELETE, (_event, { bookId }: { bookId: string }) =>
wrap(() => {
registry.delete(bookId)
})
)
ipcMain.handle(IPC.BOOK_OPEN, (_event, { bookId }: { bookId: string }) =>
wrap(() => registry.open(bookId))
)
ipcMain.handle(
IPC.BOOK_UPDATE_META,
(
_event,
{
bookId,
...patch
}: {
bookId: string
lastChapterId?: string | null
lastOpenedAt?: string
status?: BookMeta['status']
}
) => wrap(() => registry.updateMeta(bookId, patch))
)
}
+96
View File
@@ -0,0 +1,96 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ChapterStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerChapterHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.VOLUME_CREATE,
(_event, { bookId, name }: { bookId: string; name: string }) =>
wrap(() => {
const repo = registry.getVolumeRepo(bookId)
const volumes = repo.list()
return repo.create(name, volumes.length)
})
)
ipcMain.handle(
IPC.VOLUME_UPDATE,
(
_event,
{ bookId, volumeId, name, description }: { bookId: string; volumeId: string; name?: string; description?: string }
) => wrap(() => registry.getVolumeRepo(bookId).update(volumeId, { name, description }))
)
ipcMain.handle(
IPC.VOLUME_DELETE,
(_event, { bookId, volumeId }: { bookId: string; volumeId: string }) =>
wrap(() => {
registry.getVolumeRepo(bookId).delete(volumeId)
})
)
ipcMain.handle(
IPC.CHAPTER_CREATE,
(
_event,
{ bookId, volumeId, title }: { bookId: string; volumeId: string; title: string }
) =>
wrap(() => {
const repo = registry.getChapterRepo(bookId)
const sortOrder = repo.nextSortOrder(volumeId)
return repo.create(volumeId, title, sortOrder)
})
)
ipcMain.handle(
IPC.CHAPTER_GET,
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => {
const ch = registry.getChapterRepo(bookId).get(chapterId)
if (!ch) throw new Error('Chapter not found')
return ch
})
)
ipcMain.handle(
IPC.CHAPTER_UPDATE,
(
_event,
{
bookId,
chapterId,
title,
content,
status,
cursorOffset
}: {
bookId: string
chapterId: string
title?: string
content?: string
status?: ChapterStatus
cursorOffset?: number
}
) =>
wrap(() => {
const chapter = registry.getChapterRepo(bookId).update(chapterId, {
title,
content,
status,
cursorOffset
})
registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter
})
)
ipcMain.handle(
IPC.CHAPTER_DELETE,
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => {
registry.getChapterRepo(bookId).delete(chapterId)
})
)
}
+11
View File
@@ -0,0 +1,11 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { GlobalSettingsService } from '../services/global-settings'
import { wrap } from '../result'
export function registerSettingsHandlers(settings: GlobalSettingsService): void {
ipcMain.handle(IPC.SETTINGS_GET, () => wrap(() => settings.get()))
ipcMain.handle(IPC.SETTINGS_UPDATE, (_event, partial) =>
wrap(() => settings.update(partial))
)
}
+18
View File
@@ -0,0 +1,18 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ActionId } from '../../../shared/types'
import { ShortcutManager } from '../shortcuts/manager'
import { wrap } from '../result'
export function registerShortcutHandlers(shortcuts: ShortcutManager): void {
ipcMain.handle(IPC.SHORTCUT_GET_ALL, () => wrap(() => shortcuts.getAll()))
ipcMain.handle(
IPC.SHORTCUT_REGISTER,
(_event, { action, accelerator }: { action: ActionId; accelerator: string }) =>
wrap(() => {
const ok = shortcuts.save(action, accelerator)
if (!ok) throw new Error('快捷键注册失败,可能与其他应用冲突')
})
)
}
+30
View File
@@ -0,0 +1,30 @@
import { join } from 'path'
import { app } from 'electron'
import { GlobalSettingsService } from '../services/global-settings'
import { BookRegistryService } from '../services/book-registry'
import { ShortcutManager } from '../shortcuts/manager'
import { registerSettingsHandlers } from './handlers/settings.handler'
import { registerBookHandlers } from './handlers/book.handler'
import { registerChapterHandlers } from './handlers/chapter.handler'
import { registerShortcutHandlers } from './handlers/shortcut.handler'
let shortcutManager: ShortcutManager | null = null
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
const userData = app.getPath('userData')
const settings = new GlobalSettingsService(userData)
const books = new BookRegistryService(userData)
shortcutManager = new ShortcutManager(settings)
registerSettingsHandlers(settings)
registerBookHandlers(books)
registerChapterHandlers(books)
registerShortcutHandlers(shortcutManager)
return { settings, books, shortcuts: shortcutManager }
}
export function getShortcutManager(): ShortcutManager | null {
return shortcutManager
}
+18
View File
@@ -0,0 +1,18 @@
import { ErrorCode, type IpcError, type 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)
}
}
+136
View File
@@ -0,0 +1,136 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { randomUUID } from 'crypto'
import { openBookDb } from '../db/connection'
import { VolumeRepository } from '../db/repositories/volume.repo'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types'
interface RegistryFile {
books: BookMeta[]
}
export class BookRegistryService {
private booksDir: string
constructor(private userDataDir: string) {
this.booksDir = join(userDataDir, 'books')
if (!existsSync(this.booksDir)) mkdirSync(this.booksDir, { recursive: true })
}
private registryPath(): string {
return join(this.userDataDir, 'books_registry.json')
}
private readRegistry(): RegistryFile {
const path = this.registryPath()
if (!existsSync(path)) return { books: [] }
return JSON.parse(readFileSync(path, 'utf-8')) as RegistryFile
}
private writeRegistry(data: RegistryFile): void {
writeFileSync(this.registryPath(), JSON.stringify(data, null, 2), 'utf-8')
}
list(): BookMeta[] {
return this.readRegistry().books.sort(
(a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime()
)
}
getMeta(bookId: string): BookMeta | null {
return this.readRegistry().books.find((b) => b.id === bookId) ?? null
}
private dbPath(bookId: string): string {
return join(this.booksDir, `${bookId}.sqlite`)
}
create(params: CreateBookParams): BookMeta {
const id = randomUUID()
const now = new Date().toISOString()
const meta: BookMeta = {
id,
name: params.name,
category: params.category,
targetWordCount: params.targetWordCount ?? null,
dbPath: join('books', `${id}.sqlite`),
status: 'draft',
lastOpenedAt: now,
lastChapterId: null,
createdAt: now
}
const db = openBookDb(this.dbPath(id))
const volumes = new VolumeRepository(db)
const chapters = new ChapterRepository(db)
const vol = volumes.create('第一卷', 0)
if (params.createSampleChapter) {
const ch = chapters.create(vol.id, '第一章', 0, '在这里开始你的创作……')
meta.lastChapterId = ch.id
}
const registry = this.readRegistry()
registry.books.unshift(meta)
this.writeRegistry(registry)
return meta
}
delete(bookId: string): void {
const registry = this.readRegistry()
registry.books = registry.books.filter((b) => b.id !== bookId)
this.writeRegistry(registry)
}
updateMeta(bookId: string, patch: Partial<Pick<BookMeta, 'lastOpenedAt' | 'lastChapterId' | 'status'>>): BookMeta {
const registry = this.readRegistry()
const idx = registry.books.findIndex((b) => b.id === bookId)
if (idx === -1) throw new Error('Book not found')
registry.books[idx] = { ...registry.books[idx], ...patch }
this.writeRegistry(registry)
return registry.books[idx]
}
open(bookId: string): BookOpenResult {
const meta = this.getMeta(bookId)
if (!meta) throw new Error('Book not found')
const db = openBookDb(join(this.userDataDir, meta.dbPath))
const volumes = new VolumeRepository(db).list()
const chapters = new ChapterRepository(db).list()
const updated = this.updateMeta(bookId, { lastOpenedAt: new Date().toISOString() })
return { meta: updated, volumes, chapters }
}
getDb(bookId: string) {
const meta = this.getMeta(bookId)
if (!meta) throw new Error('Book not found')
return openBookDb(join(this.userDataDir, meta.dbPath))
}
getVolumeRepo(bookId: string): VolumeRepository {
return new VolumeRepository(this.getDb(bookId))
}
getChapterRepo(bookId: string): ChapterRepository {
return new ChapterRepository(this.getDb(bookId))
}
}
export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): {
book: number
byVolume: Record<string, number>
} {
const byVolume: Record<string, number> = {}
for (const v of volumes) byVolume[v.id] = 0
let book = 0
for (const ch of chapters) {
book += ch.wordCount
if (ch.volumeId && byVolume[ch.volumeId] !== undefined) {
byVolume[ch.volumeId] += ch.wordCount
}
}
return { book, byVolume }
}
+50
View File
@@ -0,0 +1,50 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } 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')) as Partial<GlobalSettings>
return {
...defaults(),
...raw,
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts }
}
}
update(partial: Partial<GlobalSettings>): GlobalSettings {
const current = this.get()
const next: GlobalSettings = {
...current,
...partial,
shortcuts: partial.shortcuts
? { ...current.shortcuts, ...partial.shortcuts }
: current.shortcuts
}
writeFileSync(this.filePath(), JSON.stringify(next, null, 2), 'utf-8')
return next
}
}
+6
View File
@@ -0,0 +1,6 @@
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)
}
+64
View File
@@ -0,0 +1,64 @@
import { BrowserWindow, globalShortcut } from 'electron'
import { DEFAULT_SHORTCUTS } from '../../shared/default-shortcuts'
import { IPC } from '../../shared/ipc-channels'
import type { ActionId } from '../../shared/types'
import { GlobalSettingsService } from '../services/global-settings'
export class ShortcutManager {
private bindings = new Map<ActionId, string>()
private targetWindow: BrowserWindow | null = null
constructor(private settings: GlobalSettingsService) {}
setWindow(win: BrowserWindow): void {
this.targetWindow = win
this.registerAll(this.settings.get().shortcuts)
}
unregisterAll(): void {
for (const acc of this.bindings.values()) {
try {
globalShortcut.unregister(acc)
} catch {
/* ignore */
}
}
this.bindings.clear()
}
register(action: ActionId, accelerator: string): boolean {
const prev = this.bindings.get(action)
if (prev) globalShortcut.unregister(prev)
const win = this.targetWindow
if (!win) return false
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>): void {
this.unregisterAll()
const merged = { ...DEFAULT_SHORTCUTS, ...map }
for (const [action, acc] of Object.entries(merged) as [ActionId, string][]) {
this.register(action, acc)
}
}
getAll(): Record<ActionId, string> {
return { ...DEFAULT_SHORTCUTS, ...this.settings.get().shortcuts }
}
save(action: ActionId, accelerator: string): boolean {
const ok = this.register(action, accelerator)
if (ok) {
const shortcuts = this.settings.get().shortcuts
shortcuts[action] = accelerator
this.settings.update({ shortcuts })
}
return ok
}
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.sql?raw' {
const content: string
export default content
}
+78
View File
@@ -0,0 +1,78 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
import { IPC } from '../shared/ipc-channels'
import type {
ActionId,
BookMeta,
BookOpenResult,
Chapter,
CreateBookParams,
GlobalSettings,
IpcResult,
UpdateChapterParams,
Volume
} from '../shared/types'
const electronAPI = {
window: {
minimize: (): Promise<void> => ipcRenderer.invoke(IPC.WINDOW_MINIMIZE),
maximize: (): Promise<void> => ipcRenderer.invoke(IPC.WINDOW_MAXIMIZE),
close: (): Promise<void> => ipcRenderer.invoke(IPC.WINDOW_CLOSE)
},
settings: {
get: (): Promise<IpcResult<GlobalSettings>> => ipcRenderer.invoke(IPC.SETTINGS_GET),
update: (partial: Partial<GlobalSettings>): Promise<IpcResult<GlobalSettings>> =>
ipcRenderer.invoke(IPC.SETTINGS_UPDATE, partial)
},
book: {
list: (): Promise<IpcResult<BookMeta[]>> => ipcRenderer.invoke(IPC.BOOK_LIST),
create: (params: CreateBookParams): Promise<IpcResult<BookMeta>> =>
ipcRenderer.invoke(IPC.BOOK_CREATE, params),
delete: (bookId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.BOOK_DELETE, { bookId }),
open: (bookId: string): Promise<IpcResult<BookOpenResult>> =>
ipcRenderer.invoke(IPC.BOOK_OPEN, { bookId }),
updateMeta: (
bookId: string,
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch })
},
volume: {
create: (bookId: string, name: string): Promise<IpcResult<Volume>> =>
ipcRenderer.invoke(IPC.VOLUME_CREATE, { bookId, name }),
update: (
bookId: string,
volumeId: string,
patch: { name?: string; description?: string }
): Promise<IpcResult<Volume>> =>
ipcRenderer.invoke(IPC.VOLUME_UPDATE, { bookId, volumeId, ...patch }),
delete: (bookId: string, volumeId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.VOLUME_DELETE, { bookId, volumeId })
},
chapter: {
create: (bookId: string, volumeId: string, title: string): Promise<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.CHAPTER_CREATE, { bookId, volumeId, title }),
get: (bookId: string, chapterId: string): Promise<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.CHAPTER_GET, { bookId, chapterId }),
update: (params: UpdateChapterParams): Promise<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.CHAPTER_UPDATE, params),
delete: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId })
},
shortcut: {
getAll: (): Promise<IpcResult<Record<ActionId, string>>> =>
ipcRenderer.invoke(IPC.SHORTCUT_GET_ALL),
register: (action: ActionId, accelerator: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.SHORTCUT_REGISTER, { action, accelerator })
},
onShortcutTriggered: (callback: (action: ActionId) => void): (() => void) => {
const handler = (_event: IpcRendererEvent, payload: { action: ActionId }) => {
callback(payload.action)
}
ipcRenderer.on(IPC.SHORTCUT_TRIGGERED, handler)
return () => ipcRenderer.removeListener(IPC.SHORTCUT_TRIGGERED, handler)
}
}
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
export type ElectronAPI = typeof electronAPI
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import { Provider as JotaiProvider } from 'jotai'
import { useTranslation } from 'react-i18next'
import './i18n'
import './styles/themes.css'
import './styles/globals.css'
import './styles/layout.css'
import { TopBar } from '@renderer/components/layout/TopBar'
import { HomePage } from '@renderer/components/home/HomePage'
import { EditorLayout } from '@renderer/components/layout/EditorLayout'
import { SettingsPage } from '@renderer/components/settings/SettingsPage'
import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWizard'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useTabStore } from '@renderer/stores/useTabStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { ipcCall } from '@renderer/lib/ipc-client'
function AppInner(): React.JSX.Element {
const { t } = useTranslation()
const view = useAppStore((s) => s.view)
const toast = useAppStore((s) => s.toast)
const showToast = useAppStore((s) => s.showToast)
const setView = useAppStore((s) => s.setView)
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
const loadBooks = useBookStore((s) => s.loadBooks)
const { activeTabId, openTabs } = useTabStore()
const openBook = useBookStore((s) => s.openBook)
const [showOnboarding, setShowOnboarding] = useState(false)
useEffect(() => {
void (async () => {
await loadSettings()
await loadBooks()
})()
}, [loadSettings, loadBooks])
useEffect(() => {
if (loaded && !onboardingCompleted) setShowOnboarding(true)
}, [loaded, onboardingCompleted])
useEffect(() => {
const unsub = window.electronAPI.onShortcutTriggered(async (action) => {
if (action === 'saveChapter') {
try {
await flushEditorSave()
} catch {
showToast(t('toast.saveFailed'))
}
return
}
if (action === 'newChapter') {
if (view !== 'editor') return
const { currentBookId, activeVolumeId, refreshChapters, setSelectedChapter } = useBookStore.getState()
if (!currentBookId || !activeVolumeId) return
await flushEditorSave()
const ch = await ipcCall(() =>
window.electronAPI.chapter.create(currentBookId, activeVolumeId, t('editor.newChapter'))
)
await refreshChapters()
setSelectedChapter(ch.id)
return
}
if (action === 'closeTab') {
const tab = openTabs.find((x) => x.id === activeTabId)
if (!tab) return
const next = useTabStore.getState().closeTab(tab.id)
if (next) {
const nt = useTabStore.getState().openTabs.find((x) => x.id === next)
if (nt?.type === 'book' && nt.bookId) {
await openBook(nt.bookId)
setView('editor')
} else setView('settings')
} else setView('home')
return
}
showToast(t('feature.comingSoon'))
})
return unsub
}, [view, activeTabId, openTabs, openBook, setView, showToast, t])
const handleOnboardingComplete = (): void => {
setShowOnboarding(false)
}
return (
<div id="app">
<TopBar />
<div id="main-area">
{view === 'home' && <HomePage />}
{view === 'editor' && <EditorLayout />}
{view === 'settings' && <SettingsPage />}
</div>
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
{toast && <div className="toast">{toast}</div>}
</div>
)
}
export default function App(): React.JSX.Element {
return (
<JotaiProvider>
<AppInner />
</JotaiProvider>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { atom } from 'jotai'
export const editorDirtyAtom = atom(false)
export const editorSavingAtom = atom(false)
export const lastSavedAtAtom = atom<Date | null>(null)
export const wordCountAtom = atom({ chapter: 0, volume: 0, book: 0 })
@@ -0,0 +1,146 @@
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Placeholder from '@tiptap/extension-placeholder'
import { useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useSetAtom } from 'jotai'
import type { Chapter } from '@shared/types'
import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } from '@renderer/atoms/editorAtoms'
import { useBookStore } from '@renderer/stores/useBookStore'
import { ipcCall } from '@renderer/lib/ipc-client'
function countPlain(text: string): number {
const stripped = text.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)
}
interface TipTapEditorProps {
chapter: Chapter | null
bookId: string | null
}
export function TipTapEditor({ chapter, bookId }: TipTapEditorProps): React.JSX.Element {
const { t, i18n } = useTranslation()
const setDirty = useSetAtom(editorDirtyAtom)
const setSaving = useSetAtom(editorSavingAtom)
const setLastSaved = useSetAtom(lastSavedAtAtom)
const setWordCount = useSetAtom(wordCountAtom)
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
const chapters = useBookStore((s) => s.chapters)
const timerRef = useRef<ReturnType<typeof setTimeout>>()
const chapterIdRef = useRef<string | null>(null)
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Placeholder.configure({ placeholder: t('editor.placeholder') })
],
content: chapter?.content ?? '',
onUpdate: ({ editor: ed }) => {
setDirty(true)
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
void (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave?.()
}, 2000)
const text = ed.getText()
const volId = chapter?.volumeId
const chapterCount = countPlain(text)
const volumeTotal = chapters
.filter((c) => c.volumeId === volId)
.reduce((sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount), 0)
const bookTotal = chapters.reduce(
(sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount),
0
)
setWordCount({ chapter: chapterCount, volume: volumeTotal, book: bookTotal })
},
editorProps: {
handlePaste: (view, event) => {
if (event.shiftKey) return false
const text = event.clipboardData?.getData('text/plain')
if (text) {
view.dispatch(view.state.tr.insertText(text))
return true
}
return false
}
}
}, [i18n.language])
const save = useCallback(async () => {
if (!bookId || !chapter || !editor) return
setSaving(true)
try {
const html = editor.getHTML()
const text = editor.getText()
const updated = await ipcCall(() =>
window.electronAPI.chapter.update({
bookId,
chapterId: chapter.id,
content: html,
cursorOffset: editor.state.selection.anchor
})
)
updateChapterLocal(updated)
setDirty(false)
setLastSaved(new Date())
const volId = updated.volumeId
const volumeTotal = chapters
.map((c) => (c.id === updated.id ? updated : c))
.filter((c) => c.volumeId === volId)
.reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0)
const bookTotal = chapters
.map((c) => (c.id === updated.id ? updated : c))
.reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0)
setWordCount({ chapter: countPlain(text), volume: volumeTotal, book: bookTotal })
} finally {
setSaving(false)
}
}, [bookId, chapter, chapters, editor, setDirty, setLastSaved, setSaving, setWordCount, updateChapterLocal])
useEffect(() => {
;(window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave = save
return () => {
clearTimeout(timerRef.current)
delete (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave
}
}, [save])
useEffect(() => {
if (!editor || !chapter) return
if (chapterIdRef.current !== chapter.id) {
void (async () => {
await flushEditorSave()
chapterIdRef.current = chapter.id
editor.commands.setContent(chapter.content || '')
const pos = Math.min(chapter.cursorOffset ?? 0, editor.state.doc.content.size)
editor.commands.focus(pos)
setDirty(false)
setWordCount({
chapter: chapter.wordCount,
volume: chapters.filter((c) => c.volumeId === chapter.volumeId).reduce((s, c) => s + c.wordCount, 0),
book: chapters.reduce((s, c) => s + c.wordCount, 0)
})
})()
}
}, [chapter, editor, chapters, setDirty, setWordCount])
if (!chapter) {
return <div className="placeholder-box">{t('editor.placeholder')}</div>
}
return (
<div className="editor-content-wrap">
<EditorContent editor={editor} />
</div>
)
}
export async function flushEditorSave(): Promise<void> {
const fn = (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave
if (fn) await fn()
}
+138
View File
@@ -0,0 +1,138 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useTabStore } from '@renderer/stores/useTabStore'
import { ipcCall } from '@renderer/lib/ipc-client'
export function HomePage(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const setView = useAppStore((s) => s.setView)
const { books, loadBooks, openBook } = useBookStore()
const { openBookTab, openSettingsTab } = useTabStore()
const [showDialog, setShowDialog] = useState(false)
const [name, setName] = useState('')
const [category, setCategory] = useState('玄幻')
const [target, setTarget] = useState('')
const handleOpenBook = async (bookId: string, bookName: string): Promise<void> => {
await openBook(bookId)
openBookTab(bookId, bookName)
setView('editor')
}
const handleCreate = async (): Promise<void> => {
if (!name.trim()) {
showToast(t('toast.enterBookName'))
return
}
const meta = await ipcCall(() =>
window.electronAPI.book.create({
name: name.trim(),
category,
targetWordCount: target ? Number(target) : null
})
)
setShowDialog(false)
setName('')
await loadBooks()
showToast(t('toast.bookCreated'))
await handleOpenBook(meta.id, meta.name)
}
return (
<div id="home-page">
<div className="home-hero">
<h1> {t('app.name')}</h1>
<p className="subtitle">{t('app.tagline')}</p>
</div>
<div className="home-actions">
<button type="button" className="btn-large primary" onClick={() => setShowDialog(true)}>
+ {t('home.newBook')}
</button>
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
{t('home.importBook')}
</button>
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
{t('home.exportAll')}
</button>
<button
type="button"
className="btn-large"
onClick={() => {
openSettingsTab()
setView('settings')
}}
>
{t('settings.title')}
</button>
</div>
<h3 style={{ marginBottom: 12, fontSize: 14 }}>
📚 {t('home.myBooks')}{' '}
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>
{t('home.bookCount', { count: books.length })}
</span>
</h3>
<div className="book-grid">
{books.map((book) => (
<div
key={book.id}
className="book-card"
onClick={() => void handleOpenBook(book.id, book.name)}
onKeyDown={(e) => e.key === 'Enter' && void handleOpenBook(book.id, book.name)}
role="button"
tabIndex={0}
>
<div className="book-name">{book.name}</div>
<div className="book-meta">
<div>{book.category}</div>
<div>{new Date(book.lastOpenedAt).toLocaleDateString()}</div>
</div>
</div>
))}
<div
className="book-card"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 100, color: 'var(--text-muted)' }}
onClick={() => setShowDialog(true)}
role="button"
tabIndex={0}
>
+ {t('home.newBook')}
</div>
</div>
{showDialog && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog-content">
<h3 style={{ marginBottom: 16 }}>{t('dialog.newBook.title')}</h3>
<div className="form-group">
<label>{t('dialog.newBook.name')}</label>
<input value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</div>
<div className="form-group">
<label>{t('dialog.newBook.category')}</label>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="玄幻"></option>
<option value="仙侠"></option>
<option value="科幻"></option>
<option value="言情"></option>
</select>
</div>
<div className="form-group">
<label>{t('dialog.newBook.target')}</label>
<input type="number" value={target} onChange={(e) => setTarget(e.target.value)} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
<button type="button" className="btn" onClick={() => setShowDialog(false)}>
{t('dialog.cancel')}
</button>
<button type="button" className="btn primary" onClick={() => void handleCreate()}>
{t('dialog.create')}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,149 @@
import { useTranslation } from 'react-i18next'
import { useAtomValue } from 'jotai'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { RightPanel } from '@renderer/components/layout/RightPanel'
import {
editorDirtyAtom,
editorSavingAtom,
lastSavedAtAtom,
wordCountAtom
} from '@renderer/atoms/editorAtoms'
export function EditorLayout(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const sidebarPanel = useAppStore((s) => s.setSidebarPanel)
const activePanel = useAppStore((s) => s.sidebarPanel)
const {
currentBookId,
volumes,
chapters,
selectedChapterId,
activeVolumeId,
setSelectedChapter,
setActiveVolume,
refreshChapters
} = useBookStore()
const dirty = useAtomValue(editorDirtyAtom)
const saving = useAtomValue(editorSavingAtom)
const lastSaved = useAtomValue(lastSavedAtAtom)
const wordCount = useAtomValue(wordCountAtom)
const currentChapter = chapters.find((c) => c.id === selectedChapterId) ?? null
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
const handleSelectChapter = async (chapterId: string): Promise<void> => {
await flushEditorSave()
setSelectedChapter(chapterId)
}
const handleNewChapter = async (): Promise<void> => {
if (!currentBookId || !activeVolumeId) return
await flushEditorSave()
const ch = await ipcCall(() =>
window.electronAPI.chapter.create(currentBookId, activeVolumeId, t('editor.newChapter'))
)
await refreshChapters()
setSelectedChapter(ch.id)
}
const handleNewVolume = async (): Promise<void> => {
if (!currentBookId) return
const vol = await ipcCall(() =>
window.electronAPI.volume.create(currentBookId, t('editor.newVolume'))
)
await refreshChapters()
setActiveVolume(vol.id)
}
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
return (
<div id="editor-layout">
<div id="left-sidebar">
<div className="sidebar-header">{bookMeta?.name ?? '—'}</div>
<div className="sidebar-nav">
{(['chapters', 'outline', 'setting', 'inspiration'] as const).map((panel) => (
<button
key={panel}
type="button"
className={`nav-btn ${activePanel === panel ? 'active' : ''}`}
onClick={() => sidebarPanel(panel)}
>
{panel === 'chapters' && t('sidebar.chapters')}
{panel === 'outline' && t('sidebar.outline')}
{panel === 'setting' && t('sidebar.settings')}
{panel === 'inspiration' && t('sidebar.inspiration')}
</button>
))}
</div>
<div className={`sidebar-panel ${activePanel === 'chapters' ? 'active' : ''}`}>
{volumes.map((vol) => (
<div key={vol.id}>
<div
className="vol-header"
onClick={() => setActiveVolume(vol.id)}
style={{ color: activeVolumeId === vol.id ? 'var(--accent-light)' : undefined }}
>
{vol.name}
</div>
{chapters
.filter((c) => c.volumeId === vol.id)
.map((ch, idx) => (
<div
key={ch.id}
className={`chapter-item ${selectedChapterId === ch.id ? 'active' : ''}`}
onClick={() => void handleSelectChapter(ch.id)}
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
role="button"
tabIndex={0}
>
<span>{idx + 1}.</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
<span className={`ch-badge ${ch.status === 'done' ? 'done' : 'draft'}`}>{ch.status}</span>
</div>
))}
</div>
))}
</div>
{activePanel !== 'chapters' && (
<div className="sidebar-panel active">
<div className="placeholder-box" style={{ padding: 20 }}>
{t('feature.comingSoon')}
</div>
</div>
)}
<div className="sidebar-footer">
<button type="button" onClick={() => void handleNewChapter()}>
+ {t('editor.newChapter')}
</button>
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
+ {t('editor.newVolume')}
</button>
</div>
</div>
<div id="editor-area">
<div id="editor-toolbar">
<button type="button" className="tool-btn" onClick={() => showToast(t('feature.comingSoon'))} title="引用">
📎
</button>
<span className="editor-label">
{currentChapter ? currentChapter.title : '—'}
</span>
</div>
<TipTapEditor chapter={currentChapter} bookId={currentBookId} />
<div id="editor-statusbar">
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
</div>
</div>
<RightPanel />
</div>
)
}
@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
export function RightPanel(): React.JSX.Element {
const { t } = useTranslation()
const { rightPanel, setRightPanel } = useAppStore()
const tabs = [
{ id: 'ai' as const, label: t('panel.ai') },
{ id: 'knowledge' as const, label: t('panel.knowledge') },
{ id: 'wordfreq' as const, label: t('panel.wordfreq') },
{ id: 'book-settings' as const, label: t('panel.bookSettings') }
]
return (
<div id="right-panel">
<div className="panel-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`}
onClick={() => setRightPanel(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className={`panel-content ${rightPanel === 'ai' ? 'active' : ''}`}>
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>{t('panel.aiPlaceholder')}</p>
<input className="ai-input-disabled" disabled placeholder={t('feature.comingSoon')} />
</div>
{(['knowledge', 'wordfreq', 'book-settings'] as const).map((id) => (
<div key={id} className={`panel-content ${rightPanel === id ? 'active' : ''}`}>
<div className="placeholder-box">{t('feature.comingSoon')}</div>
</div>
))}
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useTabStore } from '@renderer/stores/useTabStore'
import { useBookStore } from '@renderer/stores/useBookStore'
export function TopBar(): React.JSX.Element {
const { t } = useTranslation()
const setView = useAppStore((s) => s.setView)
const showToast = useAppStore((s) => s.showToast)
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
const currentBookId = useBookStore((s) => s.currentBookId)
const handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
e.stopPropagation()
const next = closeTab(tabId)
if (next) {
const tab = useTabStore.getState().openTabs.find((t) => t.id === next)
if (tab?.type === 'book' && tab.bookId) {
void useBookStore.getState().openBook(tab.bookId)
setView('editor')
} else if (tab?.type === 'settings') {
setView('settings')
}
} else {
setView('home')
}
}
return (
<div id="topbar">
<button type="button" className="logo-area" onClick={() => setView('home')}>
{t('app.name')}
</button>
<div className="tab-bar">
{openTabs.map((tab) => (
<button
key={tab.id}
type="button"
className={`tab ${tab.id === activeTabId ? 'active' : ''}`}
onClick={() => {
switchTab(tab.id)
if (tab.type === 'book' && tab.bookId) {
void useBookStore.getState().openBook(tab.bookId)
setView('editor')
} else {
setView('settings')
}
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{tab.name}
</span>
<span
onClick={(e) => handleCloseTab(tab.id, e)}
style={{ marginLeft: 4, opacity: 0.6, fontSize: 10 }}
>
</span>
</button>
))}
</div>
<div className="top-spacer" />
<button type="button" className="top-btn" title={t('feature.comingSoon')} onClick={() => showToast(t('feature.comingSoon'))}>
📊
</button>
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
</button>
<button type="button" className="top-btn" onClick={() => showToast(t('feature.comingSoon'))}>
👁
</button>
<button type="button" className="top-btn" onClick={() => window.electronAPI.window.minimize()}></button>
<button type="button" className="top-btn" onClick={() => window.electronAPI.window.maximize()}></button>
<button type="button" className="top-btn window-close" onClick={() => window.electronAPI.window.close()}></button>
</div>
)
}
@@ -0,0 +1,90 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { ipcCall } from '@renderer/lib/ipc-client'
interface OnboardingWizardProps {
onComplete: () => void
}
export function OnboardingWizard({ onComplete }: OnboardingWizardProps): React.JSX.Element {
const { t } = useTranslation()
const update = useSettingsStore((s) => s.update)
const loadBooks = useBookStore((s) => s.loadBooks)
const [step, setStep] = useState(0)
const [penName, setPenName] = useState('')
const [createSample, setCreateSample] = useState(true)
const finish = async (skipped = false): Promise<void> => {
const name = skipped ? '未命名作者' : penName.trim() || '未命名作者'
await update({ penName: name, onboardingCompleted: true })
if (createSample) {
await ipcCall(() =>
window.electronAPI.book.create({
name: t('book.createSample'),
category: '未分类',
createSampleChapter: true
})
)
await loadBooks()
}
onComplete()
}
const slides = [
<div key="0" style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: 48, marginBottom: 12 }}></div>
<h2>{t('onboarding.welcome')}</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.tagline')}</p>
</div>,
<div key="1">
<h2>{t('onboarding.slide1')}</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.slide1desc')}</p>
</div>,
<div key="2">
<h2>{t('onboarding.slide2')}</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.slide2desc')}</p>
</div>,
<div key="3">
<h2>{t('onboarding.slide3')}</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: 8 }}>{t('onboarding.slide3desc')}</p>
</div>,
<div key="4">
<h2>{t('onboarding.penName')}</h2>
<input
data-testid="onboarding-pen-name"
value={penName}
onChange={(e) => setPenName(e.target.value)}
placeholder={t('onboarding.penNamePlaceholder')}
style={{ width: '100%', marginTop: 12, padding: 10, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--bg-tertiary)', color: 'inherit' }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
<input type="checkbox" checked={createSample} onChange={(e) => setCreateSample(e.target.checked)} />
{t('onboarding.createSample')}
</label>
</div>
]
return (
<div className="dialog-overlay">
<div className="dialog-content" style={{ maxWidth: 520 }}>
{slides[step]}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 24 }}>
<button type="button" className="btn" onClick={() => void finish(true)}>
{t('onboarding.skip')}
</button>
{step < slides.length - 1 ? (
<button type="button" className="btn primary" data-testid="onboarding-next" onClick={() => setStep((s) => s + 1)}>
{t('onboarding.next')}
</button>
) : (
<button type="button" className="btn primary" data-testid="onboarding-start" onClick={() => void finish(false)}>
{t('onboarding.start')}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,103 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ThemeId, Language } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
const THEMES: { id: ThemeId; key: string }[] = [
{ id: 'default', key: 'theme.default' },
{ id: 'bamboo', key: 'theme.bamboo' },
{ id: 'moonlit', key: 'theme.moonlit' },
{ id: 'ricepaper', key: 'theme.ricepaper' },
{ id: 'mist', key: 'theme.mist' },
{ id: 'teagarden', key: 'theme.teagarden' },
{ id: 'twilight', key: 'theme.twilight' }
]
export function SettingsPage(): React.JSX.Element {
const { t } = useTranslation()
const settings = useSettingsStore()
const [section, setSection] = useState<'general' | 'shortcuts' | 'about'>('general')
return (
<div id="settings-page">
<div className="settings-sidebar">
<div
className={`settings-nav-item ${section === 'general' ? 'active' : ''}`}
onClick={() => setSection('general')}
role="button"
tabIndex={0}
>
{t('settings.general')}
</div>
<div
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
onClick={() => setSection('shortcuts')}
role="button"
tabIndex={0}
>
{t('settings.shortcuts')}
</div>
<div
className={`settings-nav-item ${section === 'about' ? 'active' : ''}`}
onClick={() => setSection('about')}
role="button"
tabIndex={0}
>
{t('settings.about')}
</div>
</div>
<div className="settings-content">
<h2 style={{ marginBottom: 20 }}>{t('settings.title')}</h2>
{section === 'general' && (
<>
<div className="setting-item">
<span>{t('settings.penName')}</span>
<input
data-testid="settings-pen-name"
value={settings.penName}
onChange={(e) => void settings.update({ penName: e.target.value })}
style={{ width: 160, padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
/>
</div>
<div className="setting-item">
<span>{t('settings.theme')}</span>
<select
data-testid="settings-theme"
value={settings.theme}
onChange={(e) => void settings.update({ theme: e.target.value as ThemeId })}
style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
>
{THEMES.map((th) => (
<option key={th.id} value={th.id}>
{t(th.key)}
</option>
))}
</select>
</div>
<div className="setting-item">
<span>{t('settings.language')}</span>
<select
data-testid="settings-language"
value={settings.language}
onChange={(e) => void settings.update({ language: e.target.value as Language })}
style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
>
<option value="zh-CN"></option>
<option value="en">English</option>
</select>
</div>
</>
)}
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v0.1.0
<br />
{t('app.tagline')}
</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ActionId } from '@shared/types'
import { DEFAULT_SHORTCUTS } from '@shared/default-shortcuts'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAppStore } from '@renderer/stores/useAppStore'
const ACTION_IDS: ActionId[] = [
'saveChapter',
'newChapter',
'focusMode',
'globalSearch',
'closeTab',
'openReference',
'insertLandmark',
'openLandmarks',
'exportBook',
'toggleTTS',
'captureInspiration'
]
function formatAccelerator(acc: string): string {
return acc.replace(/CommandOrControl/g, 'Ctrl')
}
function keyEventToAccelerator(e: KeyboardEvent): string | null {
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return null
const parts: string[] = []
if (e.ctrlKey || e.metaKey) parts.push('CommandOrControl')
if (e.shiftKey) parts.push('Shift')
if (e.altKey) parts.push('Alt')
const special: Record<string, string> = {
' ': 'Space',
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
Escape: 'Esc',
Backspace: 'Backspace',
Delete: 'Delete',
Enter: 'Enter',
Tab: 'Tab'
}
let key = special[e.key]
if (!key) {
if (e.key.length === 1) key = e.key.toUpperCase()
else if (e.code.startsWith('Key')) key = e.code.slice(3)
else if (e.code.startsWith('Digit')) key = e.code.slice(5)
else key = e.key
}
parts.push(key)
return parts.join('+')
}
export function ShortcutEditor(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const [bindings, setBindings] = useState<Record<ActionId, string>>({ ...DEFAULT_SHORTCUTS })
const [recording, setRecording] = useState<ActionId | null>(null)
const loadBindings = useCallback(async () => {
const all = await ipcCall(() => window.electronAPI.shortcut.getAll())
setBindings(all)
}, [])
useEffect(() => {
void loadBindings()
}, [loadBindings])
useEffect(() => {
if (!recording) return
const onKeyDown = (e: KeyboardEvent): void => {
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
setRecording(null)
return
}
const accelerator = keyEventToAccelerator(e)
if (!accelerator) return
const conflict = ACTION_IDS.some(
(id) => id !== recording && bindings[id] === accelerator
)
if (conflict) {
showToast(t('shortcuts.conflict'))
setRecording(null)
return
}
void (async () => {
try {
await ipcCall(() => window.electronAPI.shortcut.register(recording, accelerator))
setBindings((prev) => ({ ...prev, [recording]: accelerator }))
} catch {
showToast(t('shortcuts.registerFailed'))
} finally {
setRecording(null)
}
})()
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [recording, bindings, showToast, t])
return (
<div className="shortcut-editor">
<p style={{ color: 'var(--text-secondary)', marginBottom: 16, fontSize: 13 }}>
{recording ? t('shortcuts.recordHint') : t('shortcuts.clickToRecord')}
</p>
{ACTION_IDS.map((action) => (
<div key={action} className="setting-item">
<span>{t(`shortcuts.${action}`)}</span>
<button
type="button"
className={`shortcut-key ${recording === action ? 'recording' : ''}`}
data-testid={`shortcut-${action}`}
onClick={() => setRecording(action)}
>
{recording === action ? t('shortcuts.recording') : formatAccelerator(bindings[action] ?? '—')}
</button>
</div>
))}
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
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
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>笔临</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import type { IpcError, IpcResult } from '@shared/types'
export class IpcCallError extends Error {
ipcError: IpcError
constructor(ipcError: IpcError) {
super(ipcError.message)
this.name = 'IpcCallError'
this.ipcError = ipcError
}
}
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
}
+9
View File
@@ -0,0 +1,9 @@
import type { ThemeId } from '@shared/types'
export function applyTheme(theme: ThemeId): void {
if (theme === 'default') {
document.documentElement.removeAttribute('data-theme')
} else {
document.documentElement.setAttribute('data-theme', theme)
}
}
+9
View File
@@ -0,0 +1,9 @@
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>
)
+30
View File
@@ -0,0 +1,30 @@
import { create } from 'zustand'
export type AppView = 'home' | 'editor' | 'settings'
interface AppState {
view: AppView
sidebarPanel: 'chapters' | 'outline' | 'setting' | 'inspiration'
rightPanel: 'ai' | 'knowledge' | 'wordfreq' | 'book-settings'
toast: string | null
setView: (view: AppView) => void
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
setRightPanel: (panel: AppState['rightPanel']) => void
showToast: (message: string) => void
clearToast: () => void
}
export const useAppStore = create<AppState>((set) => ({
view: 'home',
sidebarPanel: 'chapters',
rightPanel: 'ai',
toast: null,
setView: (view) => set({ view }),
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
setRightPanel: (rightPanel) => set({ rightPanel }),
showToast: (toast) => {
set({ toast })
setTimeout(() => set({ toast: null }), 2500)
},
clearToast: () => set({ toast: null })
}))
+66
View File
@@ -0,0 +1,66 @@
import { create } from 'zustand'
import type { BookMeta, Volume, Chapter } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
interface BookState {
books: BookMeta[]
currentBookId: string | null
volumes: Volume[]
chapters: Chapter[]
selectedChapterId: string | null
activeVolumeId: string | null
loadBooks: () => Promise<void>
openBook: (bookId: string) => Promise<void>
setSelectedChapter: (chapterId: string) => void
setActiveVolume: (volumeId: string) => void
refreshChapters: () => Promise<void>
updateChapterLocal: (chapter: Chapter) => void
}
export const useBookStore = create<BookState>((set, get) => ({
books: [],
currentBookId: null,
volumes: [],
chapters: [],
selectedChapterId: null,
activeVolumeId: null,
loadBooks: async () => {
const books = await ipcCall(() => window.electronAPI.book.list())
set({ books })
},
openBook: async (bookId) => {
const result = await ipcCall(() => window.electronAPI.book.open(bookId))
const firstVolume = result.volumes[0]?.id ?? null
const lastChapter =
result.meta.lastChapterId ??
result.chapters.find((c) => c.volumeId === firstVolume)?.id ??
result.chapters[0]?.id ??
null
set({
currentBookId: bookId,
volumes: result.volumes,
chapters: result.chapters,
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
selectedChapterId: lastChapter
})
},
setSelectedChapter: (chapterId) => {
const ch = get().chapters.find((c) => c.id === chapterId)
set({
selectedChapterId: chapterId,
activeVolumeId: ch?.volumeId ?? get().activeVolumeId
})
},
setActiveVolume: (volumeId) => set({ activeVolumeId: volumeId }),
refreshChapters: async () => {
const bookId = get().currentBookId
if (!bookId) return
const result = await ipcCall(() => window.electronAPI.book.open(bookId))
set({ volumes: result.volumes, chapters: result.chapters })
},
updateChapterLocal: (chapter) => {
set((s) => ({
chapters: s.chapters.map((c) => (c.id === chapter.id ? chapter : c))
}))
}
}))
+33
View File
@@ -0,0 +1,33 @@
import { create } from 'zustand'
import type { ThemeId, Language, GlobalSettings, ActionId } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { applyTheme } from '@renderer/lib/theme'
import i18n from '@renderer/i18n'
interface SettingsState extends GlobalSettings {
loaded: boolean
load: () => Promise<void>
update: (partial: Partial<GlobalSettings>) => Promise<void>
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
penName: '未命名作者',
onboardingCompleted: false,
theme: 'default',
language: 'zh-CN',
dailyWordGoal: 0,
shortcuts: {} as Record<ActionId, string>,
loaded: false,
load: async () => {
const data = await ipcCall(() => window.electronAPI.settings.get())
set({ ...data, loaded: true })
applyTheme(data.theme)
await i18n.changeLanguage(data.language)
},
update: async (partial) => {
const data = await ipcCall(() => window.electronAPI.settings.update(partial))
set({ ...get(), ...data })
if (partial.theme) applyTheme(data.theme)
if (partial.language) await i18n.changeLanguage(data.language)
}
}))
+58
View File
@@ -0,0 +1,58 @@
import { create } from 'zustand'
export interface AppTab {
id: string
type: 'book' | 'settings'
name: string
bookId?: string
}
interface TabState {
openTabs: AppTab[]
activeTabId: string | null
openBookTab: (bookId: string, name: string) => void
openSettingsTab: () => void
switchTab: (tabId: string) => void
closeTab: (tabId: string) => string | null
}
export const useTabStore = create<TabState>((set, get) => ({
openTabs: [],
activeTabId: null,
openBookTab: (bookId, name) => {
const existing = get().openTabs.find((t) => t.type === 'book' && t.bookId === bookId)
if (existing) {
set({ activeTabId: existing.id })
return
}
const id = `book-${bookId}`
set((s) => ({
openTabs: [...s.openTabs, { id, type: 'book', name, bookId }],
activeTabId: id
}))
},
openSettingsTab: () => {
const existing = get().openTabs.find((t) => t.type === 'settings')
if (existing) {
set({ activeTabId: existing.id })
return
}
set((s) => ({
openTabs: [...s.openTabs, { id: 'settings', type: 'settings', name: '系统设置' }],
activeTabId: 'settings'
}))
},
switchTab: (tabId) => set({ activeTabId: tabId }),
closeTab: (tabId) => {
const { openTabs, activeTabId } = get()
const idx = openTabs.findIndex((t) => t.id === tabId)
if (idx === -1) return activeTabId
const nextTabs = openTabs.filter((t) => t.id !== tabId)
let nextActive = activeTabId
if (activeTabId === tabId) {
nextActive = nextTabs[Math.max(0, idx - 1)]?.id ?? null
}
set({ openTabs: nextTabs, activeTabId: nextActive })
return nextActive
}
}))
+64
View File
@@ -0,0 +1,64 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
button {
font-family: inherit;
cursor: pointer;
}
input,
select,
textarea {
font-family: inherit;
}
.btn {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: transparent;
color: var(--text-primary);
font-size: 13px;
transition: var(--transition);
}
.btn:hover {
background: var(--bg-hover);
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn.primary:hover {
background: var(--accent-light);
}
.hidden {
display: none !important;
}
+515
View File
@@ -0,0 +1,515 @@
#topbar {
display: flex;
align-items: center;
height: 44px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 0 10px;
gap: 4px;
flex-shrink: 0;
-webkit-app-region: drag;
}
#topbar button,
#topbar .tab,
#topbar .logo-area {
-webkit-app-region: no-drag;
}
.logo-area {
display: flex;
align-items: center;
gap: 6px;
font-weight: 700;
font-size: 14px;
padding: 6px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
border: none;
background: transparent;
color: var(--text-primary);
}
.logo-area:hover {
background: var(--bg-hover);
}
.tab-bar {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
overflow-x: auto;
}
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-secondary);
border: 1px solid transparent;
background: transparent;
cursor: pointer;
max-width: 160px;
}
.tab.active {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-light);
}
.top-spacer {
flex: 1;
-webkit-app-region: drag;
}
.top-btn {
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.top-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.window-close:hover {
background: var(--red) !important;
color: #fff !important;
}
#main-area {
display: flex;
flex: 1;
overflow: hidden;
}
#home-page {
flex: 1;
overflow-y: auto;
padding: 32px 40px;
}
.home-hero h1 {
font-size: 28px;
margin-bottom: 8px;
}
.home-hero .subtitle {
color: var(--text-secondary);
margin-bottom: 24px;
}
.home-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 28px;
}
.btn-large {
padding: 10px 18px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 13px;
}
.btn-large.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.book-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
cursor: pointer;
transition: var(--transition);
}
.book-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.book-name {
font-size: 15px;
font-weight: 600;
margin-bottom: 8px;
}
.book-meta {
font-size: 11px;
color: var(--text-muted);
line-height: 1.6;
}
#editor-layout {
display: flex;
flex: 1;
overflow: hidden;
}
#left-sidebar {
width: 260px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 12px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 13px;
}
.sidebar-nav {
display: flex;
border-bottom: 1px solid var(--border);
}
.nav-btn {
flex: 1;
padding: 8px 4px;
font-size: 11px;
border: none;
background: transparent;
color: var(--text-muted);
border-bottom: 2px solid transparent;
cursor: pointer;
}
.nav-btn.active {
color: var(--accent-light);
border-bottom-color: var(--accent);
}
.sidebar-panel {
flex: 1;
overflow-y: auto;
display: none;
}
.sidebar-panel.active {
display: block;
}
.vol-header {
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
}
.chapter-item {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 12px 7px 20px;
font-size: 12px;
cursor: pointer;
color: var(--text-secondary);
}
.chapter-item:hover,
.chapter-item.active {
background: var(--bg-hover);
color: var(--text-primary);
}
.ch-badge {
margin-left: auto;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
background: var(--bg-tertiary);
}
.ch-badge.done {
color: var(--green);
}
.ch-badge.draft {
color: var(--orange);
}
.sidebar-footer {
padding: 10px;
border-top: 1px solid var(--border);
}
.sidebar-footer button {
width: 100%;
padding: 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 12px;
}
#editor-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
#editor-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.tool-btn {
width: 30px;
height: 30px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.tool-btn:hover,
.tool-btn.active {
background: var(--bg-hover);
color: var(--text-primary);
}
.editor-label {
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
}
.editor-content-wrap {
flex: 1;
overflow-y: auto;
background: var(--editor-bg);
}
.ProseMirror {
min-height: 100%;
padding: 32px 48px;
outline: none;
color: var(--editor-text);
font-size: 16px;
line-height: 1.9;
}
.ProseMirror p.is-editor-empty:first-child::before {
color: var(--text-muted);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
#editor-statusbar {
display: flex;
align-items: center;
gap: 16px;
padding: 6px 14px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
background: var(--bg-secondary);
}
#right-panel {
width: 320px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.panel-tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.panel-tab {
flex: 1;
padding: 8px 4px;
font-size: 10px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.panel-tab.active {
color: var(--accent-light);
border-bottom-color: var(--accent);
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 12px;
display: none;
}
.panel-content.active {
display: flex;
flex-direction: column;
}
.placeholder-box {
color: var(--text-muted);
font-size: 12px;
line-height: 1.7;
padding: 16px;
text-align: center;
}
#settings-page {
flex: 1;
display: flex;
overflow: hidden;
}
.settings-sidebar {
width: 180px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 8px 0;
}
.settings-nav-item {
padding: 10px 16px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
border-left: 3px solid transparent;
}
.settings-nav-item.active {
color: var(--text-primary);
background: var(--bg-tertiary);
border-left-color: var(--accent);
}
.settings-content {
flex: 1;
padding: 24px 32px;
overflow-y: auto;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
margin-bottom: 8px;
font-size: 13px;
}
.shortcut-key {
min-width: 140px;
padding: 6px 12px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-primary);
font-family: ui-monospace, monospace;
font-size: 12px;
cursor: pointer;
text-align: center;
}
.shortcut-key.recording {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
}
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-content {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
width: min(480px, 90vw);
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
}
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-surface);
border: 1px solid var(--border-light);
padding: 10px 18px;
border-radius: 20px;
font-size: 12px;
z-index: 2000;
}
.ai-input-disabled {
width: 100%;
padding: 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-muted);
}
+119
View File
@@ -0,0 +1,119 @@
:root {
--bg-primary: #1c1816;
--bg-secondary: #201c1a;
--bg-tertiary: #262220;
--bg-surface: #2a2624;
--bg-hover: #302c28;
--bg-active: #363230;
--bg-card: #221e1c;
--text-primary: #e8e0d8;
--text-secondary: #b8aca0;
--text-muted: #786858;
--text-dim: #584838;
--accent: #c0504a;
--accent-glow: rgba(192, 80, 74, 0.25);
--accent-light: #d4706a;
--accent-dark: #a0403a;
--green: #609060;
--red: #c0504a;
--orange: #c08050;
--border: #383028;
--border-light: #484030;
--editor-bg: #1c1816;
--editor-text: #e8e0d8;
--radius-sm: 6px;
--radius: 10px;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
[data-theme='bamboo'] {
--bg-primary: #1a1e18;
--bg-secondary: #1e221c;
--bg-tertiary: #242820;
--bg-surface: #282c24;
--bg-hover: #2e322a;
--bg-card: #20241c;
--text-primary: #e0e4d8;
--text-secondary: #b0b8a8;
--text-muted: #687858;
--accent: #7a9a60;
--accent-light: #98b878;
--border: #303828;
--editor-bg: #1a1e18;
--editor-text: #e0e4d8;
}
[data-theme='moonlit'] {
--bg-primary: #1a1e24;
--bg-secondary: #1e2228;
--bg-tertiary: #242830;
--bg-hover: #2e3238;
--bg-card: #202428;
--text-primary: #d8dce4;
--text-secondary: #a8b0b8;
--accent: #8098b8;
--accent-light: #a0b4d0;
--border: #2a3038;
--editor-bg: #1a1e24;
--editor-text: #d8dce4;
}
[data-theme='ricepaper'] {
--bg-primary: #f4efe4;
--bg-secondary: #faf6ee;
--bg-tertiary: #efe8d8;
--bg-hover: #e8e0d0;
--bg-card: #fcf6ec;
--text-primary: #3a3028;
--text-secondary: #6a5a48;
--text-muted: #988878;
--accent: #8b4513;
--accent-light: #a86030;
--border: #d8d0c0;
--editor-bg: #fdf9f2;
--editor-text: #3a3028;
}
[data-theme='mist'] {
--bg-primary: #eff0f2;
--bg-secondary: #f5f6f8;
--bg-tertiary: #eaeaee;
--bg-hover: #e2e3e8;
--bg-card: #f8f8fa;
--text-primary: #2c3038;
--text-secondary: #585c68;
--accent: #6878a0;
--border: #d8dae0;
--editor-bg: #f8f9fc;
--editor-text: #2c3038;
}
[data-theme='teagarden'] {
--bg-primary: #eef0e4;
--bg-secondary: #f4f6ec;
--bg-tertiary: #e6e8d8;
--bg-hover: #dee2d0;
--bg-card: #f2f4e8;
--text-primary: #2d3a28;
--text-secondary: #4a5840;
--accent: #688848;
--border: #d0d8c0;
--editor-bg: #f6faf0;
--editor-text: #2d3a28;
}
[data-theme='twilight'] {
--bg-primary: #1f1a16;
--bg-secondary: #241e1a;
--bg-tertiary: #2a2420;
--bg-hover: #342e28;
--bg-card: #26201c;
--text-primary: #e8dcc8;
--text-secondary: #b8a890;
--accent: #c88850;
--accent-light: #d8a870;
--border: #383028;
--editor-bg: #1f1a16;
--editor-text: #e8dcc8;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="./../shared/electron-api.d.ts" />
+15
View File
@@ -0,0 +1,15 @@
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'
}
+61
View File
@@ -0,0 +1,61 @@
import type {
ActionId,
BookMeta,
BookOpenResult,
Chapter,
CreateBookParams,
GlobalSettings,
IpcResult,
UpdateChapterParams,
Volume
} from './types'
export interface ElectronAPI {
window: {
minimize: () => Promise<void>
maximize: () => Promise<void>
close: () => Promise<void>
}
settings: {
get: () => Promise<IpcResult<GlobalSettings>>
update: (partial: Partial<GlobalSettings>) => Promise<IpcResult<GlobalSettings>>
}
book: {
list: () => Promise<IpcResult<BookMeta[]>>
create: (params: CreateBookParams) => Promise<IpcResult<BookMeta>>
delete: (bookId: string) => Promise<IpcResult<void>>
open: (bookId: string) => Promise<IpcResult<BookOpenResult>>
updateMeta: (
bookId: string,
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
) => Promise<IpcResult<BookMeta>>
}
volume: {
create: (bookId: string, name: string) => Promise<IpcResult<Volume>>
update: (
bookId: string,
volumeId: string,
patch: { name?: string; description?: string }
) => Promise<IpcResult<Volume>>
delete: (bookId: string, volumeId: string) => Promise<IpcResult<void>>
}
chapter: {
create: (bookId: string, volumeId: string, title: string) => Promise<IpcResult<Chapter>>
get: (bookId: string, chapterId: string) => Promise<IpcResult<Chapter>>
update: (params: UpdateChapterParams) => Promise<IpcResult<Chapter>>
delete: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
}
shortcut: {
getAll: () => Promise<IpcResult<Record<ActionId, string>>>
register: (action: ActionId, accelerator: string) => Promise<IpcResult<void>>
}
onShortcutTriggered: (callback: (action: ActionId) => void) => () => void
}
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
export {}
+22
View File
@@ -0,0 +1,22 @@
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',
BOOK_UPDATE_META: 'book:updateMeta',
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',
WINDOW_MINIMIZE: 'window:minimize',
WINDOW_MAXIMIZE: 'window:maximize',
WINDOW_CLOSE: 'window:close'
} as const
+102
View File
@@ -0,0 +1,102 @@
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[]
}
export interface UpdateChapterParams {
bookId: string
chapterId: string
title?: string
content?: string
status?: ChapterStatus
cursorOffset?: number
}