From fe127ec3ed4839d5078a4f5a244149c821e6e892 Mon Sep 17 00:00:00 2001 From: kun1h Date: Wed, 8 Jul 2026 15:22:58 +0800 Subject: [PATCH] feat: ship v1.2.0 Wave 1 foundation with book settings, chapter management, and focus mode Deliver schema v9, book/chapter IPC extensions, BookSettingsTab, chapter meta/tags, AI session menu, focus mode and TTS, template context enrich, and Wave 1 E2E coverage. Co-authored-by: Cursor --- README.md | 10 +- .../plans/2026-07-08-bilin-w1-foundation.md | 94 ++++---- e2e/book-settings.spec.ts | 43 ++++ e2e/chapter-management.spec.ts | 55 +++++ e2e/chapter-template.spec.ts | 34 ++- e2e/focus-tts.spec.ts | 42 ++++ e2e/helpers.ts | 16 ++ package.json | 2 +- public/locales/en/translation.json | 46 +++- public/locales/zh-CN/translation.json | 46 +++- src/main/db/migrate.ts | 11 +- src/main/db/repositories/chapter-tag.repo.ts | 44 ++++ src/main/db/repositories/chapter.repo.ts | 26 ++- src/main/db/schema-v9.sql | 7 + src/main/ipc/handlers/ai.handler.ts | 45 +++- src/main/ipc/handlers/book-prefs.handler.ts | 21 ++ src/main/ipc/handlers/book.handler.ts | 37 +-- src/main/ipc/handlers/chapter.handler.ts | 49 +++- src/main/ipc/register.ts | 2 + src/main/services/book-registry.ts | 9 +- src/main/services/cockpit.service.ts | 4 +- src/preload/index.ts | 33 ++- src/renderer/App.tsx | 35 ++- src/renderer/components/ai/AiPanel.tsx | 31 ++- src/renderer/components/ai/AiSessionMenu.tsx | 113 +++++++++ .../components/book/BookSettingsTab.tsx | 183 +++++++++++++++ .../components/chapter/ChapterMetaPanel.tsx | 169 ++++++++++++++ .../chapter/TemplateQuickCreateModal.tsx | 38 ++- .../components/editor/TipTapEditor.tsx | 11 +- .../components/focus/FocusModeOverlay.tsx | 31 +++ src/renderer/components/home/HomePage.tsx | 8 +- .../components/layout/EditorLayout.tsx | 120 +++++++++- src/renderer/components/layout/RightPanel.tsx | 3 +- src/renderer/components/layout/TopBar.tsx | 40 +++- .../components/settings/SettingsPage.tsx | 2 +- .../components/volume/VolumeContextMenu.tsx | 82 +++++++ .../components/wordfreq/WordFreqPanel.tsx | 5 +- src/renderer/lib/editor-commands.ts | 11 + src/renderer/lib/tts-controller.ts | 16 ++ src/renderer/stores/useAiStore.ts | 10 +- src/renderer/stores/useAppStore.ts | 8 + src/renderer/stores/useBookPrefsStore.ts | 28 +++ src/renderer/styles/layout.css | 218 ++++++++++++++++++ src/shared/chapter-template.ts | 35 +++ src/shared/electron-api.d.ts | 22 +- src/shared/ipc-channels.ts | 9 +- src/shared/types.ts | 26 +++ tests/main/chapter-tag.repo.test.ts | 35 +++ tests/main/chapter-template-context.test.ts | 92 ++++++++ tests/main/migrate-v2.test.ts | 4 +- tests/main/migrate-v3.test.ts | 4 +- tests/main/migrate-v4.test.ts | 4 +- tests/main/migrate-v5.test.ts | 4 +- tests/main/migrate-v6.test.ts | 2 +- tests/main/migrate-v7.test.ts | 2 +- tests/main/migrate-v8.test.ts | 2 +- tests/main/migrate-v9.test.ts | 23 ++ 57 files changed, 1963 insertions(+), 139 deletions(-) create mode 100644 e2e/book-settings.spec.ts create mode 100644 e2e/chapter-management.spec.ts create mode 100644 e2e/focus-tts.spec.ts create mode 100644 src/main/db/repositories/chapter-tag.repo.ts create mode 100644 src/main/db/schema-v9.sql create mode 100644 src/main/ipc/handlers/book-prefs.handler.ts create mode 100644 src/renderer/components/ai/AiSessionMenu.tsx create mode 100644 src/renderer/components/book/BookSettingsTab.tsx create mode 100644 src/renderer/components/chapter/ChapterMetaPanel.tsx create mode 100644 src/renderer/components/focus/FocusModeOverlay.tsx create mode 100644 src/renderer/components/volume/VolumeContextMenu.tsx create mode 100644 src/renderer/lib/tts-controller.ts create mode 100644 src/renderer/stores/useBookPrefsStore.ts create mode 100644 tests/main/chapter-tag.repo.test.ts create mode 100644 tests/main/chapter-template-context.test.ts create mode 100644 tests/main/migrate-v9.test.ts diff --git a/README.md b/README.md index 5f712c1..2f75e00 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # 笔临 (Bilin) -长篇创作智能协作平台 — Electron 桌面客户端 v1.1.0(作家日更工具包) +长篇创作智能协作平台 — Electron 桌面客户端 v1.2.0(基础治理) + +## 功能概览(v1.2.0) + +- 书籍设置:书名/分类/目标字数/简介/封面、驾驶舱偏好、删书 +- 分卷右键管理、章节双击改名/删除、章节元数据与标签 +- 模板变量自动填充(含大纲项与角色列表) +- AI 会话重命名/归档/导出/删除 +- 专注模式快捷键、TTS 朗读、顶栏日更进度条 ## 功能概览(v1.1.0) diff --git a/docs/superpowers/plans/2026-07-08-bilin-w1-foundation.md b/docs/superpowers/plans/2026-07-08-bilin-w1-foundation.md index ac4f02b..6e4315d 100644 --- a/docs/superpowers/plans/2026-07-08-bilin-w1-foundation.md +++ b/docs/superpowers/plans/2026-07-08-bilin-w1-foundation.md @@ -62,7 +62,7 @@ - Modify: `src/shared/types.ts` - Modify: `src/shared/ipc-channels.ts` -- [ ] **Step 1: schema-v9.sql** +- [x] **Step 1: schema-v9.sql** ```sql CREATE TABLE IF NOT EXISTS chapter_tags ( @@ -74,7 +74,7 @@ CREATE TABLE IF NOT EXISTS chapter_tags ( ); ``` -- [ ] **Step 2: migrate.ts** — `CURRENT_VERSION = 9`,新增 block: +- [x] **Step 2: migrate.ts** — `CURRENT_VERSION = 9`,新增 block: ```typescript if (current < 9) { @@ -85,7 +85,7 @@ if (current < 9) { } ``` -- [ ] **Step 3: types.ts** — 扩展: +- [x] **Step 3: types.ts** — 扩展: ```typescript export interface BookMeta { @@ -113,7 +113,7 @@ export interface ChapterTag { // Chapter 已有 summary?, storyTime? — 确保 chapter.update IPC 接受它们 ``` -- [ ] **Step 4: ipc-channels.ts** +- [x] **Step 4: ipc-channels.ts** ```typescript CHAPTER_TAG_LIST: 'chapter:tagList', @@ -124,7 +124,7 @@ BOOK_PREFS_SET: 'book:prefsSet', AI_SESSION_EXPORT: 'ai:sessionExport', ``` -- [ ] **Step 5: 运行** `npm run build` — 应通过 +- [x] **Step 5: 运行** `npm run build` — 应通过 --- @@ -136,7 +136,7 @@ AI_SESSION_EXPORT: 'ai:sessionExport', - Modify: `src/main/services/book-registry.ts` - Modify: `src/main/services/cockpit.service.ts` -- [ ] **Step 1: ChapterTagRepository** +- [x] **Step 1: ChapterTagRepository** ```typescript export class ChapterTagRepository { @@ -148,7 +148,7 @@ export class ChapterTagRepository { } ``` -- [ ] **Step 2: chapter.repo update** — `update()` 与 `setPublishStatus()` 支持 `summary`, `storyTime`, `wordCountThreshold`, `publishedAt`: +- [x] **Step 2: chapter.repo update** — `update()` 与 `setPublishStatus()` 支持 `summary`, `storyTime`, `wordCountThreshold`, `publishedAt`: ```typescript setPublishStatus(id: string, publishStatus: PublishStatus): Chapter { @@ -160,9 +160,9 @@ setPublishStatus(id: string, publishStatus: PublishStatus): Chapter { } ``` -- [ ] **Step 3: book-registry.updateMeta** — 签名改为 `Partial>` +- [x] **Step 3: book-registry.updateMeta** — 签名改为 `Partial>` -- [ ] **Step 4: cockpit.service** +- [x] **Step 4: cockpit.service** ```typescript shouldShowOnOpen(): boolean { @@ -172,7 +172,7 @@ shouldShowOnOpen(): boolean { } ``` -- [ ] **Step 5: UT** `tests/main/chapter-tag.repo.test.ts` — 创建章+标签+列表 +- [x] **Step 5: UT** `tests/main/chapter-tag.repo.test.ts` — 创建章+标签+列表 Run: `npm run test -- --run tests/main/chapter-tag.repo.test.ts` @@ -189,9 +189,9 @@ Run: `npm run test -- --run tests/main/chapter-tag.repo.test.ts` - Modify: `src/preload/index.ts` - Modify: `src/shared/electron-api.d.ts` -- [ ] **Step 1: book.handler** — `BOOK_UPDATE_META` 接受 `UpdateBookMetaParams` +- [x] **Step 1: book.handler** — `BOOK_UPDATE_META` 接受 `UpdateBookMetaParams` -- [ ] **Step 2: chapter.handler** — 扩展 `CHAPTER_UPDATE` 接受 `summary`, `storyTime`, `wordCountThreshold`;新增: +- [x] **Step 2: chapter.handler** — 扩展 `CHAPTER_UPDATE` 接受 `summary`, `storyTime`, `wordCountThreshold`;新增: ```typescript ipcMain.handle(IPC.CHAPTER_TAG_LIST, (_e, { bookId, chapterId }) => @@ -199,7 +199,7 @@ ipcMain.handle(IPC.CHAPTER_TAG_LIST, (_e, { bookId, chapterId }) => // TAG_SET / TAG_REMOVE 同理 ``` -- [ ] **Step 3: book-prefs.handler** +- [x] **Step 3: book-prefs.handler** ```typescript ipcMain.handle(IPC.BOOK_PREFS_GET, (_e, { bookId, key }) => @@ -208,11 +208,11 @@ ipcMain.handle(IPC.BOOK_PREFS_SET, (_e, { bookId, key, value }) => wrap(() => { new BookPrefsRepository(registry.getDb(bookId)).set(key, value) })) ``` -- [ ] **Step 4: ai.handler** — `AI_SESSION_EXPORT`:读 messages,格式化 markdown,`dialog.showSaveDialog`,写文件 +- [x] **Step 4: ai.handler** — `AI_SESSION_EXPORT`:读 messages,格式化 markdown,`dialog.showSaveDialog`,写文件 -- [ ] **Step 5: preload** — `book.updateMeta` 扩展;`bookPrefs.get/set`;`chapterTag.list/set/remove`;`ai.session.export(bookId, sessionId, format)` +- [x] **Step 5: preload** — `book.updateMeta` 扩展;`bookPrefs.get/set`;`chapterTag.list/set/remove`;`ai.session.export(bookId, sessionId, format)` -- [ ] **Step 6: build** `npm run build` +- [x] **Step 6: build** `npm run build` --- @@ -223,7 +223,7 @@ ipcMain.handle(IPC.BOOK_PREFS_SET, (_e, { bookId, key, value }) => - Modify: `src/renderer/components/chapter/TemplateQuickCreateModal.tsx` - Create: `tests/main/chapter-template-context.test.ts` -- [ ] **Step 1: 新增 `buildTemplateContext()`** +- [x] **Step 1: 新增 `buildTemplateContext()`** ```typescript export function buildTemplateContext(input: { @@ -260,9 +260,9 @@ export function buildTemplateContext(input: { } ``` -- [ ] **Step 2: TemplateQuickCreateModal** — 用 `buildTemplateContext` 替换内联 `buildContext` +- [x] **Step 2: TemplateQuickCreateModal** — 用 `buildTemplateContext` 替换内联 `buildContext` -- [ ] **Step 3: UT** +- [x] **Step 3: UT** ```typescript it('UT-TEMPL-05: characterList from settings', () => { @@ -282,15 +282,15 @@ Run: `npm run test -- --run tests/main/chapter-template-context.test.ts` - Create: `src/renderer/stores/useBookPrefsStore.ts` - Modify: `src/renderer/components/layout/RightPanel.tsx` -- [ ] **Step 1: BookSettingsTab** — 表单字段: +- [x] **Step 1: BookSettingsTab** — 表单字段: - 书名、分类、目标字数、简介(textarea) - 封面:按钮选文件 → 存 `userData/covers/{bookId}.jpg` 路径到 meta.coverPath - 「每次打开显示驾驶舱」checkbox → `bookPrefs.set('alwaysShowCockpit', 'true'|'false')` - 危险区:删书 → confirm → `book.delete` → `loadBooks` → `setView('home')` -- [ ] **Step 2: RightPanel** — `book-settings` panel 渲染 `` +- [x] **Step 2: RightPanel** — `book-settings` panel 渲染 `` -- [ ] **Step 3: i18n** — `bookSettings.*` 键(中英) +- [x] **Step 3: i18n** — `bookSettings.*` 键(中英) --- @@ -301,23 +301,23 @@ Run: `npm run test -- --run tests/main/chapter-template-context.test.ts` - Create: `src/renderer/components/chapter/ChapterMetaPanel.tsx` - Modify: `src/renderer/components/layout/EditorLayout.tsx` -- [ ] **Step 1: VolumeContextMenu** — 绑定分卷标题 `onContextMenu`: +- [x] **Step 1: VolumeContextMenu** — 绑定分卷标题 `onContextMenu`: - 重命名:prompt → `volume.update` - 编辑简述:prompt → `volume.update({ description })` - 删除:若卷内无章 → `volume.delete`;有章则 toast 错误 -- [ ] **Step 2: 章节双击改名** — `chapter-item` `onDoubleClick` → input inline → `chapter.update({ title })` +- [x] **Step 2: 章节双击改名** — `chapter-item` `onDoubleClick` → input inline → `chapter.update({ title })` -- [ ] **Step 3: 章节删除** — 右键或 hover 图标 → confirm → `chapter.delete` → `refreshChapters` +- [x] **Step 3: 章节删除** — 右键或 hover 图标 → confirm → `chapter.delete` → `refreshChapters` -- [ ] **Step 4: ChapterMetaPanel** — 侧栏或状态栏按钮打开: +- [x] **Step 4: ChapterMetaPanel** — 侧栏或状态栏按钮打开: - summary、story_time 输入 - 标签 chips + 添加输入 - word_count_threshold 数字 -- [ ] **Step 5: 发布日期** — `setPublishStatus` 后本地 chapter 显示 `published_at`(只读) +- [x] **Step 5: 发布日期** — `setPublishStatus` 后本地 chapter 显示 `published_at`(只读) -- [ ] **Step 6: 字数阈值变色** — 状态栏 `wordCount.chapter` 与 `currentChapter.wordCountThreshold` 比较,超则橙色 class +- [x] **Step 6: 字数阈值变色** — 状态栏 `wordCount.chapter` 与 `currentChapter.wordCountThreshold` 比较,超则橙色 class --- @@ -328,15 +328,15 @@ Run: `npm run test -- --run tests/main/chapter-template-context.test.ts` - Modify: `src/renderer/components/ai/AiPanel.tsx` - Modify: `src/renderer/stores/useAiStore.ts` -- [ ] **Step 1: AiSessionMenu** — 会话选择器旁「⋯」按钮: +- [x] **Step 1: AiSessionMenu** — 会话选择器旁「⋯」按钮: - 重命名 → `ai.session.update({ title })` - 归档/取消归档 → `{ archived: true/false }` - 导出 → 子菜单 txt/md → `ai.session.export` - 删除 → confirm → `ai.session.delete` -- [ ] **Step 2: useAiStore** — `loadSessions(bookId, true)` 加载含归档;归档会话 optgroup 或折叠区 +- [x] **Step 2: useAiStore** — `loadSessions(bookId, true)` 加载含归档;归档会话 optgroup 或折叠区 -- [ ] **Step 3: i18n** — `ai.session.*` +- [x] **Step 3: i18n** — `ai.session.*` --- @@ -348,13 +348,13 @@ Run: `npm run test -- --run tests/main/chapter-template-context.test.ts` - Modify: `src/renderer/App.tsx` - Modify: `src/renderer/styles/layout.css` -- [ ] **Step 1: FocusModeOverlay** — `useAppStore.focusMode` boolean: +- [x] **Step 1: FocusModeOverlay** — `useAppStore.focusMode` boolean: - true 时 `#app` 加 `focus-mode` class(CSS 隐藏 sidebars/right-panel/topbar 非必要按钮) - Esc 或按钮退出 -- [ ] **Step 2: App.tsx** — `focusMode` 动作调用 `setFocusMode(true)`;再次按退出 +- [x] **Step 2: App.tsx** — `focusMode` 动作调用 `setFocusMode(true)`;再次按退出 -- [ ] **Step 3: tts-controller.ts** +- [x] **Step 3: tts-controller.ts** ```typescript export function speakText(text: string, lang = 'zh-CN'): void { @@ -368,9 +368,9 @@ export function stopSpeaking(): void { } ``` -- [ ] **Step 4: App.tsx** — `toggleTTS`:若正在朗读则 stop;否则读当前章 `editor.getText()` 或选区 +- [x] **Step 4: App.tsx** — `toggleTTS`:若正在朗读则 stop;否则读当前章 `editor.getText()` 或选区 -- [ ] **Step 5: i18n** — `focus.*` / `tts.*` +- [x] **Step 5: i18n** — `focus.*` / `tts.*` --- @@ -381,13 +381,13 @@ export function stopSpeaking(): void { - Modify: `src/renderer/components/layout/TopBar.tsx` - Modify: `src/renderer/stores/useAppStore.ts` -- [ ] **Step 1: WordFreqPanel** — 底部按钮 `onClick` 改为 `setNamingOpen(true)` 或触发 KnowledgePanel 命名检查(与 `NamingCheckModal` 联动) +- [x] **Step 1: WordFreqPanel** — 底部按钮 `onClick` 改为 `setNamingOpen(true)` 或触发 KnowledgePanel 命名检查(与 `NamingCheckModal` 联动) -- [ ] **Step 2: TopBar** — 在 `top-spacer` 前加细进度条: +- [x] **Step 2: TopBar** — 在 `top-spacer` 前加细进度条: - 读 `useWritingStatsStore` + `dailyWordGoal` - `goalProgress` 映射 width%;≥100% 绿色,≥80% 橙色 -- [ ] **Step 3: 全局 grep** `feature.comingSoon` — Wave 1 范围内应无功能入口残留 +- [x] **Step 3: 全局 grep** `feature.comingSoon` — Wave 1 范围内应无功能入口残留 --- @@ -401,17 +401,17 @@ export function stopSpeaking(): void { - Modify: `e2e/helpers.ts` - Modify: `package.json`, `README.md`, `SettingsPage.tsx` -- [ ] **Step 1: e2e/helpers.ts** — `openBookSettings(page)` helper +- [x] **Step 1: e2e/helpers.ts** — `openBookSettings(page)` helper -- [ ] **Step 2: E2E-BOOK-01** — 改书名、勾选 always cockpit、删书 +- [x] **Step 2: E2E-BOOK-01** — 改书名、勾选 always cockpit、删书 -- [ ] **Step 3: E2E-CHAP-01** — 双击改标题、删除章 +- [x] **Step 3: E2E-CHAP-01** — 双击改标题、删除章 -- [ ] **Step 4: E2E-FOCUS-01** — Ctrl+Alt+F 进入专注,Esc 退出 +- [x] **Step 4: E2E-FOCUS-01** — Ctrl+Alt+F 进入专注,Esc 退出 -- [ ] **Step 5: E2E-TEMPL-05** — 模板含 `{characterList}` 的自定义模板(设置页创建)→ 快速新建 → 编辑器含角色名 +- [x] **Step 5: E2E-TEMPL-05** — 模板含 `{characterList}` 的自定义模板(设置页创建)→ 快速新建 → 编辑器含角色名 -- [ ] **Step 6: 全量验证** +- [x] **Step 6: 全量验证** ```bash npm run build @@ -419,9 +419,9 @@ npm run test npm run test:e2e -- e2e/book-settings.spec.ts e2e/chapter-management.spec.ts e2e/focus-tts.spec.ts e2e/chapter-template.spec.ts ``` -- [ ] **Step 7: 版本** — `package.json` / README / Settings 关于页 → **v1.2.0** +- [x] **Step 7: 版本** — `package.json` / README / Settings 关于页 → **v1.2.0** -- [ ] **Step 8: 勾选本 plan 全部 checkbox** +- [x] **Step 8: 勾选本 plan 全部 checkbox** --- diff --git a/e2e/book-settings.spec.ts b/e2e/book-settings.spec.ts new file mode 100644 index 0000000..618de09 --- /dev/null +++ b/e2e/book-settings.spec.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect } from '@playwright/test' +import { launchApp, skipOnboarding, createBookAndOpenEditor, openBookSettings } from './helpers' + +test.describe('Book settings', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-book-settings-')) + }) + + test.afterEach(() => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-BOOK-01: rename book, always cockpit, delete book', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createBookAndOpenEditor(page, '原始书名') + + await openBookSettings(page) + await page.getByTestId('book-settings-name').fill('改后书名') + await page.getByTestId('book-settings-always-cockpit').check() + await page.getByTestId('book-settings-save').click() + await expect(page.locator('.sidebar-header')).toContainText('改后书名', { timeout: 10_000 }) + + page.once('dialog', (dialog) => dialog.accept()) + await page.getByTestId('book-settings-delete').click() + await expect(page.locator('#home-page')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByText('改后书名')).toHaveCount(0) + + await app.close() + }) +}) diff --git a/e2e/chapter-management.spec.ts b/e2e/chapter-management.spec.ts new file mode 100644 index 0000000..9f6bf6f --- /dev/null +++ b/e2e/chapter-management.spec.ts @@ -0,0 +1,55 @@ +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect } from '@playwright/test' +import { launchApp, skipOnboarding, createBookAndOpenEditor, ensureChapterEditor } from './helpers' + +test.describe('Chapter management', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-chapter-mgmt-')) + }) + + test.afterEach(() => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-CHAP-01: double-click rename and delete chapter', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createBookAndOpenEditor(page, '章节管理书') + await ensureChapterEditor(page) + await page.getByTestId('sidebar-tab-chapters').click() + + const firstChapter = page.locator('.chapter-item').first() + await expect(firstChapter).toBeVisible({ timeout: 10_000 }) + const chapterTestId = await firstChapter.getAttribute('data-testid') + const chapterId = chapterTestId?.replace('chapter-item-', '') ?? '' + expect(chapterId).toBeTruthy() + + await firstChapter.dblclick() + const renameInput = page.getByTestId(`chapter-rename-${chapterId}`) + await expect(renameInput).toBeVisible({ timeout: 5000 }) + await renameInput.fill('重命名章节') + await renameInput.blur() + await expect(firstChapter).toContainText('重命名章节', { timeout: 10_000 }) + + await page.getByRole('button', { name: '+ 新章' }).click() + await expect(page.locator('.chapter-item')).toHaveCount(2, { timeout: 10_000 }) + + page.once('dialog', (dialog) => dialog.accept()) + await firstChapter.hover() + await page.getByTestId(`chapter-delete-${chapterId}`).click() + await expect(page.locator('.chapter-item')).toHaveCount(1, { timeout: 10_000 }) + + await app.close() + }) +}) diff --git a/e2e/chapter-template.spec.ts b/e2e/chapter-template.spec.ts index 66ead59..c3a9a5a 100644 --- a/e2e/chapter-template.spec.ts +++ b/e2e/chapter-template.spec.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, existsSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' import { test, expect } from '@playwright/test' -import { launchApp, skipOnboarding, createBookAndOpenEditor, ensureChapterEditor } from './helpers' +import { launchApp, skipOnboarding, createBookAndOpenEditor, ensureChapterEditor, addCharacterSetting, dismissCockpitIfOpen } from './helpers' test.describe('Chapter template', () => { let userDataDir: string @@ -40,4 +40,36 @@ test.describe('Chapter template', () => { await app.close() }) + + test('E2E-TEMPL-05: characterList from settings in quick create', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createBookAndOpenEditor(page, '角色模板书') + await addCharacterSetting(page, '林远') + await addCharacterSetting(page, '苏晴') + + await page.locator('#topbar .top-btn').filter({ hasText: '⚙' }).click() + await page.getByTestId('settings-nav-templates').click() + await page.getByTestId('setting-template-add').click() + const customCard = page.locator('.template-settings-item').filter({ hasText: '新模板' }).last() + await customCard.getByRole('button', { name: '编辑' }).click() + await page.locator('.template-edit-panel textarea').fill('出场角色:{characterList}\n\n正文') + await page.locator('#topbar .tab').filter({ hasText: '角色模板书' }).click() + await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 10_000 }) + await dismissCockpitIfOpen(page) + await page.getByTestId('sidebar-tab-chapters').click() + + await page.getByTestId('chapter-quick-new').click() + await expect(page.getByTestId('template-quick-create-modal')).toBeVisible() + await page.locator('.template-card').filter({ hasText: '新模板' }).click() + await page.getByTestId('template-chapter-title').fill('角色章') + await page.getByTestId('template-create-btn').click() + + const editor = page.locator('.ProseMirror') + await expect(editor).toContainText('林远', { timeout: 10_000 }) + await expect(editor).toContainText('苏晴') + + await app.close() + }) }) diff --git a/e2e/focus-tts.spec.ts b/e2e/focus-tts.spec.ts new file mode 100644 index 0000000..18c483c --- /dev/null +++ b/e2e/focus-tts.spec.ts @@ -0,0 +1,42 @@ +import { mkdtempSync, rmSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { test, expect } from '@playwright/test' +import { launchApp, skipOnboarding, createBookAndOpenEditor, ensureChapterEditor } from './helpers' + +test.describe('Focus mode', () => { + let userDataDir: string + + test.beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-focus-')) + }) + + test.afterEach(() => { + if (userDataDir && existsSync(userDataDir)) { + try { + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + } catch { + /* ignore */ + } + } + }) + + test('E2E-FOCUS-01: Ctrl+Alt+F enters focus mode, Esc exits', async () => { + const app = await launchApp(userDataDir) + const page = await app.firstWindow() + await skipOnboarding(page) + await createBookAndOpenEditor(page, '专注模式书') + await ensureChapterEditor(page) + + await page.getByTestId('topbar-focus-mode').click() + await expect(page.getByTestId('app-root')).toHaveClass(/focus-mode/, { timeout: 5000 }) + await expect(page.locator('#left-sidebar')).toBeHidden() + await expect(page.getByTestId('focus-mode-exit')).toBeVisible() + + await page.keyboard.press('Escape') + await expect(page.getByTestId('app-root')).not.toHaveClass(/focus-mode/) + await expect(page.locator('#left-sidebar')).toBeVisible() + + await app.close() + }) +}) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index ca33788..ab3632f 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -61,3 +61,19 @@ export async function openExportModal(page: Page): Promise { } await expect(modal).toBeVisible({ timeout: 10_000 }) } + +export async function openBookSettings(page: Page): Promise { + await page.getByTestId('panel-tab-book-settings').click() + await expect(page.getByTestId('book-settings-tab')).toBeVisible({ timeout: 10_000 }) +} + +export async function addCharacterSetting(page: Page, name: string): Promise { + await page.getByTestId('sidebar-tab-setting').click() + await page.getByTestId('setting-new').click() + await page.getByTestId('setting-name-input').fill(name) + await page.locator('.dialog-content select').selectOption('character') + await page.getByRole('button', { name: '创建' }).click() + await expect(page.getByTestId(/setting-item-/).filter({ hasText: name })).toBeVisible({ + timeout: 10_000 + }) +} diff --git a/package.json b/package.json index 91bd1d6..efe5257 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bilin", - "version": "1.1.0", + "version": "1.2.0", "description": "笔临 - 长篇创作智能协作平台", "main": "./out/main/index.js", "type": "module", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 0d1e9ec..cec8f59 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -457,5 +457,49 @@ "submission.setDefault": "Set as default", "inspiration.selectBook": "Link to book", "inspiration.saved": "Idea saved", - "inspiration.noBooks": "Create a book first to save ideas" + "inspiration.noBooks": "Create a book first to save ideas", + "bookSettings.title": "Book settings", + "bookSettings.synopsis": "Synopsis", + "bookSettings.cover": "Cover", + "bookSettings.pickCover": "Choose cover image", + "bookSettings.noCover": "Not set", + "bookSettings.alwaysShowCockpit": "Show cockpit on every open", + "bookSettings.dangerZone": "Danger zone", + "bookSettings.deleteBook": "Delete this book", + "bookSettings.deleteConfirm": "Delete \"{{name}}\"? This cannot be undone.", + "volume.rename": "Rename volume", + "volume.renamePrompt": "Volume name", + "volume.editDescription": "Edit description", + "volume.descriptionPrompt": "Volume description", + "volume.delete": "Delete volume", + "volume.deleteConfirm": "Delete volume \"{{name}}\"?", + "volume.deleteHasChapters": "Cannot delete a volume that still has chapters", + "chapter.delete": "Delete chapter", + "chapter.deleteConfirm": "Delete chapter \"{{title}}\"?", + "chapterMeta.title": "Chapter metadata", + "chapterMeta.open": "Meta", + "chapterMeta.summary": "Summary", + "chapterMeta.storyTime": "Story time", + "chapterMeta.wordThreshold": "Word count threshold", + "chapterMeta.tags": "Tags", + "chapterMeta.tagPlaceholder": "Enter tag", + "chapterMeta.addTag": "Add", + "chapterMeta.publishedAt": "Published {{date}}", + "ai.session.rename": "Rename session", + "ai.session.renamePrompt": "Session title", + "ai.session.archive": "Archive", + "ai.session.unarchive": "Unarchive", + "ai.session.export": "Export", + "ai.session.exportMd": "Markdown (.md)", + "ai.session.exportTxt": "Plain text (.txt)", + "ai.session.delete": "Delete session", + "ai.session.deleteConfirm": "Delete session \"{{title}}\"?", + "ai.session.active": "Active", + "ai.session.archived": "Archived", + "focus.toggle": "Focus mode", + "focus.exit": "Exit focus", + "tts.started": "Reading aloud", + "tts.stopped": "Stopped reading", + "tts.noText": "Nothing to read", + "home.exportAllWave2": "Bulk export coming in a later release" } diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index 39849b8..ad57341 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -457,5 +457,49 @@ "submission.setDefault": "设为默认预设", "inspiration.selectBook": "关联书籍", "inspiration.saved": "灵感已保存", - "inspiration.noBooks": "请先创建一本书再保存灵感" + "inspiration.noBooks": "请先创建一本书再保存灵感", + "bookSettings.title": "书籍设置", + "bookSettings.synopsis": "简介", + "bookSettings.cover": "封面", + "bookSettings.pickCover": "选择封面图片", + "bookSettings.noCover": "未设置", + "bookSettings.alwaysShowCockpit": "每次打开显示驾驶舱", + "bookSettings.dangerZone": "危险操作", + "bookSettings.deleteBook": "删除此书", + "bookSettings.deleteConfirm": "确定删除「{{name}}」?此操作不可恢复。", + "volume.rename": "重命名分卷", + "volume.renamePrompt": "分卷名称", + "volume.editDescription": "编辑简述", + "volume.descriptionPrompt": "分卷简述", + "volume.delete": "删除分卷", + "volume.deleteConfirm": "确定删除分卷「{{name}}」?", + "volume.deleteHasChapters": "分卷内仍有章节,无法删除", + "chapter.delete": "删除章节", + "chapter.deleteConfirm": "确定删除章节「{{title}}」?", + "chapterMeta.title": "章节元数据", + "chapterMeta.open": "元数据", + "chapterMeta.summary": "章节摘要", + "chapterMeta.storyTime": "故事时间", + "chapterMeta.wordThreshold": "字数阈值", + "chapterMeta.tags": "标签", + "chapterMeta.tagPlaceholder": "输入标签", + "chapterMeta.addTag": "添加", + "chapterMeta.publishedAt": "发布于 {{date}}", + "ai.session.rename": "重命名会话", + "ai.session.renamePrompt": "会话标题", + "ai.session.archive": "归档", + "ai.session.unarchive": "取消归档", + "ai.session.export": "导出", + "ai.session.exportMd": "Markdown (.md)", + "ai.session.exportTxt": "纯文本 (.txt)", + "ai.session.delete": "删除会话", + "ai.session.deleteConfirm": "确定删除会话「{{title}}」?", + "ai.session.active": "活跃", + "ai.session.archived": "已归档", + "focus.toggle": "专注模式", + "focus.exit": "退出专注", + "tts.started": "开始朗读", + "tts.stopped": "已停止朗读", + "tts.noText": "没有可朗读的内容", + "home.exportAllWave2": "批量导出将在后续版本提供" } diff --git a/src/main/db/migrate.ts b/src/main/db/migrate.ts index 9858642..a5c26c1 100644 --- a/src/main/db/migrate.ts +++ b/src/main/db/migrate.ts @@ -7,8 +7,9 @@ import schemaV5 from './schema-v5.sql?raw' import schemaV6 from './schema-v6.sql?raw' import schemaV7 from './schema-v7.sql?raw' import schemaV8 from './schema-v8.sql?raw' +import schemaV9 from './schema-v9.sql?raw' -const CURRENT_VERSION = 8 +const CURRENT_VERSION = 9 function tryAlter(db: SqliteDb, sql: string): void { try { @@ -88,5 +89,13 @@ export function migrate(db: SqliteDb): void { if (current < 8) { db.exec(schemaV8) db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection') + current = 8 + } + + if (current < 9) { + tryAlter(db, 'ALTER TABLE chapters ADD COLUMN published_at TEXT DEFAULT NULL') + tryAlter(db, 'ALTER TABLE chapters ADD COLUMN word_count_threshold INTEGER DEFAULT NULL') + db.exec(schemaV9) + db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(9, 'W1 foundation') } } diff --git a/src/main/db/repositories/chapter-tag.repo.ts b/src/main/db/repositories/chapter-tag.repo.ts new file mode 100644 index 0000000..c814e36 --- /dev/null +++ b/src/main/db/repositories/chapter-tag.repo.ts @@ -0,0 +1,44 @@ +import type { ChapterTag } from '../../../shared/types' +import type { SqliteDb } from '../types' + +function mapRow(row: Record): ChapterTag { + return { + chapterId: row.chapter_id as string, + tag: row.tag as string, + color: (row.color as string) ?? '' + } +} + +export class ChapterTagRepository { + constructor(private db: SqliteDb) {} + + list(chapterId: string): ChapterTag[] { + return ( + this.db + .prepare('SELECT * FROM chapter_tags WHERE chapter_id = ? ORDER BY tag') + .all(chapterId) as Record[] + ).map(mapRow) + } + + listAll(): ChapterTag[] { + return ( + this.db.prepare('SELECT * FROM chapter_tags ORDER BY chapter_id, tag').all() as Record< + string, + unknown + >[] + ).map(mapRow) + } + + set(chapterId: string, tag: string, color = ''): void { + this.db + .prepare( + `INSERT INTO chapter_tags (chapter_id, tag, color) VALUES (?, ?, ?) + ON CONFLICT(chapter_id, tag) DO UPDATE SET color = excluded.color` + ) + .run(chapterId, tag, color) + } + + remove(chapterId: string, tag: string): void { + this.db.prepare('DELETE FROM chapter_tags WHERE chapter_id = ? AND tag = ?').run(chapterId, tag) + } +} diff --git a/src/main/db/repositories/chapter.repo.ts b/src/main/db/repositories/chapter.repo.ts index a4e546c..061a7cf 100644 --- a/src/main/db/repositories/chapter.repo.ts +++ b/src/main/db/repositories/chapter.repo.ts @@ -14,7 +14,11 @@ function mapRow(row: Record): Chapter { cursorOffset: (row.cursor_offset as number) ?? 0, origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin, publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus, - povCharacterId: (row.pov_character_id as string) || null + povCharacterId: (row.pov_character_id as string) || null, + summary: (row.summary as string) ?? '', + storyTime: (row.story_time as string) ?? null, + publishedAt: (row.published_at as string) ?? null, + wordCountThreshold: (row.word_count_threshold as number) ?? null } } @@ -68,6 +72,10 @@ export class ChapterRepository { cursorOffset: number publishStatus: PublishStatus povCharacterId: string | null + summary: string + storyTime: string | null + publishedAt: string | null + wordCountThreshold: number | null }> ): Chapter { const existing = this.get(id) @@ -82,7 +90,9 @@ export class ChapterRepository { .prepare( `UPDATE chapters SET title = ?, content = ?, status = ?, cursor_offset = ?, - publish_status = ?, pov_character_id = ?, word_count = ?, updated_at = datetime('now') + publish_status = ?, pov_character_id = ?, word_count = ?, + summary = ?, story_time = ?, published_at = ?, word_count_threshold = ?, + updated_at = datetime('now') WHERE id = ?` ) .run( @@ -93,13 +103,23 @@ export class ChapterRepository { patch.publishStatus ?? existing.publishStatus ?? 'draft', povCharacterId, wordCount, + patch.summary ?? existing.summary ?? '', + patch.storyTime !== undefined ? patch.storyTime : (existing.storyTime ?? null), + patch.publishedAt !== undefined ? patch.publishedAt : (existing.publishedAt ?? null), + patch.wordCountThreshold !== undefined + ? patch.wordCountThreshold + : (existing.wordCountThreshold ?? null), id ) return this.get(id)! } setPublishStatus(id: string, publishStatus: PublishStatus): Chapter { - return this.update(id, { publishStatus }) + const patch: Parameters[1] = { publishStatus } + if (publishStatus === 'published') { + patch.publishedAt = new Date().toISOString() + } + return this.update(id, patch) } countByPublishStatus(status: PublishStatus): number { diff --git a/src/main/db/schema-v9.sql b/src/main/db/schema-v9.sql new file mode 100644 index 0000000..80b23c0 --- /dev/null +++ b/src/main/db/schema-v9.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS chapter_tags ( + chapter_id TEXT NOT NULL, + tag TEXT NOT NULL, + color TEXT DEFAULT '', + PRIMARY KEY (chapter_id, tag), + FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE +); diff --git a/src/main/ipc/handlers/ai.handler.ts b/src/main/ipc/handlers/ai.handler.ts index 21158c9..4b949ed 100644 --- a/src/main/ipc/handlers/ai.handler.ts +++ b/src/main/ipc/handlers/ai.handler.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto' -import { BrowserWindow, ipcMain } from 'electron' +import { writeFileSync } from 'fs' +import { BrowserWindow, dialog, ipcMain } from 'electron' import { IPC } from '../../../shared/ipc-channels' import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types' import { BookRegistryService } from '../../services/book-registry' @@ -19,6 +20,15 @@ import { wrap } from '../result' const activeChats = new Map() +function formatSessionMarkdown(title: string, messages: { role: string; content: string; createdAt: string }[]): string { + const lines = [`# ${title || 'AI 会话'}`, ''] + for (const msg of messages) { + const label = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : msg.role + lines.push(`## ${label}`, '', msg.content, '', `_${msg.createdAt}_`, '') + } + return lines.join('\n') +} + export function registerAiHandlers( registry: BookRegistryService, settings: GlobalSettingsService, @@ -67,6 +77,39 @@ export function registerAiHandlers( wrap(() => registry.getAiSessionRepo(bookId).listMessages(sessionId)) ) + ipcMain.handle( + IPC.AI_SESSION_EXPORT, + async ( + _e, + { + bookId, + sessionId, + format = 'markdown' + }: { bookId: string; sessionId: string; format?: 'markdown' | 'txt' } + ) => + wrap(async () => { + const repo = registry.getAiSessionRepo(bookId) + const session = repo.get(sessionId) + if (!session) throw new Error('AI session not found') + const messages = repo.listMessages(sessionId) + const text = + format === 'markdown' + ? formatSessionMarkdown(session.title, messages) + : messages + .map((m) => `[${m.role}] ${m.createdAt}\n${m.content}`) + .join('\n\n---\n\n') + + const ext = format === 'markdown' ? 'md' : 'txt' + const { canceled, filePath } = await dialog.showSaveDialog({ + defaultPath: `${session.title || 'ai-session'}.${ext}`, + filters: [{ name: format === 'markdown' ? 'Markdown' : 'Text', extensions: [ext] }] + }) + if (canceled || !filePath) return null + writeFileSync(filePath, text, 'utf8') + return filePath + }) + ) + ipcMain.handle( IPC.AI_BUILD_CONTEXT, (_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) => diff --git a/src/main/ipc/handlers/book-prefs.handler.ts b/src/main/ipc/handlers/book-prefs.handler.ts new file mode 100644 index 0000000..44e279a --- /dev/null +++ b/src/main/ipc/handlers/book-prefs.handler.ts @@ -0,0 +1,21 @@ +import { ipcMain } from 'electron' +import { IPC } from '../../../shared/ipc-channels' +import { BookPrefsRepository } from '../../db/repositories/book-prefs.repo' +import { BookRegistryService } from '../../services/book-registry' +import { wrap } from '../result' + +export function registerBookPrefsHandlers(registry: BookRegistryService): void { + ipcMain.handle( + IPC.BOOK_PREFS_GET, + (_event, { bookId, key }: { bookId: string; key: string }) => + wrap(() => new BookPrefsRepository(registry.getDb(bookId)).get(key)) + ) + + ipcMain.handle( + IPC.BOOK_PREFS_SET, + (_event, { bookId, key, value }: { bookId: string; key: string; value: string }) => + wrap(() => { + new BookPrefsRepository(registry.getDb(bookId)).set(key, value) + }) + ) +} diff --git a/src/main/ipc/handlers/book.handler.ts b/src/main/ipc/handlers/book.handler.ts index a03de0f..72af56d 100644 --- a/src/main/ipc/handlers/book.handler.ts +++ b/src/main/ipc/handlers/book.handler.ts @@ -1,6 +1,8 @@ -import { ipcMain } from 'electron' +import { ipcMain, dialog, app } from 'electron' +import { copyFileSync, mkdirSync } from 'fs' +import { extname, join } from 'path' import { IPC } from '../../../shared/ipc-channels' -import type { BookMeta, CreateBookParams } from '../../../shared/types' +import type { CreateBookParams, UpdateBookMetaParams } from '../../../shared/types' import { BookRegistryService } from '../../services/book-registry' import type { WritingSessionService } from '../../services/writing-session.service' import { wrap } from '../result' @@ -35,17 +37,24 @@ export function registerBookHandlers( ipcMain.handle( IPC.BOOK_UPDATE_META, - ( - _event, - { - bookId, - ...patch - }: { - bookId: string - lastChapterId?: string | null - lastOpenedAt?: string - status?: BookMeta['status'] - } - ) => wrap(() => registry.updateMeta(bookId, patch)) + (_event, { bookId, ...patch }: { bookId: string } & UpdateBookMetaParams) => + wrap(() => registry.updateMeta(bookId, patch)) + ) + + ipcMain.handle(IPC.BOOK_PICK_COVER, (_event, { bookId }: { bookId: string }) => + wrap(async () => { + const { canceled, filePaths } = await dialog.showOpenDialog({ + filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }], + properties: ['openFile'] + }) + if (canceled || !filePaths[0]) return null + const src = filePaths[0] + const ext = extname(src).toLowerCase() || '.jpg' + const coversDir = join(app.getPath('userData'), 'covers') + mkdirSync(coversDir, { recursive: true }) + const dest = join(coversDir, `${bookId}${ext}`) + copyFileSync(src, dest) + return registry.updateMeta(bookId, { coverPath: dest }) + }) ) } diff --git a/src/main/ipc/handlers/chapter.handler.ts b/src/main/ipc/handlers/chapter.handler.ts index 97503d7..76a3694 100644 --- a/src/main/ipc/handlers/chapter.handler.ts +++ b/src/main/ipc/handlers/chapter.handler.ts @@ -75,7 +75,12 @@ export function registerChapterHandlers( content, status, cursorOffset, - povCharacterId + povCharacterId, + summary, + storyTime, + wordCountThreshold, + publishStatus, + publishedAt }: { bookId: string chapterId: string @@ -84,6 +89,11 @@ export function registerChapterHandlers( status?: ChapterStatus cursorOffset?: number povCharacterId?: string | null + summary?: string + storyTime?: string | null + wordCountThreshold?: number | null + publishStatus?: PublishStatus + publishedAt?: string | null } ) => wrap(() => { @@ -92,7 +102,12 @@ export function registerChapterHandlers( content, status, cursorOffset, - povCharacterId + povCharacterId, + summary, + storyTime, + wordCountThreshold, + publishStatus, + publishedAt }) ftsSync.upsert( registry.getDb(bookId), @@ -146,4 +161,34 @@ export function registerChapterHandlers( { bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus } ) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, publishStatus)) ) + + ipcMain.handle( + IPC.CHAPTER_TAG_LIST, + (_event, { bookId, chapterId }: { bookId: string; chapterId: string }) => + wrap(() => registry.getChapterTagRepo(bookId).list(chapterId)) + ) + + ipcMain.handle( + IPC.CHAPTER_TAG_SET, + ( + _event, + { + bookId, + chapterId, + tag, + color + }: { bookId: string; chapterId: string; tag: string; color?: string } + ) => + wrap(() => { + registry.getChapterTagRepo(bookId).set(chapterId, tag, color ?? '') + }) + ) + + ipcMain.handle( + IPC.CHAPTER_TAG_REMOVE, + (_event, { bookId, chapterId, tag }: { bookId: string; chapterId: string; tag: string }) => + wrap(() => { + registry.getChapterTagRepo(bookId).remove(chapterId, tag) + }) + ) } diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 66ef419..0deef76 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -14,6 +14,7 @@ import { WritingSessionRepository } from '../db/repositories/writing-session.rep import { WritingSessionService } from '../services/writing-session.service' import { registerSettingsHandlers } from './handlers/settings.handler' import { registerBookHandlers } from './handlers/book.handler' +import { registerBookPrefsHandlers } from './handlers/book-prefs.handler' import { registerChapterHandlers } from './handlers/chapter.handler' import { registerShortcutHandlers } from './handlers/shortcut.handler' import { registerOutlineHandlers } from './handlers/outline.handler' @@ -69,6 +70,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg registerSettingsHandlers(settings) registerBookHandlers(books, writingSessions) + registerBookPrefsHandlers(books) registerChapterHandlers(books, writingLogs, writingSessions) registerShortcutHandlers(shortcutManager) registerOutlineHandlers(books) diff --git a/src/main/services/book-registry.ts b/src/main/services/book-registry.ts index 9857530..50690ec 100644 --- a/src/main/services/book-registry.ts +++ b/src/main/services/book-registry.ts @@ -11,7 +11,8 @@ import { AiSessionRepository } from '../db/repositories/ai-session.repo' import { InteractiveRepository } from '../db/repositories/interactive.repo' import { SnapshotRepository } from '../db/repositories/snapshot.repo' import { BookmarkRepository } from '../db/repositories/bookmark.repo' -import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types' +import { ChapterTagRepository } from '../db/repositories/chapter-tag.repo' +import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume, UpdateBookMetaParams } from '../../shared/types' interface RegistryFile { books: BookMeta[] @@ -90,7 +91,7 @@ export class BookRegistryService { this.writeRegistry(registry) } - updateMeta(bookId: string, patch: Partial>): BookMeta { + updateMeta(bookId: string, patch: UpdateBookMetaParams): BookMeta { const registry = this.readRegistry() const idx = registry.books.findIndex((b) => b.id === bookId) if (idx === -1) throw new Error('Book not found') @@ -162,6 +163,10 @@ export class BookRegistryService { getBookmarkRepo(bookId: string): BookmarkRepository { return new BookmarkRepository(this.getDb(bookId)) } + + getChapterTagRepo(bookId: string): ChapterTagRepository { + return new ChapterTagRepository(this.getDb(bookId)) + } } export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): { diff --git a/src/main/services/cockpit.service.ts b/src/main/services/cockpit.service.ts index fcd936e..1fb34be 100644 --- a/src/main/services/cockpit.service.ts +++ b/src/main/services/cockpit.service.ts @@ -52,7 +52,9 @@ export class CockpitService { } shouldShowOnOpen(): boolean { - return new BookPrefsRepository(this.db).get('cockpitSeen') !== 'true' + const prefs = new BookPrefsRepository(this.db) + if (prefs.get('alwaysShowCockpit') === 'true') return true + return prefs.get('cockpitSeen') !== 'true' } markSeen(): void { diff --git a/src/preload/index.ts b/src/preload/index.ts index 4e76ef5..b0c5a6d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -36,6 +36,8 @@ import type { Snapshot, SnapshotType, UpdateChapterParams, + UpdateBookMetaParams, + ChapterTag, Volume, WordFreqResult, KnowledgeEntry, @@ -80,8 +82,16 @@ const electronAPI = { ipcRenderer.invoke(IPC.BOOK_OPEN, { bookId }), updateMeta: ( bookId: string, - patch: { lastChapterId?: string | null; status?: BookMeta['status'] } - ): Promise> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch }) + patch: UpdateBookMetaParams + ): Promise> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch }), + pickCover: (bookId: string): Promise> => + ipcRenderer.invoke(IPC.BOOK_PICK_COVER, { bookId }) + }, + bookPrefs: { + get: (bookId: string, key: string): Promise> => + ipcRenderer.invoke(IPC.BOOK_PREFS_GET, { bookId, key }), + set: (bookId: string, key: string, value: string): Promise> => + ipcRenderer.invoke(IPC.BOOK_PREFS_SET, { bookId, key, value }) }, volume: { create: (bookId: string, name: string): Promise> => @@ -120,6 +130,19 @@ const electronAPI = { ): Promise> => ipcRenderer.invoke(IPC.CHAPTER_SET_PUBLISH_STATUS, { bookId, chapterId, publishStatus }) }, + chapterTag: { + list: (bookId: string, chapterId: string): Promise> => + ipcRenderer.invoke(IPC.CHAPTER_TAG_LIST, { bookId, chapterId }), + set: ( + bookId: string, + chapterId: string, + tag: string, + color?: string + ): Promise> => + ipcRenderer.invoke(IPC.CHAPTER_TAG_SET, { bookId, chapterId, tag, color }), + remove: (bookId: string, chapterId: string, tag: string): Promise> => + ipcRenderer.invoke(IPC.CHAPTER_TAG_REMOVE, { bookId, chapterId, tag }) + }, outline: { list: (bookId: string): Promise> => ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }), @@ -277,6 +300,12 @@ const electronAPI = { ipcRenderer.invoke(IPC.AI_SESSION_UPDATE, { bookId, sessionId, patch }), sessionDelete: (bookId: string, sessionId: string): Promise> => ipcRenderer.invoke(IPC.AI_SESSION_DELETE, { bookId, sessionId }), + sessionExport: ( + bookId: string, + sessionId: string, + format?: 'markdown' | 'txt' + ): Promise> => + ipcRenderer.invoke(IPC.AI_SESSION_EXPORT, { bookId, sessionId, format }), messageList: (bookId: string, sessionId: string): Promise> => ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }), chat: ( diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3007482..d50d34b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -18,10 +18,14 @@ import { useEditStore } from '@renderer/stores/useEditStore' import { SearchModal } from '@renderer/components/search/SearchModal' import { InspirationModal } from '@renderer/components/inspiration/InspirationModal' import { ImportBookModal } from '@renderer/components/import/ImportBookModal' +import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay' +import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal' import { useSearchStore } from '@renderer/stores/useSearchStore' import { useReferenceStore } from '@renderer/stores/useReferenceStore' import { flushEditorSave } from '@renderer/components/editor/TipTapEditor' +import { getEditorTextForTts } from '@renderer/lib/editor-commands' import { insertLandmarkInEditor } from '@renderer/lib/editor-commands' +import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller' import { useAiStore } from '@renderer/stores/useAiStore' import { useExportStore } from '@renderer/stores/useExportStore' import { ipcCall } from '@renderer/lib/ipc-client' @@ -30,6 +34,10 @@ function AppInner(): React.JSX.Element { const { t } = useTranslation() const view = useAppStore((s) => s.view) const toast = useAppStore((s) => s.toast) + const focusMode = useAppStore((s) => s.focusMode) + const namingCheckOpen = useAppStore((s) => s.namingCheckOpen) + const setFocusMode = useAppStore((s) => s.setFocusMode) + const setNamingCheckOpen = useAppStore((s) => s.setNamingCheckOpen) const showToast = useAppStore((s) => s.showToast) const setView = useAppStore((s) => s.setView) const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore() @@ -158,27 +166,50 @@ function AppInner(): React.JSX.Element { useExportStore.getState().openModal() return } + if (action === 'focusMode') { + if (view !== 'editor') return + setFocusMode(!useAppStore.getState().focusMode) + return + } + if (action === 'toggleTTS') { + if (view !== 'editor') return + if (isSpeaking()) { + stopSpeaking() + showToast(t('tts.stopped')) + return + } + const text = getEditorTextForTts() + if (!text.trim()) { + showToast(t('tts.noText')) + return + } + speakText(text) + showToast(t('tts.started')) + return + } showToast(t('feature.comingSoon')) }) return unsub - }, [view, activeTabId, openTabs, openBook, setView, showToast, t]) + }, [view, activeTabId, openTabs, openBook, setView, setFocusMode, showToast, t]) const handleOnboardingComplete = (): void => { setShowOnboarding(false) } return ( -
+
{view === 'home' && } {view === 'editor' && } {view === 'settings' && }
+ {showOnboarding && } + setNamingCheckOpen(false)} /> {toast &&
{toast}
}
) diff --git a/src/renderer/components/ai/AiPanel.tsx b/src/renderer/components/ai/AiPanel.tsx index 051433e..ac9dbf4 100644 --- a/src/renderer/components/ai/AiPanel.tsx +++ b/src/renderer/components/ai/AiPanel.tsx @@ -12,6 +12,7 @@ import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal' import { InteractivePanel } from '@renderer/components/ai/InteractivePanel' import { AutoPanel } from '@renderer/components/ai/AutoPanel' import { WizardPanel } from '@renderer/components/ai/WizardPanel' +import { AiSessionMenu } from '@renderer/components/ai/AiSessionMenu' import { useInteractiveStore } from '@renderer/stores/useInteractiveStore' import { useAutoStore } from '@renderer/stores/useAutoStore' import { useWizardStore } from '@renderer/stores/useWizardStore' @@ -75,7 +76,7 @@ export function AiPanel(): React.JSX.Element { useEffect(() => { if (bookId) { - void loadSessions(bookId) + void loadSessions(bookId, true) void checkGate(bookId) void checkAutoGate(bookId) void checkWizardGate(bookId) @@ -176,6 +177,10 @@ export function AiPanel(): React.JSX.Element { ) } + const activeSessions = sessions.filter((s) => !s.archived) + const archivedSessions = sessions.filter((s) => s.archived) + const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null + return ( <>
@@ -186,12 +191,26 @@ export function AiPanel(): React.JSX.Element { onChange={(e) => void selectSession(e.target.value)} > {sessions.length === 0 && } - {sessions.map((s) => ( - - ))} + {activeSessions.length > 0 && ( + + {activeSessions.map((s) => ( + + ))} + + )} + {archivedSessions.length > 0 && ( + + {archivedSessions.map((s) => ( + + ))} + + )} + + {open && ( +
e.stopPropagation()}> + + + + {exportOpen && ( +
+ + +
+ )} + +
+ )} +
+ ) +} diff --git a/src/renderer/components/book/BookSettingsTab.tsx b/src/renderer/components/book/BookSettingsTab.tsx new file mode 100644 index 0000000..34e53df --- /dev/null +++ b/src/renderer/components/book/BookSettingsTab.tsx @@ -0,0 +1,183 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ipcCall } from '@renderer/lib/ipc-client' +import { useAppStore } from '@renderer/stores/useAppStore' +import { useBookStore } from '@renderer/stores/useBookStore' +import { useBookPrefsStore } from '@renderer/stores/useBookPrefsStore' + +export function BookSettingsTab(): React.JSX.Element { + const { t } = useTranslation() + const setView = useAppStore((s) => s.setView) + const { currentBookId, books, loadBooks } = useBookStore() + const { alwaysShowCockpit, load, setAlwaysShowCockpit } = useBookPrefsStore() + + const book = useMemo( + () => books.find((b) => b.id === currentBookId) ?? null, + [books, currentBookId] + ) + + const [name, setName] = useState('') + const [category, setCategory] = useState('玄幻') + const [targetWordCount, setTargetWordCount] = useState('') + const [synopsis, setSynopsis] = useState('') + const [coverPath, setCoverPath] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!book) return + setName(book.name) + setCategory(book.category || '玄幻') + setTargetWordCount(book.targetWordCount != null ? String(book.targetWordCount) : '') + setSynopsis(book.synopsis ?? '') + setCoverPath(book.coverPath ?? null) + }, [book]) + + useEffect(() => { + if (currentBookId) void load(currentBookId) + }, [currentBookId, load]) + + const handleSave = async (): Promise => { + if (!currentBookId || !name.trim()) return + setSaving(true) + try { + const updated = await ipcCall(() => + window.electronAPI.book.updateMeta(currentBookId, { + name: name.trim(), + category, + targetWordCount: targetWordCount ? Number(targetWordCount) : null, + synopsis: synopsis.trim(), + coverPath + }) + ) + setCoverPath(updated.coverPath ?? null) + await loadBooks() + } finally { + setSaving(false) + } + } + + const handlePickCover = async (): Promise => { + if (!currentBookId) return + const updated = await ipcCall(() => window.electronAPI.book.pickCover(currentBookId)) + if (updated) { + setCoverPath(updated.coverPath ?? null) + await loadBooks() + } + } + + const handleDelete = async (): Promise => { + if (!currentBookId || !book) return + if (!window.confirm(t('bookSettings.deleteConfirm', { name: book.name }))) return + await ipcCall(() => window.electronAPI.book.delete(currentBookId)) + await loadBooks() + setView('home') + } + + if (!currentBookId || !book) { + return
{t('ai.noBook')}
+ } + + return ( +
+

{t('bookSettings.title')}

+ +
+ + setName(e.target.value)} + /> +
+ +
+ + +
+ +
+ + setTargetWordCount(e.target.value)} + /> +
+ +
+ +