diff --git a/docs/superpowers/plans/2026-07-08-bilin-v11-writer-toolkit.md b/docs/superpowers/plans/2026-07-08-bilin-v11-writer-toolkit.md new file mode 100644 index 0000000..629c8de --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-bilin-v11-writer-toolkit.md @@ -0,0 +1,663 @@ +# 笔临 v1.1 作家日更工具包 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 交付 v1.1.0:§10 章节模板 + 快速新建、§11.1 txt/md/docx 导入、§20.2 投稿预设单章导出、§20.6 全局灵感捕获增强。 + +**Architecture:** `GlobalSettings` 扩展 `chapterTemplates` / `submissionPresets`;渲染进程 `chapter-template.ts` 做变量替换后复用 `chapter.create/update`;主进程 `ImportService`(mammoth/marked + `chapter-splitter`)与 `ExportService`(`submission-formatter` + clipboard);灵感模态解除视图限制且保存不跳转。 + +**Tech Stack:** Electron 36、electron-vite、React 18、TypeScript、node:sqlite、mammoth、marked、Zustand、i18next、Vitest、Playwright + +**Spec:** `docs/superpowers/specs/2026-07-08-bilin-v11-writer-toolkit-design.md` + +--- + +## File Map + +| 文件 | 职责 | +|------|------| +| `src/shared/types.ts` | `ChapterTemplate`、`SubmissionPreset`、`Import*`、`Export*` DTO | +| `src/shared/builtin-templates.ts` | 内置模板与投稿预设常量 | +| `src/shared/ipc-channels.ts` | `import.*` / `export.*` / `IMPORT_PROGRESS` | +| `src/main/lib/chapter-splitter.ts` | txt/md/html 分章 | +| `src/main/lib/submission-formatter.ts` | HTML → 投稿纯文本 | +| `src/main/services/import.service.ts` | 读文件、预览、创建书+章 | +| `src/main/services/export.service.ts` | 格式化章节、剪贴板、存盘 | +| `src/main/ipc/handlers/import.handler.ts` | 导入 IPC + progress 事件 | +| `src/main/ipc/handlers/export.handler.ts` | 导出 IPC | +| `src/main/ipc/register.ts` | 注册 import/export | +| `src/preload/index.ts` + `electron-api.d.ts` | import / export API | +| `src/renderer/lib/chapter-template.ts` | 模板变量替换 | +| `src/renderer/components/chapter/TemplateQuickCreateModal.tsx` | 快速新建章 | +| `src/renderer/components/settings/TemplateSettingsTab.tsx` | 模板 CRUD | +| `src/renderer/components/settings/SubmissionPresetTab.tsx` | 投稿预设 CRUD | +| `src/renderer/components/import/ImportBookModal.tsx` | 导入 UI | +| `src/renderer/components/export/ExportModal.tsx` | 导出 UI | +| `src/renderer/components/inspiration/InspirationModal.tsx` | 全局灵感增强 | +| `src/renderer/components/home/HomePage.tsx` | 导入入口 | +| `src/renderer/components/layout/EditorLayout.tsx` | 快速新建/导出入口 | +| `src/renderer/App.tsx` | 全局灵感快捷键 | +| `src/renderer/stores/useSettingsStore.ts` | 新 settings 字段默认值 | +| `src/main/services/global-settings.ts` | defaults 合并 | +| `public/locales/*/translation.json` | template / import / export / submission / inspiration | +| `src/renderer/styles/layout.css` | 模板/导入/导出样式 | +| `tests/main/chapter-template.test.ts` | UT-TEMPL-* | +| `tests/main/chapter-splitter.test.ts` | UT-IMPORT-01 | +| `tests/main/submission-formatter.test.ts` | UT-EXPORT-* | +| `tests/main/import.service.test.ts` | IT-IMPORT-01 | +| `e2e/chapter-template.spec.ts` | E2E-TEMPL-01 | +| `e2e/import-book.spec.ts` | E2E-IMPORT-01 | +| `e2e/export-submission.spec.ts` | E2E-EXPORT-01 | +| `e2e/inspiration-capture.spec.ts` | E2E-INSP-01 | + +--- + +## Task 1: 类型 + 内置常量 + GlobalSettings + +**Files:** +- Modify: `src/shared/types.ts` +- Create: `src/shared/builtin-templates.ts` +- Modify: `src/main/services/global-settings.ts` +- Modify: `src/renderer/stores/useSettingsStore.ts` + +- [ ] **Step 1: 扩展 types** + +```typescript +export interface ChapterTemplate { + id: string + name: string + body: string + defaultStatus?: ChapterStatus + builtin?: boolean +} + +export interface SubmissionPreset { + id: string + name: string + titleFormat: string + indentParagraphs: boolean + includeAuthorsNote: boolean + paragraphGap: 'single' | 'double' +} + +export type ImportSplitMode = 'auto' | 'paragraph' | 'regex' + +export interface ImportChapterPreview { + title: string + contentHtml: string + wordCount: number +} + +export interface ImportPreviewResult { + fileName: string + fileSizeBytes: number + chapters: ImportChapterPreview[] + warnings: string[] +} + +export interface ImportExecuteParams { + filePath: string + bookName: string + category: string + splitMode: ImportSplitMode + customRegex?: string +} + +// GlobalSettings 追加: +chapterTemplates?: ChapterTemplate[] +submissionPresets?: SubmissionPreset[] +defaultSubmissionPresetId?: string +``` + +- [ ] **Step 2: builtin-templates.ts** + +```typescript +import type { ChapterTemplate, SubmissionPreset } from './types' + +export const BUILTIN_CHAPTER_TEMPLATES: ChapterTemplate[] = [ + { + id: 'builtin-generic', + name: '通用模板', + body: '## 第{chapterNumber}章 {chapterTitle}\n\n', + builtin: true + }, + { + id: 'builtin-webnovel', + name: '网文常用', + body: '## 第{chapterNumber}章 {chapterTitle}\n\n\n\n——\n作者的话:\n', + builtin: true + } +] + +export const BUILTIN_SUBMISSION_PRESETS: SubmissionPreset[] = [ + { + id: 'builtin-webnovel', + name: '通用网文', + titleFormat: '# 第{chapterNumber}章 {chapterTitle}', + indentParagraphs: true, + includeAuthorsNote: true, + paragraphGap: 'double' + } +] + +export function mergeChapterTemplates(custom: ChapterTemplate[] = []): ChapterTemplate[] { + return [...BUILTIN_CHAPTER_TEMPLATES, ...custom.filter((t) => !t.builtin)] +} + +export function mergeSubmissionPresets(custom: SubmissionPreset[] = []): SubmissionPreset[] { + return [...BUILTIN_SUBMISSION_PRESETS, ...custom.filter((p) => p.id !== 'builtin-webnovel')] +} +``` + +- [ ] **Step 3: global-settings defaults** + +在 `defaults()` 追加 `chapterTemplates: []`, `submissionPresets: []`, `defaultSubmissionPresetId: 'builtin-webnovel'`。 + +- [ ] **Step 4: useSettingsStore 初始字段对齐** + +- [ ] **Step 5: build** + +```bash +npm run build +``` + +--- + +## Task 2: chapter-template 引擎 + 单元测试 + +**Files:** +- Create: `src/renderer/lib/chapter-template.ts` +- Create: `tests/main/chapter-template.test.ts` + +- [ ] **Step 1: 实现 replaceChapterTemplate** + +```typescript +export interface TemplateContext { + chapterNumber: number + chapterTitle: string + penName: string + date: string + volumeName: string + previousChapterTitle: string + outlineItem: string + characterList: string +} + +const VARS = [ + 'chapterNumber', 'chapterTitle', 'penName', 'date', + 'volumeName', 'previousChapterTitle', 'outlineItem', 'characterList' +] as const + +export function replaceChapterTemplate(body: string, ctx: TemplateContext): string { + let out = body + for (const key of VARS) { + out = out.split(`{${key}}`).join(String(ctx[key])) + } + return out +} + +export function plainTextToEditorHtml(text: string): string { + return text + .split(/\n{2,}/) + .map((p) => `

