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 <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,14 @@
|
|||||||
# 笔临 (Bilin)
|
# 笔临 (Bilin)
|
||||||
|
|
||||||
长篇创作智能协作平台 — Electron 桌面客户端 v1.1.0(作家日更工具包)
|
长篇创作智能协作平台 — Electron 桌面客户端 v1.2.0(基础治理)
|
||||||
|
|
||||||
|
## 功能概览(v1.2.0)
|
||||||
|
|
||||||
|
- 书籍设置:书名/分类/目标字数/简介/封面、驾驶舱偏好、删书
|
||||||
|
- 分卷右键管理、章节双击改名/删除、章节元数据与标签
|
||||||
|
- 模板变量自动填充(含大纲项与角色列表)
|
||||||
|
- AI 会话重命名/归档/导出/删除
|
||||||
|
- 专注模式快捷键、TTS 朗读、顶栏日更进度条
|
||||||
|
|
||||||
## 功能概览(v1.1.0)
|
## 功能概览(v1.1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
- Modify: `src/shared/types.ts`
|
- Modify: `src/shared/types.ts`
|
||||||
- Modify: `src/shared/ipc-channels.ts`
|
- Modify: `src/shared/ipc-channels.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: schema-v9.sql**
|
- [x] **Step 1: schema-v9.sql**
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE IF NOT EXISTS chapter_tags (
|
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
|
```typescript
|
||||||
if (current < 9) {
|
if (current < 9) {
|
||||||
@@ -85,7 +85,7 @@ if (current < 9) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: types.ts** — 扩展:
|
- [x] **Step 3: types.ts** — 扩展:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export interface BookMeta {
|
export interface BookMeta {
|
||||||
@@ -113,7 +113,7 @@ export interface ChapterTag {
|
|||||||
// Chapter 已有 summary?, storyTime? — 确保 chapter.update IPC 接受它们
|
// Chapter 已有 summary?, storyTime? — 确保 chapter.update IPC 接受它们
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: ipc-channels.ts**
|
- [x] **Step 4: ipc-channels.ts**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
CHAPTER_TAG_LIST: 'chapter:tagList',
|
CHAPTER_TAG_LIST: 'chapter:tagList',
|
||||||
@@ -124,7 +124,7 @@ BOOK_PREFS_SET: 'book:prefsSet',
|
|||||||
AI_SESSION_EXPORT: 'ai:sessionExport',
|
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/book-registry.ts`
|
||||||
- Modify: `src/main/services/cockpit.service.ts`
|
- Modify: `src/main/services/cockpit.service.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: ChapterTagRepository**
|
- [x] **Step 1: ChapterTagRepository**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export class ChapterTagRepository {
|
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
|
```typescript
|
||||||
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
|
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
|
||||||
@@ -160,9 +160,9 @@ setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: book-registry.updateMeta** — 签名改为 `Partial<Pick<BookMeta, 'name' | 'category' | 'targetWordCount' | 'coverPath' | 'synopsis' | 'lastOpenedAt' | 'lastChapterId' | 'status'>>`
|
- [x] **Step 3: book-registry.updateMeta** — 签名改为 `Partial<Pick<BookMeta, 'name' | 'category' | 'targetWordCount' | 'coverPath' | 'synopsis' | 'lastOpenedAt' | 'lastChapterId' | 'status'>>`
|
||||||
|
|
||||||
- [ ] **Step 4: cockpit.service**
|
- [x] **Step 4: cockpit.service**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
shouldShowOnOpen(): boolean {
|
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`
|
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/preload/index.ts`
|
||||||
- Modify: `src/shared/electron-api.d.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
|
```typescript
|
||||||
ipcMain.handle(IPC.CHAPTER_TAG_LIST, (_e, { bookId, chapterId }) =>
|
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 同理
|
// TAG_SET / TAG_REMOVE 同理
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: book-prefs.handler**
|
- [x] **Step 3: book-prefs.handler**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
ipcMain.handle(IPC.BOOK_PREFS_GET, (_e, { bookId, key }) =>
|
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) }))
|
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`
|
- Modify: `src/renderer/components/chapter/TemplateQuickCreateModal.tsx`
|
||||||
- Create: `tests/main/chapter-template-context.test.ts`
|
- Create: `tests/main/chapter-template-context.test.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: 新增 `buildTemplateContext()`**
|
- [x] **Step 1: 新增 `buildTemplateContext()`**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export function buildTemplateContext(input: {
|
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
|
```typescript
|
||||||
it('UT-TEMPL-05: characterList from settings', () => {
|
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`
|
- Create: `src/renderer/stores/useBookPrefsStore.ts`
|
||||||
- Modify: `src/renderer/components/layout/RightPanel.tsx`
|
- Modify: `src/renderer/components/layout/RightPanel.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1: BookSettingsTab** — 表单字段:
|
- [x] **Step 1: BookSettingsTab** — 表单字段:
|
||||||
- 书名、分类、目标字数、简介(textarea)
|
- 书名、分类、目标字数、简介(textarea)
|
||||||
- 封面:按钮选文件 → 存 `userData/covers/{bookId}.jpg` 路径到 meta.coverPath
|
- 封面:按钮选文件 → 存 `userData/covers/{bookId}.jpg` 路径到 meta.coverPath
|
||||||
- 「每次打开显示驾驶舱」checkbox → `bookPrefs.set('alwaysShowCockpit', 'true'|'false')`
|
- 「每次打开显示驾驶舱」checkbox → `bookPrefs.set('alwaysShowCockpit', 'true'|'false')`
|
||||||
- 危险区:删书 → confirm → `book.delete` → `loadBooks` → `setView('home')`
|
- 危险区:删书 → confirm → `book.delete` → `loadBooks` → `setView('home')`
|
||||||
|
|
||||||
- [ ] **Step 2: RightPanel** — `book-settings` panel 渲染 `<BookSettingsTab />`
|
- [x] **Step 2: RightPanel** — `book-settings` panel 渲染 `<BookSettingsTab />`
|
||||||
|
|
||||||
- [ ] **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`
|
- Create: `src/renderer/components/chapter/ChapterMetaPanel.tsx`
|
||||||
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
|
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1: VolumeContextMenu** — 绑定分卷标题 `onContextMenu`:
|
- [x] **Step 1: VolumeContextMenu** — 绑定分卷标题 `onContextMenu`:
|
||||||
- 重命名:prompt → `volume.update`
|
- 重命名:prompt → `volume.update`
|
||||||
- 编辑简述:prompt → `volume.update({ description })`
|
- 编辑简述:prompt → `volume.update({ description })`
|
||||||
- 删除:若卷内无章 → `volume.delete`;有章则 toast 错误
|
- 删除:若卷内无章 → `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 输入
|
- summary、story_time 输入
|
||||||
- 标签 chips + 添加输入
|
- 标签 chips + 添加输入
|
||||||
- word_count_threshold 数字
|
- 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/components/ai/AiPanel.tsx`
|
||||||
- Modify: `src/renderer/stores/useAiStore.ts`
|
- Modify: `src/renderer/stores/useAiStore.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: AiSessionMenu** — 会话选择器旁「⋯」按钮:
|
- [x] **Step 1: AiSessionMenu** — 会话选择器旁「⋯」按钮:
|
||||||
- 重命名 → `ai.session.update({ title })`
|
- 重命名 → `ai.session.update({ title })`
|
||||||
- 归档/取消归档 → `{ archived: true/false }`
|
- 归档/取消归档 → `{ archived: true/false }`
|
||||||
- 导出 → 子菜单 txt/md → `ai.session.export`
|
- 导出 → 子菜单 txt/md → `ai.session.export`
|
||||||
- 删除 → confirm → `ai.session.delete`
|
- 删除 → 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/App.tsx`
|
||||||
- Modify: `src/renderer/styles/layout.css`
|
- 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 非必要按钮)
|
- true 时 `#app` 加 `focus-mode` class(CSS 隐藏 sidebars/right-panel/topbar 非必要按钮)
|
||||||
- Esc 或按钮退出
|
- 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
|
```typescript
|
||||||
export function speakText(text: string, lang = 'zh-CN'): void {
|
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/components/layout/TopBar.tsx`
|
||||||
- Modify: `src/renderer/stores/useAppStore.ts`
|
- 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`
|
- 读 `useWritingStatsStore` + `dailyWordGoal`
|
||||||
- `goalProgress` 映射 width%;≥100% 绿色,≥80% 橙色
|
- `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: `e2e/helpers.ts`
|
||||||
- Modify: `package.json`, `README.md`, `SettingsPage.tsx`
|
- 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
|
```bash
|
||||||
npm run build
|
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
|
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**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, existsSync } from 'fs'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import { test, expect } from '@playwright/test'
|
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', () => {
|
test.describe('Chapter template', () => {
|
||||||
let userDataDir: string
|
let userDataDir: string
|
||||||
@@ -40,4 +40,36 @@ test.describe('Chapter template', () => {
|
|||||||
|
|
||||||
await app.close()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -61,3 +61,19 @@ export async function openExportModal(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
await expect(modal).toBeVisible({ timeout: 10_000 })
|
await expect(modal).toBeVisible({ timeout: 10_000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openBookSettings(page: Page): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bilin",
|
"name": "bilin",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"description": "笔临 - 长篇创作智能协作平台",
|
"description": "笔临 - 长篇创作智能协作平台",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -457,5 +457,49 @@
|
|||||||
"submission.setDefault": "Set as default",
|
"submission.setDefault": "Set as default",
|
||||||
"inspiration.selectBook": "Link to book",
|
"inspiration.selectBook": "Link to book",
|
||||||
"inspiration.saved": "Idea saved",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,5 +457,49 @@
|
|||||||
"submission.setDefault": "设为默认预设",
|
"submission.setDefault": "设为默认预设",
|
||||||
"inspiration.selectBook": "关联书籍",
|
"inspiration.selectBook": "关联书籍",
|
||||||
"inspiration.saved": "灵感已保存",
|
"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": "批量导出将在后续版本提供"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -7,8 +7,9 @@ import schemaV5 from './schema-v5.sql?raw'
|
|||||||
import schemaV6 from './schema-v6.sql?raw'
|
import schemaV6 from './schema-v6.sql?raw'
|
||||||
import schemaV7 from './schema-v7.sql?raw'
|
import schemaV7 from './schema-v7.sql?raw'
|
||||||
import schemaV8 from './schema-v8.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 {
|
function tryAlter(db: SqliteDb, sql: string): void {
|
||||||
try {
|
try {
|
||||||
@@ -88,5 +89,13 @@ export function migrate(db: SqliteDb): void {
|
|||||||
if (current < 8) {
|
if (current < 8) {
|
||||||
db.exec(schemaV8)
|
db.exec(schemaV8)
|
||||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection')
|
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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ChapterTag } from '../../../shared/types'
|
||||||
|
import type { SqliteDb } from '../types'
|
||||||
|
|
||||||
|
function mapRow(row: Record<string, unknown>): 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<string, unknown>[]
|
||||||
|
).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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,11 @@ function mapRow(row: Record<string, unknown>): Chapter {
|
|||||||
cursorOffset: (row.cursor_offset as number) ?? 0,
|
cursorOffset: (row.cursor_offset as number) ?? 0,
|
||||||
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin,
|
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin,
|
||||||
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus,
|
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
|
cursorOffset: number
|
||||||
publishStatus: PublishStatus
|
publishStatus: PublishStatus
|
||||||
povCharacterId: string | null
|
povCharacterId: string | null
|
||||||
|
summary: string
|
||||||
|
storyTime: string | null
|
||||||
|
publishedAt: string | null
|
||||||
|
wordCountThreshold: number | null
|
||||||
}>
|
}>
|
||||||
): Chapter {
|
): Chapter {
|
||||||
const existing = this.get(id)
|
const existing = this.get(id)
|
||||||
@@ -82,7 +90,9 @@ export class ChapterRepository {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE chapters SET
|
`UPDATE chapters SET
|
||||||
title = ?, content = ?, status = ?, cursor_offset = ?,
|
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 = ?`
|
WHERE id = ?`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
@@ -93,13 +103,23 @@ export class ChapterRepository {
|
|||||||
patch.publishStatus ?? existing.publishStatus ?? 'draft',
|
patch.publishStatus ?? existing.publishStatus ?? 'draft',
|
||||||
povCharacterId,
|
povCharacterId,
|
||||||
wordCount,
|
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
|
id
|
||||||
)
|
)
|
||||||
return this.get(id)!
|
return this.get(id)!
|
||||||
}
|
}
|
||||||
|
|
||||||
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
|
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
|
||||||
return this.update(id, { publishStatus })
|
const patch: Parameters<ChapterRepository['update']>[1] = { publishStatus }
|
||||||
|
if (publishStatus === 'published') {
|
||||||
|
patch.publishedAt = new Date().toISOString()
|
||||||
|
}
|
||||||
|
return this.update(id, patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
countByPublishStatus(status: PublishStatus): number {
|
countByPublishStatus(status: PublishStatus): number {
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'crypto'
|
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 { IPC } from '../../../shared/ipc-channels'
|
||||||
import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types'
|
import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types'
|
||||||
import { BookRegistryService } from '../../services/book-registry'
|
import { BookRegistryService } from '../../services/book-registry'
|
||||||
@@ -19,6 +20,15 @@ import { wrap } from '../result'
|
|||||||
|
|
||||||
const activeChats = new Map<string, AbortController>()
|
const activeChats = new Map<string, AbortController>()
|
||||||
|
|
||||||
|
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(
|
export function registerAiHandlers(
|
||||||
registry: BookRegistryService,
|
registry: BookRegistryService,
|
||||||
settings: GlobalSettingsService,
|
settings: GlobalSettingsService,
|
||||||
@@ -67,6 +77,39 @@ export function registerAiHandlers(
|
|||||||
wrap(() => registry.getAiSessionRepo(bookId).listMessages(sessionId))
|
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(
|
ipcMain.handle(
|
||||||
IPC.AI_BUILD_CONTEXT,
|
IPC.AI_BUILD_CONTEXT,
|
||||||
(_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) =>
|
(_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) =>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { 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 { BookRegistryService } from '../../services/book-registry'
|
||||||
import type { WritingSessionService } from '../../services/writing-session.service'
|
import type { WritingSessionService } from '../../services/writing-session.service'
|
||||||
import { wrap } from '../result'
|
import { wrap } from '../result'
|
||||||
@@ -35,17 +37,24 @@ export function registerBookHandlers(
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.BOOK_UPDATE_META,
|
IPC.BOOK_UPDATE_META,
|
||||||
(
|
(_event, { bookId, ...patch }: { bookId: string } & UpdateBookMetaParams) =>
|
||||||
_event,
|
wrap(() => registry.updateMeta(bookId, patch))
|
||||||
{
|
)
|
||||||
bookId,
|
|
||||||
...patch
|
ipcMain.handle(IPC.BOOK_PICK_COVER, (_event, { bookId }: { bookId: string }) =>
|
||||||
}: {
|
wrap(async () => {
|
||||||
bookId: string
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||||
lastChapterId?: string | null
|
filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }],
|
||||||
lastOpenedAt?: string
|
properties: ['openFile']
|
||||||
status?: BookMeta['status']
|
})
|
||||||
}
|
if (canceled || !filePaths[0]) return null
|
||||||
) => wrap(() => registry.updateMeta(bookId, patch))
|
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 })
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,12 @@ export function registerChapterHandlers(
|
|||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
cursorOffset,
|
cursorOffset,
|
||||||
povCharacterId
|
povCharacterId,
|
||||||
|
summary,
|
||||||
|
storyTime,
|
||||||
|
wordCountThreshold,
|
||||||
|
publishStatus,
|
||||||
|
publishedAt
|
||||||
}: {
|
}: {
|
||||||
bookId: string
|
bookId: string
|
||||||
chapterId: string
|
chapterId: string
|
||||||
@@ -84,6 +89,11 @@ export function registerChapterHandlers(
|
|||||||
status?: ChapterStatus
|
status?: ChapterStatus
|
||||||
cursorOffset?: number
|
cursorOffset?: number
|
||||||
povCharacterId?: string | null
|
povCharacterId?: string | null
|
||||||
|
summary?: string
|
||||||
|
storyTime?: string | null
|
||||||
|
wordCountThreshold?: number | null
|
||||||
|
publishStatus?: PublishStatus
|
||||||
|
publishedAt?: string | null
|
||||||
}
|
}
|
||||||
) =>
|
) =>
|
||||||
wrap(() => {
|
wrap(() => {
|
||||||
@@ -92,7 +102,12 @@ export function registerChapterHandlers(
|
|||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
cursorOffset,
|
cursorOffset,
|
||||||
povCharacterId
|
povCharacterId,
|
||||||
|
summary,
|
||||||
|
storyTime,
|
||||||
|
wordCountThreshold,
|
||||||
|
publishStatus,
|
||||||
|
publishedAt
|
||||||
})
|
})
|
||||||
ftsSync.upsert(
|
ftsSync.upsert(
|
||||||
registry.getDb(bookId),
|
registry.getDb(bookId),
|
||||||
@@ -146,4 +161,34 @@ export function registerChapterHandlers(
|
|||||||
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
|
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
|
||||||
) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, 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)
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { WritingSessionRepository } from '../db/repositories/writing-session.rep
|
|||||||
import { WritingSessionService } from '../services/writing-session.service'
|
import { WritingSessionService } from '../services/writing-session.service'
|
||||||
import { registerSettingsHandlers } from './handlers/settings.handler'
|
import { registerSettingsHandlers } from './handlers/settings.handler'
|
||||||
import { registerBookHandlers } from './handlers/book.handler'
|
import { registerBookHandlers } from './handlers/book.handler'
|
||||||
|
import { registerBookPrefsHandlers } from './handlers/book-prefs.handler'
|
||||||
import { registerChapterHandlers } from './handlers/chapter.handler'
|
import { registerChapterHandlers } from './handlers/chapter.handler'
|
||||||
import { registerShortcutHandlers } from './handlers/shortcut.handler'
|
import { registerShortcutHandlers } from './handlers/shortcut.handler'
|
||||||
import { registerOutlineHandlers } from './handlers/outline.handler'
|
import { registerOutlineHandlers } from './handlers/outline.handler'
|
||||||
@@ -69,6 +70,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
|||||||
|
|
||||||
registerSettingsHandlers(settings)
|
registerSettingsHandlers(settings)
|
||||||
registerBookHandlers(books, writingSessions)
|
registerBookHandlers(books, writingSessions)
|
||||||
|
registerBookPrefsHandlers(books)
|
||||||
registerChapterHandlers(books, writingLogs, writingSessions)
|
registerChapterHandlers(books, writingLogs, writingSessions)
|
||||||
registerShortcutHandlers(shortcutManager)
|
registerShortcutHandlers(shortcutManager)
|
||||||
registerOutlineHandlers(books)
|
registerOutlineHandlers(books)
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { AiSessionRepository } from '../db/repositories/ai-session.repo'
|
|||||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||||
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
|
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
|
||||||
import { BookmarkRepository } from '../db/repositories/bookmark.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 {
|
interface RegistryFile {
|
||||||
books: BookMeta[]
|
books: BookMeta[]
|
||||||
@@ -90,7 +91,7 @@ export class BookRegistryService {
|
|||||||
this.writeRegistry(registry)
|
this.writeRegistry(registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMeta(bookId: string, patch: Partial<Pick<BookMeta, 'lastOpenedAt' | 'lastChapterId' | 'status'>>): BookMeta {
|
updateMeta(bookId: string, patch: UpdateBookMetaParams): BookMeta {
|
||||||
const registry = this.readRegistry()
|
const registry = this.readRegistry()
|
||||||
const idx = registry.books.findIndex((b) => b.id === bookId)
|
const idx = registry.books.findIndex((b) => b.id === bookId)
|
||||||
if (idx === -1) throw new Error('Book not found')
|
if (idx === -1) throw new Error('Book not found')
|
||||||
@@ -162,6 +163,10 @@ export class BookRegistryService {
|
|||||||
getBookmarkRepo(bookId: string): BookmarkRepository {
|
getBookmarkRepo(bookId: string): BookmarkRepository {
|
||||||
return new BookmarkRepository(this.getDb(bookId))
|
return new BookmarkRepository(this.getDb(bookId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getChapterTagRepo(bookId: string): ChapterTagRepository {
|
||||||
|
return new ChapterTagRepository(this.getDb(bookId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): {
|
export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): {
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export class CockpitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shouldShowOnOpen(): boolean {
|
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 {
|
markSeen(): void {
|
||||||
|
|||||||
+31
-2
@@ -36,6 +36,8 @@ import type {
|
|||||||
Snapshot,
|
Snapshot,
|
||||||
SnapshotType,
|
SnapshotType,
|
||||||
UpdateChapterParams,
|
UpdateChapterParams,
|
||||||
|
UpdateBookMetaParams,
|
||||||
|
ChapterTag,
|
||||||
Volume,
|
Volume,
|
||||||
WordFreqResult,
|
WordFreqResult,
|
||||||
KnowledgeEntry,
|
KnowledgeEntry,
|
||||||
@@ -80,8 +82,16 @@ const electronAPI = {
|
|||||||
ipcRenderer.invoke(IPC.BOOK_OPEN, { bookId }),
|
ipcRenderer.invoke(IPC.BOOK_OPEN, { bookId }),
|
||||||
updateMeta: (
|
updateMeta: (
|
||||||
bookId: string,
|
bookId: string,
|
||||||
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
|
patch: UpdateBookMetaParams
|
||||||
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch })
|
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch }),
|
||||||
|
pickCover: (bookId: string): Promise<IpcResult<BookMeta | null>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BOOK_PICK_COVER, { bookId })
|
||||||
|
},
|
||||||
|
bookPrefs: {
|
||||||
|
get: (bookId: string, key: string): Promise<IpcResult<string | null>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BOOK_PREFS_GET, { bookId, key }),
|
||||||
|
set: (bookId: string, key: string, value: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.BOOK_PREFS_SET, { bookId, key, value })
|
||||||
},
|
},
|
||||||
volume: {
|
volume: {
|
||||||
create: (bookId: string, name: string): Promise<IpcResult<Volume>> =>
|
create: (bookId: string, name: string): Promise<IpcResult<Volume>> =>
|
||||||
@@ -120,6 +130,19 @@ const electronAPI = {
|
|||||||
): Promise<IpcResult<Chapter>> =>
|
): Promise<IpcResult<Chapter>> =>
|
||||||
ipcRenderer.invoke(IPC.CHAPTER_SET_PUBLISH_STATUS, { bookId, chapterId, publishStatus })
|
ipcRenderer.invoke(IPC.CHAPTER_SET_PUBLISH_STATUS, { bookId, chapterId, publishStatus })
|
||||||
},
|
},
|
||||||
|
chapterTag: {
|
||||||
|
list: (bookId: string, chapterId: string): Promise<IpcResult<ChapterTag[]>> =>
|
||||||
|
ipcRenderer.invoke(IPC.CHAPTER_TAG_LIST, { bookId, chapterId }),
|
||||||
|
set: (
|
||||||
|
bookId: string,
|
||||||
|
chapterId: string,
|
||||||
|
tag: string,
|
||||||
|
color?: string
|
||||||
|
): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.CHAPTER_TAG_SET, { bookId, chapterId, tag, color }),
|
||||||
|
remove: (bookId: string, chapterId: string, tag: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.CHAPTER_TAG_REMOVE, { bookId, chapterId, tag })
|
||||||
|
},
|
||||||
outline: {
|
outline: {
|
||||||
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
|
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
|
||||||
ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }),
|
ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }),
|
||||||
@@ -277,6 +300,12 @@ const electronAPI = {
|
|||||||
ipcRenderer.invoke(IPC.AI_SESSION_UPDATE, { bookId, sessionId, patch }),
|
ipcRenderer.invoke(IPC.AI_SESSION_UPDATE, { bookId, sessionId, patch }),
|
||||||
sessionDelete: (bookId: string, sessionId: string): Promise<IpcResult<void>> =>
|
sessionDelete: (bookId: string, sessionId: string): Promise<IpcResult<void>> =>
|
||||||
ipcRenderer.invoke(IPC.AI_SESSION_DELETE, { bookId, sessionId }),
|
ipcRenderer.invoke(IPC.AI_SESSION_DELETE, { bookId, sessionId }),
|
||||||
|
sessionExport: (
|
||||||
|
bookId: string,
|
||||||
|
sessionId: string,
|
||||||
|
format?: 'markdown' | 'txt'
|
||||||
|
): Promise<IpcResult<string | null>> =>
|
||||||
|
ipcRenderer.invoke(IPC.AI_SESSION_EXPORT, { bookId, sessionId, format }),
|
||||||
messageList: (bookId: string, sessionId: string): Promise<IpcResult<AiMessage[]>> =>
|
messageList: (bookId: string, sessionId: string): Promise<IpcResult<AiMessage[]>> =>
|
||||||
ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }),
|
ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }),
|
||||||
chat: (
|
chat: (
|
||||||
|
|||||||
+33
-2
@@ -18,10 +18,14 @@ import { useEditStore } from '@renderer/stores/useEditStore'
|
|||||||
import { SearchModal } from '@renderer/components/search/SearchModal'
|
import { SearchModal } from '@renderer/components/search/SearchModal'
|
||||||
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
||||||
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
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 { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||||
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||||
|
import { getEditorTextForTts } from '@renderer/lib/editor-commands'
|
||||||
import { insertLandmarkInEditor } 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 { useAiStore } from '@renderer/stores/useAiStore'
|
||||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
@@ -30,6 +34,10 @@ function AppInner(): React.JSX.Element {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const view = useAppStore((s) => s.view)
|
const view = useAppStore((s) => s.view)
|
||||||
const toast = useAppStore((s) => s.toast)
|
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 showToast = useAppStore((s) => s.showToast)
|
||||||
const setView = useAppStore((s) => s.setView)
|
const setView = useAppStore((s) => s.setView)
|
||||||
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
|
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
|
||||||
@@ -158,27 +166,50 @@ function AppInner(): React.JSX.Element {
|
|||||||
useExportStore.getState().openModal()
|
useExportStore.getState().openModal()
|
||||||
return
|
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'))
|
showToast(t('feature.comingSoon'))
|
||||||
})
|
})
|
||||||
return unsub
|
return unsub
|
||||||
}, [view, activeTabId, openTabs, openBook, setView, showToast, t])
|
}, [view, activeTabId, openTabs, openBook, setView, setFocusMode, showToast, t])
|
||||||
|
|
||||||
const handleOnboardingComplete = (): void => {
|
const handleOnboardingComplete = (): void => {
|
||||||
setShowOnboarding(false)
|
setShowOnboarding(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="app">
|
<div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<div id="main-area">
|
<div id="main-area">
|
||||||
{view === 'home' && <HomePage />}
|
{view === 'home' && <HomePage />}
|
||||||
{view === 'editor' && <EditorLayout />}
|
{view === 'editor' && <EditorLayout />}
|
||||||
{view === 'settings' && <SettingsPage />}
|
{view === 'settings' && <SettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
|
<FocusModeOverlay />
|
||||||
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||||
<SearchModal />
|
<SearchModal />
|
||||||
<InspirationModal />
|
<InspirationModal />
|
||||||
<ImportBookModal />
|
<ImportBookModal />
|
||||||
|
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className="toast">{toast}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
|||||||
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
|
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
|
||||||
import { AutoPanel } from '@renderer/components/ai/AutoPanel'
|
import { AutoPanel } from '@renderer/components/ai/AutoPanel'
|
||||||
import { WizardPanel } from '@renderer/components/ai/WizardPanel'
|
import { WizardPanel } from '@renderer/components/ai/WizardPanel'
|
||||||
|
import { AiSessionMenu } from '@renderer/components/ai/AiSessionMenu'
|
||||||
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||||
import { useAutoStore } from '@renderer/stores/useAutoStore'
|
import { useAutoStore } from '@renderer/stores/useAutoStore'
|
||||||
import { useWizardStore } from '@renderer/stores/useWizardStore'
|
import { useWizardStore } from '@renderer/stores/useWizardStore'
|
||||||
@@ -75,7 +76,7 @@ export function AiPanel(): React.JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bookId) {
|
if (bookId) {
|
||||||
void loadSessions(bookId)
|
void loadSessions(bookId, true)
|
||||||
void checkGate(bookId)
|
void checkGate(bookId)
|
||||||
void checkAutoGate(bookId)
|
void checkAutoGate(bookId)
|
||||||
void checkWizardGate(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="ai-panel" className="ai-panel" data-testid="ai-panel">
|
<div id="ai-panel" className="ai-panel" data-testid="ai-panel">
|
||||||
@@ -186,12 +191,26 @@ export function AiPanel(): React.JSX.Element {
|
|||||||
onChange={(e) => void selectSession(e.target.value)}
|
onChange={(e) => void selectSession(e.target.value)}
|
||||||
>
|
>
|
||||||
{sessions.length === 0 && <option value="">{t('ai.noSession')}</option>}
|
{sessions.length === 0 && <option value="">{t('ai.noSession')}</option>}
|
||||||
{sessions.map((s) => (
|
{activeSessions.length > 0 && (
|
||||||
<option key={s.id} value={s.id}>
|
<optgroup label={t('ai.session.active')}>
|
||||||
{s.title || t('ai.untitledSession')}
|
{activeSessions.map((s) => (
|
||||||
</option>
|
<option key={s.id} value={s.id}>
|
||||||
))}
|
{s.title || t('ai.untitledSession')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{archivedSessions.length > 0 && (
|
||||||
|
<optgroup label={t('ai.session.archived')}>
|
||||||
|
{archivedSessions.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.title || t('ai.untitledSession')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
|
<AiSessionMenu bookId={bookId} session={activeSession} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { AiSession } from '@shared/types'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||||
|
|
||||||
|
interface AiSessionMenuProps {
|
||||||
|
bookId: string
|
||||||
|
session: AiSession | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiSessionMenu({ bookId, session }: AiSessionMenuProps): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const loadSessions = useAiStore((s) => s.loadSessions)
|
||||||
|
const selectSession = useAiStore((s) => s.selectSession)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const close = (): void => {
|
||||||
|
setOpen(false)
|
||||||
|
setExportOpen(false)
|
||||||
|
}
|
||||||
|
window.addEventListener('click', close)
|
||||||
|
return () => window.removeEventListener('click', close)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!session) return null
|
||||||
|
|
||||||
|
const handleRename = async (): Promise<void> => {
|
||||||
|
setOpen(false)
|
||||||
|
const title = window.prompt(t('ai.session.renamePrompt'), session.title)
|
||||||
|
if (!title?.trim() || title.trim() === session.title) return
|
||||||
|
await ipcCall(() =>
|
||||||
|
window.electronAPI.ai.sessionUpdate(bookId, session.id, { title: title.trim() })
|
||||||
|
)
|
||||||
|
await loadSessions(bookId, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleArchiveToggle = async (): Promise<void> => {
|
||||||
|
setOpen(false)
|
||||||
|
await ipcCall(() =>
|
||||||
|
window.electronAPI.ai.sessionUpdate(bookId, session.id, { archived: !session.archived })
|
||||||
|
)
|
||||||
|
await loadSessions(bookId, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async (format: 'markdown' | 'txt'): Promise<void> => {
|
||||||
|
setOpen(false)
|
||||||
|
setExportOpen(false)
|
||||||
|
await ipcCall(() => window.electronAPI.ai.sessionExport(bookId, session.id, format))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (): Promise<void> => {
|
||||||
|
setOpen(false)
|
||||||
|
if (!window.confirm(t('ai.session.deleteConfirm', { title: session.title || t('ai.untitledSession') }))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await ipcCall(() => window.electronAPI.ai.sessionDelete(bookId, session.id))
|
||||||
|
await loadSessions(bookId, true)
|
||||||
|
const next = useAiStore.getState().sessions[0]
|
||||||
|
if (next) await selectSession(next.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ai-session-menu-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ai-session-menu-btn"
|
||||||
|
data-testid="ai-session-menu-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="context-menu ai-session-menu" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button type="button" onClick={() => void handleRename()}>
|
||||||
|
{t('ai.session.rename')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void handleArchiveToggle()}>
|
||||||
|
{session.archived ? t('ai.session.unarchive') : t('ai.session.archive')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setExportOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('ai.session.export')} ▾
|
||||||
|
</button>
|
||||||
|
{exportOpen && (
|
||||||
|
<div className="ai-session-export-submenu">
|
||||||
|
<button type="button" onClick={() => void handleExport('markdown')}>
|
||||||
|
{t('ai.session.exportMd')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void handleExport('txt')}>
|
||||||
|
{t('ai.session.exportTxt')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="danger" onClick={() => void handleDelete()}>
|
||||||
|
{t('ai.session.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string | null>(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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
if (!currentBookId) return
|
||||||
|
const updated = await ipcCall(() => window.electronAPI.book.pickCover(currentBookId))
|
||||||
|
if (updated) {
|
||||||
|
setCoverPath(updated.coverPath ?? null)
|
||||||
|
await loadBooks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (): Promise<void> => {
|
||||||
|
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 <div className="placeholder-box">{t('ai.noBook')}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="book-settings-tab" data-testid="book-settings-tab">
|
||||||
|
<h3 className="settings-subheading">{t('bookSettings.title')}</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t('dialog.newBook.name')}</label>
|
||||||
|
<input
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="book-settings-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t('dialog.newBook.category')}</label>
|
||||||
|
<select
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="book-settings-category"
|
||||||
|
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 className="form-label">{t('dialog.newBook.target')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="book-settings-target"
|
||||||
|
value={targetWordCount}
|
||||||
|
onChange={(e) => setTargetWordCount(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t('bookSettings.synopsis')}</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="book-settings-synopsis"
|
||||||
|
rows={4}
|
||||||
|
value={synopsis}
|
||||||
|
onChange={(e) => setSynopsis(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">{t('bookSettings.cover')}</label>
|
||||||
|
<div className="book-settings-cover-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
data-testid="book-settings-pick-cover"
|
||||||
|
onClick={() => void handlePickCover()}
|
||||||
|
>
|
||||||
|
{t('bookSettings.pickCover')}
|
||||||
|
</button>
|
||||||
|
<span className="book-settings-cover-path">
|
||||||
|
{coverPath ? coverPath.replace(/^.*[\\/]/, '') : t('bookSettings.noCover')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="book-settings-always-cockpit"
|
||||||
|
checked={alwaysShowCockpit}
|
||||||
|
onChange={(e) => void setAlwaysShowCockpit(currentBookId, e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('bookSettings.alwaysShowCockpit')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="book-settings-save"
|
||||||
|
disabled={!name.trim() || saving}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
{t('dialog.save')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="book-settings-danger" data-testid="book-settings-danger">
|
||||||
|
<h4>{t('bookSettings.dangerZone')}</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
data-testid="book-settings-delete"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
>
|
||||||
|
{t('bookSettings.deleteBook')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { Chapter, ChapterTag } from '@shared/types'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
|
||||||
|
interface ChapterMetaPanelProps {
|
||||||
|
open: boolean
|
||||||
|
chapter: Chapter | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChapterMetaPanel({
|
||||||
|
open,
|
||||||
|
chapter,
|
||||||
|
onClose
|
||||||
|
}: ChapterMetaPanelProps): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const currentBookId = useBookStore((s) => s.currentBookId)
|
||||||
|
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||||
|
|
||||||
|
const [summary, setSummary] = useState('')
|
||||||
|
const [storyTime, setStoryTime] = useState('')
|
||||||
|
const [wordCountThreshold, setWordCountThreshold] = useState('')
|
||||||
|
const [tags, setTags] = useState<ChapterTag[]>([])
|
||||||
|
const [newTag, setNewTag] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !chapter || !currentBookId) return
|
||||||
|
setSummary(chapter.summary ?? '')
|
||||||
|
setStoryTime(chapter.storyTime ?? '')
|
||||||
|
setWordCountThreshold(
|
||||||
|
chapter.wordCountThreshold != null ? String(chapter.wordCountThreshold) : ''
|
||||||
|
)
|
||||||
|
void ipcCall(() =>
|
||||||
|
window.electronAPI.chapterTag.list(currentBookId, chapter.id)
|
||||||
|
).then(setTags)
|
||||||
|
}, [open, chapter, currentBookId])
|
||||||
|
|
||||||
|
const handleSave = async (): Promise<void> => {
|
||||||
|
if (!currentBookId || !chapter) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const updated = await ipcCall(() =>
|
||||||
|
window.electronAPI.chapter.update({
|
||||||
|
bookId: currentBookId,
|
||||||
|
chapterId: chapter.id,
|
||||||
|
summary: summary.trim(),
|
||||||
|
storyTime: storyTime.trim() || null,
|
||||||
|
wordCountThreshold: wordCountThreshold ? Number(wordCountThreshold) : null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
updateChapterLocal(updated)
|
||||||
|
onClose()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddTag = async (): Promise<void> => {
|
||||||
|
const tag = newTag.trim()
|
||||||
|
if (!currentBookId || !chapter || !tag) return
|
||||||
|
await ipcCall(() =>
|
||||||
|
window.electronAPI.chapterTag.set(currentBookId, chapter.id, tag)
|
||||||
|
)
|
||||||
|
setTags((prev) => [...prev.filter((t) => t.tag !== tag), { chapterId: chapter.id, tag, color: '' }])
|
||||||
|
setNewTag('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveTag = async (tag: string): Promise<void> => {
|
||||||
|
if (!currentBookId || !chapter) return
|
||||||
|
await ipcCall(() =>
|
||||||
|
window.electronAPI.chapterTag.remove(currentBookId, chapter.id, tag)
|
||||||
|
)
|
||||||
|
setTags((prev) => prev.filter((t) => t.tag !== tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open || !chapter) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" role="dialog" aria-modal="true" data-testid="chapter-meta-panel">
|
||||||
|
<div className="modal-content chapter-meta-modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{t('chapterMeta.title')}</h3>
|
||||||
|
<button type="button" className="modal-close" onClick={onClose}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<label className="form-label">{t('chapterMeta.summary')}</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="chapter-meta-summary"
|
||||||
|
rows={3}
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="form-label">{t('chapterMeta.storyTime')}</label>
|
||||||
|
<input
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="chapter-meta-story-time"
|
||||||
|
value={storyTime}
|
||||||
|
onChange={(e) => setStoryTime(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="form-label">{t('chapterMeta.wordThreshold')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="chapter-meta-threshold"
|
||||||
|
value={wordCountThreshold}
|
||||||
|
onChange={(e) => setWordCountThreshold(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{chapter.publishStatus === 'published' && chapter.publishedAt && (
|
||||||
|
<p className="chapter-meta-published" data-testid="chapter-meta-published">
|
||||||
|
{t('chapterMeta.publishedAt', {
|
||||||
|
date: new Date(chapter.publishedAt).toLocaleString('zh-CN')
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="form-label">{t('chapterMeta.tags')}</label>
|
||||||
|
<div className="chapter-meta-tags">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span key={tag.tag} className="tag-chip" data-testid={`chapter-tag-${tag.tag}`}>
|
||||||
|
{tag.tag}
|
||||||
|
<button type="button" onClick={() => void handleRemoveTag(tag.tag)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="chapter-meta-tag-add">
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
data-testid="chapter-meta-tag-input"
|
||||||
|
value={newTag}
|
||||||
|
onChange={(e) => setNewTag(e.target.value)}
|
||||||
|
placeholder={t('chapterMeta.tagPlaceholder')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void handleAddTag()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => void handleAddTag()}>
|
||||||
|
{t('chapterMeta.addTag')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
{t('dialog.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="chapter-meta-save"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
{t('dialog.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { mergeChapterTemplates } from '@shared/builtin-templates'
|
import { mergeChapterTemplates } from '@shared/builtin-templates'
|
||||||
import {
|
import {
|
||||||
bodyPlainToChapterHtml,
|
bodyPlainToChapterHtml,
|
||||||
replaceChapterTemplate,
|
buildTemplateContext,
|
||||||
type TemplateContext
|
replaceChapterTemplate
|
||||||
} from '@shared/chapter-template'
|
} from '@shared/chapter-template'
|
||||||
import type { ChapterTemplate } from '@shared/types'
|
import type { ChapterTemplate } from '@shared/types'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
@@ -30,6 +30,9 @@ export function TemplateQuickCreateModal({
|
|||||||
activeVolumeId,
|
activeVolumeId,
|
||||||
volumes,
|
volumes,
|
||||||
chapters,
|
chapters,
|
||||||
|
outlines,
|
||||||
|
settings,
|
||||||
|
selectedChapterId,
|
||||||
refreshChapters,
|
refreshChapters,
|
||||||
setSelectedChapter
|
setSelectedChapter
|
||||||
} = useBookStore()
|
} = useBookStore()
|
||||||
@@ -52,33 +55,22 @@ export function TemplateQuickCreateModal({
|
|||||||
|
|
||||||
const selectedTemplate = templates.find((tpl) => tpl.id === selectedId) ?? templates[0]
|
const selectedTemplate = templates.find((tpl) => tpl.id === selectedId) ?? templates[0]
|
||||||
|
|
||||||
const buildContext = (title: string): TemplateContext => {
|
|
||||||
const volChapters = chapters
|
|
||||||
.filter((c) => c.volumeId === activeVolumeId)
|
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
||||||
const chapterNumber = volChapters.length + 1
|
|
||||||
const volumeName = volumes.find((v) => v.id === activeVolumeId)?.name ?? ''
|
|
||||||
const previousChapterTitle =
|
|
||||||
volChapters.length > 0 ? (volChapters[volChapters.length - 1]?.title ?? '') : ''
|
|
||||||
return {
|
|
||||||
chapterNumber,
|
|
||||||
chapterTitle: title,
|
|
||||||
penName,
|
|
||||||
date: new Date().toLocaleDateString('zh-CN'),
|
|
||||||
volumeName,
|
|
||||||
previousChapterTitle,
|
|
||||||
outlineItem: '',
|
|
||||||
characterList: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = async (): Promise<void> => {
|
const handleCreate = async (): Promise<void> => {
|
||||||
if (!currentBookId || !activeVolumeId || !selectedTemplate || !chapterTitle.trim()) return
|
if (!currentBookId || !activeVolumeId || !selectedTemplate || !chapterTitle.trim()) return
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
try {
|
try {
|
||||||
await flushEditorSave()
|
await flushEditorSave()
|
||||||
const title = chapterTitle.trim()
|
const title = chapterTitle.trim()
|
||||||
const ctx = buildContext(title)
|
const ctx = buildTemplateContext({
|
||||||
|
chapterTitle: title,
|
||||||
|
penName,
|
||||||
|
volumes,
|
||||||
|
chapters,
|
||||||
|
outlines,
|
||||||
|
settings,
|
||||||
|
activeVolumeId,
|
||||||
|
selectedChapterId
|
||||||
|
})
|
||||||
const bodyPlain = replaceChapterTemplate(selectedTemplate.body, ctx)
|
const bodyPlain = replaceChapterTemplate(selectedTemplate.body, ctx)
|
||||||
const contentHtml = bodyPlainToChapterHtml(bodyPlain)
|
const contentHtml = bodyPlainToChapterHtml(bodyPlain)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { ipcCall } from '@renderer/lib/ipc-client'
|
|||||||
import { SettingRelationshipsEditor } from '@renderer/components/setting/SettingRelationshipsEditor'
|
import { SettingRelationshipsEditor } from '@renderer/components/setting/SettingRelationshipsEditor'
|
||||||
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
|
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
|
||||||
import { SettingMention } from '@renderer/extensions/mention'
|
import { SettingMention } from '@renderer/extensions/mention'
|
||||||
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert } from '@renderer/lib/editor-commands'
|
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert, registerEditorGetText } from '@renderer/lib/editor-commands'
|
||||||
function countPlain(text: string): number {
|
function countPlain(text: string): number {
|
||||||
const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '')
|
const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '')
|
||||||
const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
|
const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
|
||||||
@@ -289,6 +289,15 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
|||||||
}
|
}
|
||||||
}, [editor])
|
}, [editor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) {
|
||||||
|
registerEditorGetText(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registerEditorGetText(() => editor.getText())
|
||||||
|
return () => registerEditorGetText(null)
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !bookId || !target || target.kind !== 'chapter') {
|
if (!editor || !bookId || !target || target.kind !== 'chapter') {
|
||||||
registerEditorInsert(null, () => {})
|
registerEditorInsert(null, () => {})
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
|
||||||
|
export function FocusModeOverlay(): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const focusMode = useAppStore((s) => s.focusMode)
|
||||||
|
const setFocusMode = useAppStore((s) => s.setFocusMode)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusMode) return
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') setFocusMode(false)
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [focusMode, setFocusMode])
|
||||||
|
|
||||||
|
if (!focusMode) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-mode-exit"
|
||||||
|
data-testid="focus-mode-exit"
|
||||||
|
onClick={() => setFocusMode(false)}
|
||||||
|
>
|
||||||
|
{t('focus.exit')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -59,7 +59,13 @@ export function HomePage(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
{t('home.importBook')}
|
{t('home.importBook')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-large"
|
||||||
|
data-testid="home-export-all"
|
||||||
|
disabled
|
||||||
|
title={t('home.exportAllWave2')}
|
||||||
|
>
|
||||||
{t('home.exportAll')}
|
{t('home.exportAll')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
|
|||||||
import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal'
|
import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal'
|
||||||
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
|
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
|
||||||
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
|
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
|
||||||
|
import { ChapterMetaPanel } from '@renderer/components/chapter/ChapterMetaPanel'
|
||||||
|
import { VolumeContextMenu } from '@renderer/components/volume/VolumeContextMenu'
|
||||||
import { ExportModal } from '@renderer/components/export/ExportModal'
|
import { ExportModal } from '@renderer/components/export/ExportModal'
|
||||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||||
import {
|
import {
|
||||||
@@ -121,6 +123,12 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
|
|
||||||
const [bridgeOpen, setBridgeOpen] = useState(false)
|
const [bridgeOpen, setBridgeOpen] = useState(false)
|
||||||
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
||||||
|
const [chapterMetaOpen, setChapterMetaOpen] = useState(false)
|
||||||
|
const [volumeMenu, setVolumeMenu] = useState<{ volumeId: string; x: number; y: number } | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const [renamingChapterId, setRenamingChapterId] = useState<string | null>(null)
|
||||||
|
const [renameDraft, setRenameDraft] = useState('')
|
||||||
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
|
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
|
||||||
|
|
||||||
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
||||||
@@ -237,6 +245,36 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
setActiveVolume(vol.id)
|
setActiveVolume(vol.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commitChapterRename = async (chapterId: string): Promise<void> => {
|
||||||
|
if (!currentBookId) return
|
||||||
|
const ch = chapters.find((c) => c.id === chapterId)
|
||||||
|
const title = renameDraft.trim()
|
||||||
|
setRenamingChapterId(null)
|
||||||
|
if (!ch || !title || title === ch.title) return
|
||||||
|
const updated = await ipcCall(() =>
|
||||||
|
window.electronAPI.chapter.update({ bookId: currentBookId, chapterId, title })
|
||||||
|
)
|
||||||
|
updateChapterLocal(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteChapter = async (chapterId: string, e: React.MouseEvent): Promise<void> => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!currentBookId) return
|
||||||
|
const ch = chapters.find((c) => c.id === chapterId)
|
||||||
|
if (!ch || !window.confirm(t('chapter.deleteConfirm', { title: ch.title }))) return
|
||||||
|
await flushEditorSave()
|
||||||
|
await ipcCall(() => window.electronAPI.chapter.delete(currentBookId, chapterId))
|
||||||
|
await refreshChapters()
|
||||||
|
if (selectedChapterId === chapterId) {
|
||||||
|
const remaining = chapters.filter((c) => c.id !== chapterId)
|
||||||
|
const next = remaining.find((c) => c.volumeId === ch.volumeId) ?? remaining[0]
|
||||||
|
if (next) {
|
||||||
|
setSelectedChapter(next.id)
|
||||||
|
await switchTarget({ kind: 'chapter', id: next.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [dragOverChapterId, setDragOverChapterId] = useState<string | null>(null)
|
const [dragOverChapterId, setDragOverChapterId] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleChapterDropOnItem = async (
|
const handleChapterDropOnItem = async (
|
||||||
@@ -309,6 +347,12 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
||||||
|
const chapterOverThreshold =
|
||||||
|
currentChapter?.wordCountThreshold != null &&
|
||||||
|
wordCount.chapter > currentChapter.wordCountThreshold
|
||||||
|
const volumeMenuVolume = volumeMenu
|
||||||
|
? volumes.find((v) => v.id === volumeMenu.volumeId)
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="editor-layout" data-book-id={currentBookId ?? ''}>
|
<div id="editor-layout" data-book-id={currentBookId ?? ''}>
|
||||||
@@ -337,6 +381,10 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
className="vol-header"
|
className="vol-header"
|
||||||
data-volume-id={vol.id}
|
data-volume-id={vol.id}
|
||||||
onClick={() => setActiveVolume(vol.id)}
|
onClick={() => setActiveVolume(vol.id)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setVolumeMenu({ volumeId: vol.id, x: e.clientX, y: e.clientY })
|
||||||
|
}}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -371,12 +419,44 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
if (draggedId) void handleChapterDropOnItem(draggedId, vol.id, ch.id)
|
if (draggedId) void handleChapterDropOnItem(draggedId, vol.id, ch.id)
|
||||||
}}
|
}}
|
||||||
onClick={() => void handleSelectChapter(ch.id)}
|
onClick={() => void handleSelectChapter(ch.id)}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setRenamingChapterId(ch.id)
|
||||||
|
setRenameDraft(ch.title)
|
||||||
|
}}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
|
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<span>{idx + 1}.</span>
|
<span>{idx + 1}.</span>
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
|
{renamingChapterId === ch.id ? (
|
||||||
|
<input
|
||||||
|
className="chapter-rename-input"
|
||||||
|
data-testid={`chapter-rename-${ch.id}`}
|
||||||
|
value={renameDraft}
|
||||||
|
autoFocus
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) => setRenameDraft(e.target.value)}
|
||||||
|
onBlur={() => void commitChapterRename(ch.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void commitChapterRename(ch.id)
|
||||||
|
if (e.key === 'Escape') setRenamingChapterId(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{ch.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ch-delete-btn"
|
||||||
|
title={t('chapter.delete')}
|
||||||
|
data-testid={`chapter-delete-${ch.id}`}
|
||||||
|
onClick={(e) => void handleDeleteChapter(ch.id, e)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ch-publish-btn"
|
className="ch-publish-btn"
|
||||||
@@ -560,7 +640,27 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm status-meta-btn"
|
||||||
|
data-testid="chapter-meta-open"
|
||||||
|
onClick={() => setChapterMetaOpen(true)}
|
||||||
|
>
|
||||||
|
{t('chapterMeta.open')}
|
||||||
|
</button>
|
||||||
|
{currentChapter?.publishStatus === 'published' && currentChapter.publishedAt && (
|
||||||
|
<span className="status-published-at" data-testid="status-published-at">
|
||||||
|
{t('chapterMeta.publishedAt', {
|
||||||
|
date: new Date(currentChapter.publishedAt).toLocaleString('zh-CN')
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={chapterOverThreshold ? 'status-word-over-threshold' : undefined}
|
||||||
|
data-testid="status-chapter-words"
|
||||||
|
>
|
||||||
|
{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}
|
||||||
|
</span>
|
||||||
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
|
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
|
||||||
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
|
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
|
||||||
</>
|
</>
|
||||||
@@ -598,6 +698,22 @@ export function EditorLayout(): React.JSX.Element {
|
|||||||
open={templateModalOpen}
|
open={templateModalOpen}
|
||||||
onClose={() => setTemplateModalOpen(false)}
|
onClose={() => setTemplateModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
<ChapterMetaPanel
|
||||||
|
open={chapterMetaOpen}
|
||||||
|
chapter={currentChapter}
|
||||||
|
onClose={() => setChapterMetaOpen(false)}
|
||||||
|
/>
|
||||||
|
{volumeMenu && volumeMenuVolume && currentBookId && (
|
||||||
|
<VolumeContextMenu
|
||||||
|
bookId={currentBookId}
|
||||||
|
volume={volumeMenuVolume}
|
||||||
|
hasChapters={chapters.some((c) => c.volumeId === volumeMenuVolume.id)}
|
||||||
|
x={volumeMenu.x}
|
||||||
|
y={volumeMenu.y}
|
||||||
|
onClose={() => setVolumeMenu(null)}
|
||||||
|
onChanged={refreshChapters}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ExportModal />
|
<ExportModal />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useWizardStore } from '@renderer/stores/useWizardStore'
|
|||||||
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
|
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
|
||||||
import { AiPanel } from '@renderer/components/ai/AiPanel'
|
import { AiPanel } from '@renderer/components/ai/AiPanel'
|
||||||
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
|
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
|
||||||
|
import { BookSettingsTab } from '@renderer/components/book/BookSettingsTab'
|
||||||
|
|
||||||
export function RightPanel(): React.JSX.Element {
|
export function RightPanel(): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -54,7 +55,7 @@ export function RightPanel(): React.JSX.Element {
|
|||||||
<WordFreqPanel />
|
<WordFreqPanel />
|
||||||
</div>
|
</div>
|
||||||
<div className={`panel-content ${rightPanel === 'book-settings' ? 'active' : ''}`}>
|
<div className={`panel-content ${rightPanel === 'book-settings' ? 'active' : ''}`}>
|
||||||
<div className="placeholder-box">{t('feature.comingSoon')}</div>
|
<BookSettingsTab />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
import { useTabStore } from '@renderer/stores/useTabStore'
|
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||||
|
import { useWritingStatsStore } from '@renderer/stores/useWritingStatsStore'
|
||||||
|
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||||
|
|
||||||
export function TopBar(): React.JSX.Element {
|
export function TopBar(): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const setView = useAppStore((s) => s.setView)
|
const setView = useAppStore((s) => s.setView)
|
||||||
const showToast = useAppStore((s) => s.showToast)
|
const focusMode = useAppStore((s) => s.focusMode)
|
||||||
|
const setFocusMode = useAppStore((s) => s.setFocusMode)
|
||||||
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
|
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
|
||||||
const currentBookId = useBookStore((s) => s.currentBookId)
|
const currentBookId = useBookStore((s) => s.currentBookId)
|
||||||
const openCockpit = useCockpitStore((s) => s.openModal)
|
const openCockpit = useCockpitStore((s) => s.openModal)
|
||||||
const loadCockpitSummary = useCockpitStore((s) => s.loadSummary)
|
const loadCockpitSummary = useCockpitStore((s) => s.loadSummary)
|
||||||
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
|
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
|
||||||
|
const writingStats = useWritingStatsStore((s) => s.stats)
|
||||||
|
const dailyWordGoal = useSettingsStore((s) => s.dailyWordGoal)
|
||||||
|
const goalProgress =
|
||||||
|
dailyWordGoal > 0 && writingStats ? Math.min(writingStats.goalProgress, 1) : 0
|
||||||
|
const progressClass =
|
||||||
|
goalProgress >= 1 ? 'goal-met' : goalProgress >= 0.8 ? 'goal-near' : ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentBookId) void useWritingStatsStore.getState().refresh(currentBookId)
|
||||||
|
}, [currentBookId])
|
||||||
|
|
||||||
const handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
|
const handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -63,6 +77,21 @@ export function TopBar(): React.JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{dailyWordGoal > 0 && currentBookId && (
|
||||||
|
<div
|
||||||
|
className={`top-daily-progress ${progressClass}`}
|
||||||
|
data-testid="top-daily-progress"
|
||||||
|
title={t('status.dailyGoal', {
|
||||||
|
current: writingStats?.todayWords ?? 0,
|
||||||
|
goal: dailyWordGoal
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="top-daily-progress-fill"
|
||||||
|
style={{ width: `${goalProgress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="top-spacer" />
|
<div className="top-spacer" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -81,7 +110,14 @@ export function TopBar(): React.JSX.Element {
|
|||||||
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
|
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
|
||||||
⚙
|
⚙
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="top-btn" onClick={() => showToast(t('feature.comingSoon'))}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="top-btn"
|
||||||
|
title={t('focus.toggle')}
|
||||||
|
data-testid="topbar-focus-mode"
|
||||||
|
disabled={!currentBookId}
|
||||||
|
onClick={() => setFocusMode(!focusMode)}
|
||||||
|
>
|
||||||
👁
|
👁
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="top-btn" onClick={() => window.electronAPI.window.minimize()}>—</button>
|
<button type="button" className="top-btn" onClick={() => window.electronAPI.window.minimize()}>—</button>
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
{section === 'shortcuts' && <ShortcutEditor />}
|
{section === 'shortcuts' && <ShortcutEditor />}
|
||||||
{section === 'about' && (
|
{section === 'about' && (
|
||||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
{t('app.name')} v1.1.0
|
{t('app.name')} v1.2.0
|
||||||
<br />
|
<br />
|
||||||
{t('app.tagline')}
|
{t('app.tagline')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { Volume } from '@shared/types'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
|
||||||
|
interface VolumeContextMenuProps {
|
||||||
|
bookId: string
|
||||||
|
volume: Volume
|
||||||
|
hasChapters: boolean
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
onClose: () => void
|
||||||
|
onChanged: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VolumeContextMenu({
|
||||||
|
bookId,
|
||||||
|
volume,
|
||||||
|
hasChapters,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
onChanged
|
||||||
|
}: VolumeContextMenuProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const close = (): void => onClose()
|
||||||
|
window.addEventListener('click', close)
|
||||||
|
return () => window.removeEventListener('click', close)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const handleRename = async (): Promise<void> => {
|
||||||
|
const name = window.prompt(t('volume.renamePrompt'), volume.name)
|
||||||
|
onClose()
|
||||||
|
if (!name?.trim() || name.trim() === volume.name) return
|
||||||
|
await ipcCall(() => window.electronAPI.volume.update(bookId, volume.id, { name: name.trim() }))
|
||||||
|
await onChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDescription = async (): Promise<void> => {
|
||||||
|
const description = window.prompt(t('volume.descriptionPrompt'), volume.description ?? '')
|
||||||
|
onClose()
|
||||||
|
if (description === null) return
|
||||||
|
await ipcCall(() =>
|
||||||
|
window.electronAPI.volume.update(bookId, volume.id, { description: description.trim() })
|
||||||
|
)
|
||||||
|
await onChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (): Promise<void> => {
|
||||||
|
onClose()
|
||||||
|
if (hasChapters) {
|
||||||
|
showToast(t('volume.deleteHasChapters'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!window.confirm(t('volume.deleteConfirm', { name: volume.name }))) return
|
||||||
|
await ipcCall(() => window.electronAPI.volume.delete(bookId, volume.id))
|
||||||
|
await onChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="context-menu"
|
||||||
|
data-testid="volume-context-menu"
|
||||||
|
style={{ left: x, top: y }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={() => void handleRename()}>
|
||||||
|
{t('volume.rename')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void handleDescription()}>
|
||||||
|
{t('volume.editDescription')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="danger" onClick={() => void handleDelete()}>
|
||||||
|
{t('volume.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { highlightTokenInEditor } from '@renderer/lib/editor-commands'
|
|||||||
|
|
||||||
export function WordFreqPanel(): React.JSX.Element {
|
export function WordFreqPanel(): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const showToast = useAppStore((s) => s.showToast)
|
const setNamingCheckOpen = useAppStore((s) => s.setNamingCheckOpen)
|
||||||
const bookId = useBookStore((s) => s.currentBookId)
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
const chapters = useBookStore((s) => s.chapters)
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
const target = useEditStore((s) => s.target)
|
const target = useEditStore((s) => s.target)
|
||||||
@@ -54,7 +54,8 @@ export function WordFreqPanel(): React.JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
style={{ marginTop: 12 }}
|
style={{ marginTop: 12 }}
|
||||||
onClick={() => showToast(t('feature.comingSoon'))}
|
data-testid="wordfreq-naming-check"
|
||||||
|
onClick={() => setNamingCheckOpen(true)}
|
||||||
>
|
>
|
||||||
{t('wordfreq.aiNaming')}
|
{t('wordfreq.aiNaming')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ let insertLandmarkFn: ((payload: LandmarkInsert) => void) | null = null
|
|||||||
let highlightTokenFn: ((token: string) => void) | null = null
|
let highlightTokenFn: ((token: string) => void) | null = null
|
||||||
let editorInsertContext: EditorInsertContext | null = null
|
let editorInsertContext: EditorInsertContext | null = null
|
||||||
let insertTextFn: ((text: string) => void) | null = null
|
let insertTextFn: ((text: string) => void) | null = null
|
||||||
|
let getEditorTextFn: (() => string) | null = null
|
||||||
|
|
||||||
export function registerInsertLandmark(fn: (payload: LandmarkInsert) => void): void {
|
export function registerInsertLandmark(fn: (payload: LandmarkInsert) => void): void {
|
||||||
insertLandmarkFn = fn
|
insertLandmarkFn = fn
|
||||||
@@ -30,6 +31,16 @@ export function registerEditorInsert(
|
|||||||
insertTextFn = insert
|
insertTextFn = insert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function registerEditorGetText(fn: (() => string) | null): void {
|
||||||
|
getEditorTextFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEditorTextForTts(): string {
|
||||||
|
const selection = window.getSelection()?.toString().trim()
|
||||||
|
if (selection) return selection
|
||||||
|
return getEditorTextFn?.() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
export function insertLandmarkInEditor(payload: LandmarkInsert): void {
|
export function insertLandmarkInEditor(payload: LandmarkInsert): void {
|
||||||
insertLandmarkFn?.(payload)
|
insertLandmarkFn?.(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export function speakText(text: string, lang = 'zh-CN'): void {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
const u = new SpeechSynthesisUtterance(trimmed)
|
||||||
|
u.lang = lang
|
||||||
|
window.speechSynthesis.cancel()
|
||||||
|
window.speechSynthesis.speak(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSpeaking(): void {
|
||||||
|
window.speechSynthesis.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSpeaking(): boolean {
|
||||||
|
return window.speechSynthesis.speaking
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ interface AiStore {
|
|||||||
listenersAttached: boolean
|
listenersAttached: boolean
|
||||||
mount: () => void
|
mount: () => void
|
||||||
unmount: () => void
|
unmount: () => void
|
||||||
loadSessions: (bookId: string) => Promise<void>
|
loadSessions: (bookId: string, includeArchived?: boolean) => Promise<void>
|
||||||
selectSession: (sessionId: string) => Promise<void>
|
selectSession: (sessionId: string) => Promise<void>
|
||||||
createSession: (bookId: string) => Promise<void>
|
createSession: (bookId: string) => Promise<void>
|
||||||
sendMessage: (content: string, prompt?: string) => Promise<void>
|
sendMessage: (content: string, prompt?: string) => Promise<void>
|
||||||
@@ -63,8 +63,10 @@ export const useAiStore = create<AiStore>((set, get) => ({
|
|||||||
set({ listenersAttached: false })
|
set({ listenersAttached: false })
|
||||||
},
|
},
|
||||||
|
|
||||||
loadSessions: async (bookId) => {
|
loadSessions: async (bookId, includeArchived = true) => {
|
||||||
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
|
const sessions = await ipcCall(() =>
|
||||||
|
window.electronAPI.ai.sessionList(bookId, includeArchived)
|
||||||
|
)
|
||||||
const activeSessionId = get().activeSessionId
|
const activeSessionId = get().activeSessionId
|
||||||
const stillActive = activeSessionId && sessions.some((s) => s.id === activeSessionId)
|
const stillActive = activeSessionId && sessions.some((s) => s.id === activeSessionId)
|
||||||
set({ sessions })
|
set({ sessions })
|
||||||
@@ -157,7 +159,7 @@ export const useAiStore = create<AiStore>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
await ipcCall(() => window.electronAPI.ai.chat(bookId, sessionId, trimmed, prompt))
|
await ipcCall(() => window.electronAPI.ai.chat(bookId, sessionId, trimmed, prompt))
|
||||||
const messages = await ipcCall(() => window.electronAPI.ai.messageList(bookId, sessionId))
|
const messages = await ipcCall(() => window.electronAPI.ai.messageList(bookId, sessionId))
|
||||||
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
|
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId, true))
|
||||||
set({ messages, sessions, streamingMessageId: null, streamingBuffer: '' })
|
set({ messages, sessions, streamingMessageId: null, streamingBuffer: '' })
|
||||||
} finally {
|
} finally {
|
||||||
set({ sending: false })
|
set({ sending: false })
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface AppState {
|
|||||||
landmarksModalOpen: boolean
|
landmarksModalOpen: boolean
|
||||||
inspirationModalOpen: boolean
|
inspirationModalOpen: boolean
|
||||||
importBookModalOpen: boolean
|
importBookModalOpen: boolean
|
||||||
|
focusMode: boolean
|
||||||
|
namingCheckOpen: boolean
|
||||||
toast: string | null
|
toast: string | null
|
||||||
setView: (view: AppView) => void
|
setView: (view: AppView) => void
|
||||||
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
|
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
|
||||||
@@ -18,6 +20,8 @@ interface AppState {
|
|||||||
setLandmarksModalOpen: (open: boolean) => void
|
setLandmarksModalOpen: (open: boolean) => void
|
||||||
setInspirationModalOpen: (open: boolean) => void
|
setInspirationModalOpen: (open: boolean) => void
|
||||||
setImportBookModalOpen: (open: boolean) => void
|
setImportBookModalOpen: (open: boolean) => void
|
||||||
|
setFocusMode: (on: boolean) => void
|
||||||
|
setNamingCheckOpen: (open: boolean) => void
|
||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
clearToast: () => void
|
clearToast: () => void
|
||||||
}
|
}
|
||||||
@@ -30,6 +34,8 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
landmarksModalOpen: false,
|
landmarksModalOpen: false,
|
||||||
inspirationModalOpen: false,
|
inspirationModalOpen: false,
|
||||||
importBookModalOpen: false,
|
importBookModalOpen: false,
|
||||||
|
focusMode: false,
|
||||||
|
namingCheckOpen: false,
|
||||||
toast: null,
|
toast: null,
|
||||||
setView: (view) => set({ view }),
|
setView: (view) => set({ view }),
|
||||||
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
|
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
|
||||||
@@ -38,6 +44,8 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
|
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
|
||||||
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
|
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
|
||||||
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
|
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
|
||||||
|
setFocusMode: (focusMode) => set({ focusMode }),
|
||||||
|
setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }),
|
||||||
showToast: (toast) => {
|
showToast: (toast) => {
|
||||||
set({ toast })
|
set({ toast })
|
||||||
setTimeout(() => set({ toast: null }), 2500)
|
setTimeout(() => set({ toast: null }), 2500)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
interface BookPrefsState {
|
||||||
|
alwaysShowCockpit: boolean
|
||||||
|
loadedBookId: string | null
|
||||||
|
load: (bookId: string) => Promise<void>
|
||||||
|
setAlwaysShowCockpit: (bookId: string, value: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBookPrefsStore = create<BookPrefsState>((set, get) => ({
|
||||||
|
alwaysShowCockpit: false,
|
||||||
|
loadedBookId: null,
|
||||||
|
load: async (bookId) => {
|
||||||
|
const value = await ipcCall(() =>
|
||||||
|
window.electronAPI.bookPrefs.get(bookId, 'alwaysShowCockpit')
|
||||||
|
)
|
||||||
|
set({ alwaysShowCockpit: value === 'true', loadedBookId: bookId })
|
||||||
|
},
|
||||||
|
setAlwaysShowCockpit: async (bookId, value) => {
|
||||||
|
await ipcCall(() =>
|
||||||
|
window.electronAPI.bookPrefs.set(bookId, 'alwaysShowCockpit', value ? 'true' : 'false')
|
||||||
|
)
|
||||||
|
if (get().loadedBookId === bookId) {
|
||||||
|
set({ alwaysShowCockpit: value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -67,6 +67,59 @@
|
|||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-daily-progress {
|
||||||
|
width: 72px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-daily-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-daily-progress.goal-near .top-daily-progress-fill {
|
||||||
|
background: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-daily-progress.goal-met .top-daily-progress-fill {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.focus-mode #left-sidebar,
|
||||||
|
#app.focus-mode #right-panel,
|
||||||
|
#app.focus-mode #reference-panel.open {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.focus-mode #topbar .tab-bar,
|
||||||
|
#app.focus-mode #topbar .top-btn:not([data-testid='topbar-focus-mode']) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-mode-exit {
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 10001;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-mode-exit:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.top-btn {
|
.top-btn {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -1070,6 +1123,171 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ch-delete-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 4px;
|
||||||
|
opacity: 0;
|
||||||
|
color: var(--red, #c44);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item:hover .ch-delete-btn {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ch-delete-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-rename-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
min-width: 140px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu button.danger {
|
||||||
|
color: var(--red, #c44);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-meta-modal .modal-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-meta-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-meta-tag-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-meta-published,
|
||||||
|
.status-published-at {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-word-over-threshold {
|
||||||
|
color: #e67e22;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-meta-btn {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-session-menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-session-menu-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-session-menu {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
top: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-session-export-submenu {
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-session-export-submenu button {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-settings-tab {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-settings-cover-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-settings-cover-path {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-settings-danger {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--red, #c44);
|
||||||
|
}
|
||||||
|
|
||||||
.status-stock-warning {
|
.status-stock-warning {
|
||||||
color: var(--warning, #e6a23c);
|
color: var(--warning, #e6a23c);
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Chapter, OutlineItem, SettingEntry, Volume } from './types'
|
||||||
|
|
||||||
export interface TemplateContext {
|
export interface TemplateContext {
|
||||||
chapterNumber: number
|
chapterNumber: number
|
||||||
chapterTitle: string
|
chapterTitle: string
|
||||||
@@ -20,6 +22,39 @@ const VARS = [
|
|||||||
'characterList'
|
'characterList'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export function buildTemplateContext(input: {
|
||||||
|
chapterTitle: string
|
||||||
|
penName: string
|
||||||
|
volumes: Volume[]
|
||||||
|
chapters: Chapter[]
|
||||||
|
outlines: OutlineItem[]
|
||||||
|
settings: SettingEntry[]
|
||||||
|
activeVolumeId: string | null
|
||||||
|
selectedChapterId?: string | null
|
||||||
|
}): TemplateContext {
|
||||||
|
const volChapters = input.chapters
|
||||||
|
.filter((c) => c.volumeId === input.activeVolumeId)
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
const chapterNumber = volChapters.length + 1
|
||||||
|
const outlineItem =
|
||||||
|
input.outlines.find((o) => o.chapterId === input.selectedChapterId)?.title ?? ''
|
||||||
|
const characterList = input.settings
|
||||||
|
.filter((s) => s.type === 'character')
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((s) => s.name)
|
||||||
|
.join('、')
|
||||||
|
return {
|
||||||
|
chapterNumber,
|
||||||
|
chapterTitle: input.chapterTitle,
|
||||||
|
penName: input.penName,
|
||||||
|
date: new Date().toLocaleDateString('zh-CN'),
|
||||||
|
volumeName: input.volumes.find((v) => v.id === input.activeVolumeId)?.name ?? '',
|
||||||
|
previousChapterTitle: volChapters.at(-1)?.title ?? '',
|
||||||
|
outlineItem,
|
||||||
|
characterList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function replaceChapterTemplate(body: string, ctx: TemplateContext): string {
|
export function replaceChapterTemplate(body: string, ctx: TemplateContext): string {
|
||||||
let out = body
|
let out = body
|
||||||
for (const key of VARS) {
|
for (const key of VARS) {
|
||||||
|
|||||||
Vendored
+18
-4
@@ -32,6 +32,8 @@ import type {
|
|||||||
Snapshot,
|
Snapshot,
|
||||||
SnapshotType,
|
SnapshotType,
|
||||||
UpdateChapterParams,
|
UpdateChapterParams,
|
||||||
|
UpdateBookMetaParams,
|
||||||
|
ChapterTag,
|
||||||
Volume,
|
Volume,
|
||||||
WordFreqResult,
|
WordFreqResult,
|
||||||
KnowledgeEntry,
|
KnowledgeEntry,
|
||||||
@@ -73,10 +75,12 @@ export interface ElectronAPI {
|
|||||||
create: (params: CreateBookParams) => Promise<IpcResult<BookMeta>>
|
create: (params: CreateBookParams) => Promise<IpcResult<BookMeta>>
|
||||||
delete: (bookId: string) => Promise<IpcResult<void>>
|
delete: (bookId: string) => Promise<IpcResult<void>>
|
||||||
open: (bookId: string) => Promise<IpcResult<BookOpenResult>>
|
open: (bookId: string) => Promise<IpcResult<BookOpenResult>>
|
||||||
updateMeta: (
|
updateMeta: (bookId: string, patch: UpdateBookMetaParams) => Promise<IpcResult<BookMeta>>
|
||||||
bookId: string,
|
pickCover: (bookId: string) => Promise<IpcResult<BookMeta | null>>
|
||||||
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
|
}
|
||||||
) => Promise<IpcResult<BookMeta>>
|
bookPrefs: {
|
||||||
|
get: (bookId: string, key: string) => Promise<IpcResult<string | null>>
|
||||||
|
set: (bookId: string, key: string, value: string) => Promise<IpcResult<void>>
|
||||||
}
|
}
|
||||||
volume: {
|
volume: {
|
||||||
create: (bookId: string, name: string) => Promise<IpcResult<Volume>>
|
create: (bookId: string, name: string) => Promise<IpcResult<Volume>>
|
||||||
@@ -105,6 +109,11 @@ export interface ElectronAPI {
|
|||||||
publishStatus: PublishStatus
|
publishStatus: PublishStatus
|
||||||
) => Promise<IpcResult<Chapter>>
|
) => Promise<IpcResult<Chapter>>
|
||||||
}
|
}
|
||||||
|
chapterTag: {
|
||||||
|
list: (bookId: string, chapterId: string) => Promise<IpcResult<ChapterTag[]>>
|
||||||
|
set: (bookId: string, chapterId: string, tag: string, color?: string) => Promise<IpcResult<void>>
|
||||||
|
remove: (bookId: string, chapterId: string, tag: string) => Promise<IpcResult<void>>
|
||||||
|
}
|
||||||
outline: {
|
outline: {
|
||||||
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
|
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
|
||||||
create: (
|
create: (
|
||||||
@@ -221,6 +230,11 @@ export interface ElectronAPI {
|
|||||||
patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }>
|
patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }>
|
||||||
) => Promise<IpcResult<AiSession>>
|
) => Promise<IpcResult<AiSession>>
|
||||||
sessionDelete: (bookId: string, sessionId: string) => Promise<IpcResult<void>>
|
sessionDelete: (bookId: string, sessionId: string) => Promise<IpcResult<void>>
|
||||||
|
sessionExport: (
|
||||||
|
bookId: string,
|
||||||
|
sessionId: string,
|
||||||
|
format?: 'markdown' | 'txt'
|
||||||
|
) => Promise<IpcResult<string | null>>
|
||||||
messageList: (bookId: string, sessionId: string) => Promise<IpcResult<AiMessage[]>>
|
messageList: (bookId: string, sessionId: string) => Promise<IpcResult<AiMessage[]>>
|
||||||
chat: (
|
chat: (
|
||||||
bookId: string,
|
bookId: string,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const IPC = {
|
|||||||
BOOK_DELETE: 'book:delete',
|
BOOK_DELETE: 'book:delete',
|
||||||
BOOK_OPEN: 'book:open',
|
BOOK_OPEN: 'book:open',
|
||||||
BOOK_UPDATE_META: 'book:updateMeta',
|
BOOK_UPDATE_META: 'book:updateMeta',
|
||||||
|
BOOK_PICK_COVER: 'book:pickCover',
|
||||||
VOLUME_CREATE: 'volume:create',
|
VOLUME_CREATE: 'volume:create',
|
||||||
VOLUME_UPDATE: 'volume:update',
|
VOLUME_UPDATE: 'volume:update',
|
||||||
VOLUME_DELETE: 'volume:delete',
|
VOLUME_DELETE: 'volume:delete',
|
||||||
@@ -143,5 +144,11 @@ export const IPC = {
|
|||||||
BRIDGE_GET: 'bridge:get',
|
BRIDGE_GET: 'bridge:get',
|
||||||
BRIDGE_DISMISS: 'bridge:dismiss',
|
BRIDGE_DISMISS: 'bridge:dismiss',
|
||||||
BRIDGE_SHOULD_PROMPT: 'bridge:shouldPrompt',
|
BRIDGE_SHOULD_PROMPT: 'bridge:shouldPrompt',
|
||||||
CHAPTER_SET_PUBLISH_STATUS: 'chapter:setPublishStatus'
|
CHAPTER_SET_PUBLISH_STATUS: 'chapter:setPublishStatus',
|
||||||
|
CHAPTER_TAG_LIST: 'chapter:tagList',
|
||||||
|
CHAPTER_TAG_SET: 'chapter:tagSet',
|
||||||
|
CHAPTER_TAG_REMOVE: 'chapter:tagRemove',
|
||||||
|
BOOK_PREFS_GET: 'book:prefsGet',
|
||||||
|
BOOK_PREFS_SET: 'book:prefsSet',
|
||||||
|
AI_SESSION_EXPORT: 'ai:sessionExport'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -301,6 +301,25 @@ export interface BookMeta {
|
|||||||
lastOpenedAt: string
|
lastOpenedAt: string
|
||||||
lastChapterId: string | null
|
lastChapterId: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
coverPath?: string | null
|
||||||
|
synopsis?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateBookMetaParams {
|
||||||
|
name?: string
|
||||||
|
category?: string
|
||||||
|
targetWordCount?: number | null
|
||||||
|
coverPath?: string | null
|
||||||
|
synopsis?: string
|
||||||
|
lastChapterId?: string | null
|
||||||
|
lastOpenedAt?: string
|
||||||
|
status?: BookStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterTag {
|
||||||
|
chapterId: string
|
||||||
|
tag: string
|
||||||
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBookParams {
|
export interface CreateBookParams {
|
||||||
@@ -331,6 +350,8 @@ export interface Chapter {
|
|||||||
povCharacterId?: string | null
|
povCharacterId?: string | null
|
||||||
summary?: string
|
summary?: string
|
||||||
storyTime?: string | null
|
storyTime?: string | null
|
||||||
|
publishedAt?: string | null
|
||||||
|
wordCountThreshold?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OutlineItem {
|
export interface OutlineItem {
|
||||||
@@ -422,6 +443,11 @@ export interface UpdateChapterParams {
|
|||||||
status?: ChapterStatus
|
status?: ChapterStatus
|
||||||
cursorOffset?: number
|
cursorOffset?: number
|
||||||
povCharacterId?: string | null
|
povCharacterId?: string | null
|
||||||
|
summary?: string
|
||||||
|
storyTime?: string | null
|
||||||
|
wordCountThreshold?: number | null
|
||||||
|
publishStatus?: PublishStatus
|
||||||
|
publishedAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AiBackend = 'lmstudio' | 'openai' | 'anthropic'
|
export type AiBackend = 'lmstudio' | 'openai' | 'anthropic'
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { openBookDb, closeAllBookDbs } from '../../src/main/db/connection'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||||
|
import { ChapterTagRepository } from '../../src/main/db/repositories/chapter-tag.repo'
|
||||||
|
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||||
|
|
||||||
|
describe('ChapterTagRepository', () => {
|
||||||
|
let dir: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'bilin-tag-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeAllBookDbs()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IT-TAG-01: set and list chapter tags', () => {
|
||||||
|
const db = openBookDb(join(dir, 'test.sqlite'))
|
||||||
|
migrate(db)
|
||||||
|
const volId = new VolumeRepository(db).create('第一卷', 0).id
|
||||||
|
const chId = new ChapterRepository(db).create(volId, '第一章', 0).id
|
||||||
|
const tags = new ChapterTagRepository(db)
|
||||||
|
tags.set(chId, '高潮', '#ff0000')
|
||||||
|
tags.set(chId, '女主主场')
|
||||||
|
expect(tags.list(chId)).toHaveLength(2)
|
||||||
|
tags.remove(chId, '高潮')
|
||||||
|
expect(tags.list(chId)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { buildTemplateContext } from '../../src/shared/chapter-template'
|
||||||
|
import type { Chapter, OutlineItem, SettingEntry, Volume } from '../../src/shared/types'
|
||||||
|
|
||||||
|
const volumeId = 'vol-1'
|
||||||
|
const chapterId = 'ch-1'
|
||||||
|
|
||||||
|
const volumes: Volume[] = [
|
||||||
|
{ id: volumeId, name: '第一卷', description: '', sortOrder: 0, createdAt: '', updatedAt: '' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const chapters: Chapter[] = [
|
||||||
|
{
|
||||||
|
id: 'ch-prev',
|
||||||
|
volumeId,
|
||||||
|
title: '序章',
|
||||||
|
content: '',
|
||||||
|
status: 'draft',
|
||||||
|
publishStatus: 'draft',
|
||||||
|
sortOrder: 0,
|
||||||
|
wordCount: 0,
|
||||||
|
cursorOffset: 0,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('buildTemplateContext', () => {
|
||||||
|
it('UT-TEMPL-05: characterList from settings', () => {
|
||||||
|
const settings: SettingEntry[] = [
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
type: 'character',
|
||||||
|
name: '林远',
|
||||||
|
description: '',
|
||||||
|
properties: {},
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2',
|
||||||
|
type: 'character',
|
||||||
|
name: '苏晴',
|
||||||
|
description: '',
|
||||||
|
properties: {},
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const ctx = buildTemplateContext({
|
||||||
|
chapterTitle: '新章',
|
||||||
|
penName: '笔名',
|
||||||
|
volumes,
|
||||||
|
chapters,
|
||||||
|
outlines: [],
|
||||||
|
settings,
|
||||||
|
activeVolumeId: volumeId
|
||||||
|
})
|
||||||
|
expect(ctx.characterList).toContain('林远')
|
||||||
|
expect(ctx.characterList).toContain('苏晴')
|
||||||
|
expect(ctx.chapterNumber).toBe(2)
|
||||||
|
expect(ctx.previousChapterTitle).toBe('序章')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UT-TEMPL-06: outlineItem from linked outline', () => {
|
||||||
|
const outlines: OutlineItem[] = [
|
||||||
|
{
|
||||||
|
id: 'o1',
|
||||||
|
parentId: null,
|
||||||
|
title: '主角觉醒',
|
||||||
|
description: '',
|
||||||
|
status: 'planned',
|
||||||
|
expectedWordCount: null,
|
||||||
|
chapterId,
|
||||||
|
sortOrder: 0,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const ctx = buildTemplateContext({
|
||||||
|
chapterTitle: '第一章',
|
||||||
|
penName: '笔名',
|
||||||
|
volumes,
|
||||||
|
chapters,
|
||||||
|
outlines,
|
||||||
|
settings: [],
|
||||||
|
activeVolumeId: volumeId,
|
||||||
|
selectedChapterId: chapterId
|
||||||
|
})
|
||||||
|
expect(ctx.outlineItem).toBe('主角觉醒')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,7 +30,7 @@ describe('migrate v2', () => {
|
|||||||
expect(tableExists(db, 'search_fts')).toBe(true)
|
expect(tableExists(db, 'search_fts')).toBe(true)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(8)
|
expect(version.v).toBe(9)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates existing v1 database to v2', () => {
|
it('migrates existing v1 database to v2', () => {
|
||||||
@@ -47,7 +47,7 @@ describe('migrate v2', () => {
|
|||||||
|
|
||||||
expect(tableExists(db, 'outline_items')).toBe(true)
|
expect(tableExists(db, 'outline_items')).toBe(true)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(8)
|
expect(version.v).toBe(9)
|
||||||
|
|
||||||
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||||
const colNames = cols.map((c) => c.name)
|
const colNames = cols.map((c) => c.name)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('migrate v3', () => {
|
|||||||
expect(tableExists(db, 'ai_messages')).toBe(true)
|
expect(tableExists(db, 'ai_messages')).toBe(true)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(8)
|
expect(version.v).toBe(9)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('migrates existing v2 database to v3', () => {
|
it('migrates existing v2 database to v3', () => {
|
||||||
@@ -49,6 +49,6 @@ describe('migrate v3', () => {
|
|||||||
expect(tables.map((t) => t.name)).toContain('ai_messages')
|
expect(tables.map((t) => t.name)).toContain('ai_messages')
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(8)
|
expect(version.v).toBe(9)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe('migrate v4', () => {
|
|||||||
migrate(db)
|
migrate(db)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(8)
|
expect(version.v).toBe(9)
|
||||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||||
expect(tableExists(db, 'interactive_scenes')).toBe(true)
|
expect(tableExists(db, 'interactive_scenes')).toBe(true)
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ describe('migrate v4', () => {
|
|||||||
migrate(db)
|
migrate(db)
|
||||||
|
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(8)
|
expect(version.v).toBe(9)
|
||||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe('migrate v5', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(8)
|
expect(version.v).toBe(9)
|
||||||
const cols = db.prepare('PRAGMA table_info(interactive_flows)').all() as { name: string }[]
|
const cols = db.prepare('PRAGMA table_info(interactive_flows)').all() as { name: string }[]
|
||||||
expect(cols.some((c) => c.name === 'flow_mode')).toBe(true)
|
expect(cols.some((c) => c.name === 'flow_mode')).toBe(true)
|
||||||
expect(cols.some((c) => c.name === 'mode_config_json')).toBe(true)
|
expect(cols.some((c) => c.name === 'mode_config_json')).toBe(true)
|
||||||
@@ -39,6 +39,6 @@ describe('migrate v5', () => {
|
|||||||
db.prepare('INSERT INTO schema_version (version) VALUES (4)').run()
|
db.prepare('INSERT INTO schema_version (version) VALUES (4)').run()
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(8)
|
expect(version.v).toBe(9)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('migrate v6', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(8)
|
expect(version.v).toBe(9)
|
||||||
const tables = db
|
const tables = db
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
.all() as { name: string }[]
|
.all() as { name: string }[]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('migrate v7', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(8)
|
expect(version.v).toBe(9)
|
||||||
const tables = db
|
const tables = db
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'")
|
||||||
.get()
|
.get()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('migrate v8', () => {
|
|||||||
db = new DatabaseSync(':memory:')
|
db = new DatabaseSync(':memory:')
|
||||||
migrate(db)
|
migrate(db)
|
||||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
expect(version.v).toBe(8)
|
expect(version.v).toBe(9)
|
||||||
const table = db
|
const table = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge_injection_logs'"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge_injection_logs'"
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
import { migrate } from '../../src/main/db/migrate'
|
||||||
|
import type { SqliteDb } from '../../src/main/db/types'
|
||||||
|
|
||||||
|
describe('migrate v9', () => {
|
||||||
|
let db: SqliteDb
|
||||||
|
afterEach(() => db?.close())
|
||||||
|
|
||||||
|
it('UT-MIG-09: fresh database migrates to v9 with chapter_tags', () => {
|
||||||
|
db = new DatabaseSync(':memory:')
|
||||||
|
migrate(db)
|
||||||
|
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||||
|
expect(version.v).toBe(9)
|
||||||
|
const table = db
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_tags'")
|
||||||
|
.get()
|
||||||
|
expect(table).toBeTruthy()
|
||||||
|
const chapterCols = db.prepare('PRAGMA table_info(chapters)').all() as Array<{ name: string }>
|
||||||
|
expect(chapterCols.some((c) => c.name === 'published_at')).toBe(true)
|
||||||
|
expect(chapterCols.some((c) => c.name === 'word_count_threshold')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user