${p.replace(/\n/g, '
')}

`) + .join('') +} +``` + +- [ ] **Step 2: UT-TEMPL-01~03** + +```typescript +it('UT-TEMPL-01: replaces chapterNumber and chapterTitle', () => { + const result = replaceChapterTemplate('第{chapterNumber}章 {chapterTitle}', { + chapterNumber: 5, chapterTitle: '觉醒', + penName: '', date: '', volumeName: '', previousChapterTitle: '', + outlineItem: '', characterList: '' + }) + expect(result).toBe('第5章 觉醒') +}) +``` + +- [ ] **Step 3: 运行** + +```bash +npm run test -- tests/main/chapter-template.test.ts +``` + +--- + +## Task 3: TemplateQuickCreateModal + 章节侧栏入口 + +**Files:** +- Create: `src/renderer/components/chapter/TemplateQuickCreateModal.tsx` +- Modify: `src/renderer/components/layout/EditorLayout.tsx` + +- [ ] **Step 1: TemplateQuickCreateModal** + +- `data-testid="chapter-quick-new"` 打开模态 +- 从 `useSettingsStore` + `mergeChapterTemplates` 展示模板卡片 +- 创建流程:`chapter.create` → `replaceChapterTemplate` → `plainTextToEditorHtml` → `chapter.update({ content })` +- 若模板 `defaultStatus` 设置则 `chapter.update({ status })` +- `switchTarget` 到新章 + +- [ ] **Step 2: EditorLayout** + +在章节面板底部保留原「+ 新章」按钮,新增「模板新建」按钮 `data-testid="chapter-quick-new"`。 + +- [ ] **Step 3: build** + +```bash +npm run build +``` + +--- + +## Task 4: 设置页章节模板 Tab + +**Files:** +- Create: `src/renderer/components/settings/TemplateSettingsTab.tsx` +- Modify: `src/renderer/components/settings/SettingsPage.tsx` + +- [ ] **Step 1: TemplateSettingsTab** + +- `data-testid="settings-chapter-templates"` +- 内置模板只读展示 +- 自定义:`setting-template-add` 新建;编辑 name/body;删除(uuid id) +- 保存:`settings.update({ chapterTemplates: customOnly })` + +- [ ] **Step 2: SettingsPage 导航** + +新增 section `'templates'`,侧栏项 `settings.templates`。 + +- [ ] **Step 3: build** + +```bash +npm run build +``` + +--- + +## Task 5: submission-formatter + 单元测试 + +**Files:** +- Create: `src/main/lib/submission-formatter.ts` +- Create: `tests/main/submission-formatter.test.ts` + +- [ ] **Step 1: 实现** + +```typescript +import { stripHtml } from '../services/word-count' +import type { SubmissionPreset } from '../../shared/types' + +export interface FormatChapterInput { + chapterNumber: number + chapterTitle: string + contentHtml: string + preset: SubmissionPreset +} + +export function formatChapterForSubmission(input: FormatChapterInput): string { + let body = stripHtml(input.contentHtml) + if (!input.preset.includeAuthorsNote) { + body = body.split(/\n——\n/)[0] ?? body + } + const paragraphs = body.split(/\n+/).filter((p) => p.trim()) + const indented = input.preset.indentParagraphs + ? paragraphs.map((p) => `  ${p.trim()}`) + : paragraphs.map((p) => p.trim()) + const gap = input.preset.paragraphGap === 'double' ? '\n\n' : '\n' + const title = input.preset.titleFormat + .replace('{chapterNumber}', String(input.chapterNumber)) + .replace('{chapterTitle}', input.chapterTitle) + return `${title}\n\n${indented.join(gap)}` +} +``` + +- [ ] **Step 2: UT-EXPORT-01 / UT-EXPORT-02** + +- [ ] **Step 3: 运行** + +```bash +npm run test -- tests/main/submission-formatter.test.ts +``` + +--- + +## Task 6: ExportService + IPC + preload + +**Files:** +- Create: `src/main/services/export.service.ts` +- Create: `src/main/ipc/handlers/export.handler.ts` +- Modify: `src/shared/ipc-channels.ts` +- Modify: `src/main/ipc/register.ts` +- Modify: `src/preload/index.ts` +- Modify: `src/shared/electron-api.d.ts` + +- [ ] **Step 1: ExportService** + +```typescript +export class ExportService { + constructor( + private registry: BookRegistryService, + private settings: GlobalSettingsService + ) {} + + formatChapter(bookId: string, chapterId: string, presetId: string): string { + const db = this.registry.getDb(bookId) + const chapter = new ChapterRepository(db).get(chapterId) + if (!chapter) throw new Error('Chapter not found') + const presets = mergeSubmissionPresets(this.settings.get().submissionPresets) + const preset = presets.find((p) => p.id === presetId) ?? BUILTIN_SUBMISSION_PRESETS[0] + const chapters = new ChapterRepository(db).list().filter((c) => c.volumeId === chapter.volumeId) + const chapterNumber = chapters.findIndex((c) => c.id === chapterId) + 1 + return formatChapterForSubmission({ + chapterNumber, chapterTitle: chapter.title, + contentHtml: chapter.content, preset + }) + } + + copyToClipboard(text: string): void { + clipboard.writeText(text) + } + + async saveTxt(text: string, defaultName: string): Promise { + const { canceled, filePath } = await dialog.showSaveDialog({ + defaultPath: defaultName, + filters: [{ name: 'Text', extensions: ['txt'] }] + }) + if (canceled || !filePath) return null + writeFileSync(filePath, text, 'utf8') + return filePath + } +} +``` + +- [ ] **Step 2: export.handler + register + preload** + +```typescript +EXPORT_FORMAT_CHAPTER: 'export:formatChapter', +EXPORT_COPY_CLIPBOARD: 'export:copyClipboard', +EXPORT_SAVE_TXT: 'export:saveTxt', +``` + +- [ ] **Step 3: build** + +```bash +npm run build +``` + +--- + +## Task 7: ExportModal + 投稿预设设置 Tab + 快捷键 + +**Files:** +- Create: `src/renderer/components/export/ExportModal.tsx` +- Create: `src/renderer/components/settings/SubmissionPresetTab.tsx` +- Modify: `src/renderer/components/settings/SettingsPage.tsx` +- Modify: `src/renderer/components/layout/EditorLayout.tsx` +- Modify: `src/renderer/App.tsx` + +- [ ] **Step 1: ExportModal** + +- `data-testid="export-modal"` +- `export-preset-select` 下拉(mergeSubmissionPresets) +- 预览区:调用 `export.formatChapter` 显示前 500 字 +- `export-copy-clipboard` / `export-save-txt` + +- [ ] **Step 2: SubmissionPresetTab** + +- `data-testid="settings-submission-presets"` +- CRUD 自定义预设(不可删 builtin-webnovel) + +- [ ] **Step 3: EditorLayout 工具栏 + App exportBook 快捷键** + +```typescript +if (action === 'exportBook') { + if (view !== 'editor' || !chapterId) return + useExportStore.getState().openModal() + return +} +``` + +创建 `useExportStore`(open/close,类似 useGraphStore)。 + +- [ ] **Step 4: build** + +```bash +npm run build +``` + +--- + +## Task 8: InspirationModal 全局增强 + +**Files:** +- Modify: `src/renderer/components/inspiration/InspirationModal.tsx` +- Modify: `src/renderer/App.tsx` +- Modify: `src/renderer/styles/layout.css` + +- [ ] **Step 1: App.tsx 移除 view 限制** + +```typescript +if (action === 'captureInspiration') { + useAppStore.getState().setInspirationModalOpen(true) + return +} +``` + +- [ ] **Step 2: InspirationModal** + +- 无 `bookId` 时显示 `inspiration-book-select`(`useBookStore.books` 按 `lastOpenedAt` 排序) +- `handleSave` 删除 `switchTarget` / `setSidebarPanel`;改为 `showToast(t('inspiration.saved'))` +- 添加 `inspiration-modal--capture` class + +- [ ] **Step 3: E2E 预备 build** + +```bash +npm run build +``` + +--- + +## Task 9: chapter-splitter + 单元测试 + +**Files:** +- Create: `src/main/lib/chapter-splitter.ts` +- Create: `tests/main/chapter-splitter.test.ts` + +- [ ] **Step 1: splitPlainText** + +```typescript +export function splitPlainText( + text: string, + mode: ImportSplitMode, + customRegex?: string +): Array<{ title: string; body: string }> { + // auto: 尝试 MARKDOWN_HEADING 再 CHINESE_CHAPTER + // paragraph: split \n\s*\n + // regex: new RegExp(customRegex, 'gm') 分行匹配 +} +``` + +- [ ] **Step 2: UT-IMPORT-01** — 样本文本 3 章标题 + +- [ ] **Step 3: 运行** + +```bash +npm run test -- tests/main/chapter-splitter.test.ts +``` + +--- + +## Task 10: ImportService + IPC + 依赖安装 + +**Files:** +- Create: `src/main/services/import.service.ts` +- Create: `src/main/ipc/handlers/import.handler.ts` +- Modify: `package.json` +- Modify: `src/main/ipc/register.ts` +- Modify: `src/preload/index.ts` +- Create: `tests/main/import.service.test.ts` + +- [ ] **Step 1: 安装依赖** + +```bash +npm install mammoth marked --legacy-peer-deps +npm install -D @types/marked --legacy-peer-deps +``` + +- [ ] **Step 2: ImportService** + +```typescript +export class ImportService { + async pickFile(): Promise<{ canceled: boolean; filePath?: string }> { /* dialog */ } + + async preview(filePath: string, splitMode: ImportSplitMode, customRegex?: string): Promise { + const stat = statSync(filePath) + const ext = extname(filePath).toLowerCase() + let html: string + if (ext === '.docx') html = (await mammoth.convertToHtml({ path: filePath })).value + else if (ext === '.md') html = marked.parse(readFileSync(filePath, 'utf8')) as string + else html = plainTextToEditorHtml(readFileSync(filePath, 'utf8')) + const plain = stripHtml(html) + const parts = splitPlainText(plain, splitMode, customRegex) + // map to ImportChapterPreview with wordCount + } + + async execute(params: ImportExecuteParams, onProgress?: (c: number, t: number) => void): Promise { + const preview = await this.preview(...) + const meta = this.registry.create({ name: params.bookName, category: params.category, createSampleChapter: false }) + try { + const db = this.registry.getDb(meta.id) + const volId = new VolumeRepository(db).list()[0]?.id ?? new VolumeRepository(db).create('第一卷', 0).id + for (let i = 0; i < preview.chapters.length; i++) { + const ch = preview.chapters[i] + new ChapterRepository(db).create(volId, ch.title, i, ch.contentHtml) + onProgress?.(i + 1, preview.chapters.length) + } + return meta.id + } catch (e) { + this.registry.delete(meta.id) + throw e + } + } +} +``` + +- [ ] **Step 3: import.handler** + +- `IMPORT_PICK_FILE` / `IMPORT_PREVIEW` / `IMPORT_EXECUTE` +- progress:`BrowserWindow.getAllWindows()[0]?.webContents.send(IPC.IMPORT_PROGRESS, { current, total })` + +- [ ] **Step 4: IT-IMPORT-01** + +- [ ] **Step 5: 运行** + +```bash +npm run test -- tests/main/import.service.test.ts +``` + +--- + +## Task 11: ImportBookModal + HomePage + +**Files:** +- Create: `src/renderer/components/import/ImportBookModal.tsx` +- Modify: `src/renderer/components/home/HomePage.tsx` +- Modify: `src/renderer/App.tsx`(挂载 ImportBookModal) + +- [ ] **Step 1: ImportBookModal** + +- `data-testid="import-book-modal"` +- 浏览 → `import.pickFile` +- 分章规则 select + 条件 regex 输入 +- 预览按钮 → `import.preview` 显示章数与前 5 标题 +- `fileSizeBytes > 20 * 1024 * 1024` → confirm 对话框 +- 执行 → 监听 `onImportProgress` → `import-progress-bar` +- 成功 → `openBook(bookId)` + 切 editor + +- [ ] **Step 2: HomePage 替换 comingSoon** + +```typescript +onClick={() => setImportOpen(true)} +``` + +- [ ] **Step 3: build** + +```bash +npm run build +``` + +--- + +## Task 12: i18n + CSS + E2E + v1.1.0 + +**Files:** +- Modify: `public/locales/zh-CN/translation.json` +- Modify: `public/locales/en/translation.json` +- Modify: `src/renderer/styles/layout.css` +- Create: `e2e/chapter-template.spec.ts` +- Create: `e2e/import-book.spec.ts` +- Create: `e2e/export-submission.spec.ts` +- Create: `e2e/inspiration-capture.spec.ts` +- Modify: `package.json` +- Modify: `README.md` +- Modify: `src/renderer/components/settings/SettingsPage.tsx`(关于页版本) + +- [ ] **Step 1: i18n 键**(spec §8 全部) + +- [ ] **Step 2: CSS** + +```css +.template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; } +.import-preview-list { font-size: 12px; max-height: 120px; overflow: auto; } +.export-preview { font-family: var(--font-mono); font-size: 11px; white-space: pre-wrap; max-height: 200px; overflow: auto; } +.inspiration-modal--capture { z-index: 10000; } +``` + +- [ ] **Step 3: E2E** + +```typescript +// E2E-TEMPL-01: 模板新建章 → ProseMirror 含模板正文 +// E2E-IMPORT-01: 导入 fixture txt → 多章节可见 +// E2E-EXPORT-01: 导出复制 → 剪贴板含 # 第 +// E2E-INSP-01: 主页 Ctrl+Shift+I → toast,仍在 home +``` + +- [ ] **Step 4: 全量验证** + +```bash +npm run build +npm run test +npm run test:e2e -- e2e/chapter-template.spec.ts e2e/import-book.spec.ts e2e/export-submission.spec.ts e2e/inspiration-capture.spec.ts +``` + +- [ ] **Step 5: 版本 1.1.0** + +`package.json` / `README.md` / Settings 关于页 → `v1.1.0` + +- [ ] **Step 6: 勾选本 plan 全部 checkbox** + +--- + +## Spec Coverage Checklist + +| Spec 要求 | Task | +|-----------|------| +| chapterTemplates + 内置模板 | Task 1, 4 | +| 模板变量 + 快速新建 | Task 2, 3 | +| submissionPresets + 格式化 | Task 1, 5, 6 | +| ExportModal + 剪贴板/txt | Task 7 | +| 全局灵感 + 不跳转 | Task 8 | +| txt/md/docx 导入 + 分章 | Task 9, 10, 11 | +| >20MB 确认 + 进度条 | Task 11 | +| i18n / E2E / v1.1.0 | Task 12 | + +--- + +## Execution Notes + +- `plainTextToEditorHtml` 与 `chapter-template.ts` 可共享;主进程导入侧可 import 同逻辑或 duplicate 最小 HTML 包装 +- 导入 `.docx` 用 mammoth HTML 再 `stripHtml` 分章(章标题在纯文本层匹配) +- `export.formatChapter` 与模板 `{chapterNumber}` 均用卷内 sortOrder 计算序号 +- E2E 导入使用 `tests/fixtures/import-three-chapters.txt` fixture 文件 +- 用户未要求时不自动 git commit;每 Task 完成后勾选 plan checkbox