feat(w3): ship v1.4.0 export matrix, cockpit tasks, and focus ambient

Add ExportMatrixService for txt/md/docx/pdf, extend ExportModal, cockpit outline tasks, focus ambient sound, and E2E-PDF-01 with 134 passing unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 16:56:53 +08:00
parent c10be87d18
commit c5c0b7329b
23 changed files with 890 additions and 37 deletions
+217
View File
@@ -0,0 +1,217 @@
import { BrowserWindow, dialog } from 'electron'
import { writeFileSync } from 'fs'
import type { ExportMatrixParams } from '../../shared/export-matrix'
import type { ExportChapterBlock } from '../lib/export-content'
import {
buildExportBlocks,
buildMdExport,
buildTxtExport,
resolveExportChapters
} from '../lib/export-content'
import type { BookRegistryService } from './book-registry'
import type { GlobalSettingsService } from './global-settings'
const FORMAT_EXT: Record<ExportMatrixParams['format'], string> = {
txt: 'txt',
md: 'md',
docx: 'docx',
pdf: 'pdf'
}
export class ExportMatrixService {
constructor(
private registry: BookRegistryService,
private settings: GlobalSettingsService
) {}
collectBlocks(params: ExportMatrixParams): ExportChapterBlock[] {
const db = this.registry.getDb(params.bookId)
const chapters = resolveExportChapters(db, params.scope, params.scopeId)
if (chapters.length === 0) throw new Error('No chapters to export')
return buildExportBlocks(db, chapters, params.options)
}
async exportToPath(params: ExportMatrixParams, destPath: string): Promise<void> {
const blocks = this.collectBlocks(params)
const book = this.registry.list().find((b) => b.id === params.bookId)
const bookName = book?.name ?? 'export'
const { penName } = this.settings.get()
switch (params.format) {
case 'txt':
writeFileSync(destPath, buildTxtExport(blocks), 'utf8')
break
case 'md':
writeFileSync(destPath, buildMdExport(blocks), 'utf8')
break
case 'docx':
writeFileSync(destPath, await this.buildDocxBuffer(blocks, bookName))
break
case 'pdf':
await this.exportPdf(blocks, bookName, penName, destPath)
break
default:
throw new Error(`Unsupported format: ${params.format}`)
}
}
async pickAndExport(params: ExportMatrixParams): Promise<string | null> {
const ext = FORMAT_EXT[params.format]
let destPath: string | null = null
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_PDF_SAVE) {
destPath = process.env.BILIN_E2E_PDF_SAVE
} else {
const book = this.registry.list().find((b) => b.id === params.bookId)
const defaultPath = `${book?.name ?? 'export'}.${ext}`
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath,
filters: [{ name: ext.toUpperCase(), extensions: [ext] }]
})
if (canceled || !filePath) return null
destPath = filePath
}
await this.exportToPath(params, destPath)
return destPath
}
private async buildDocxBuffer(blocks: ExportChapterBlock[], bookName: string): Promise<Buffer> {
const docx = await import('docx')
const {
Document,
Packer,
Paragraph,
TextRun,
HeadingLevel,
TableOfContents,
AlignmentType,
PageBreak
} = docx
const children: InstanceType<typeof Paragraph>[] = [
new Paragraph({
text: bookName,
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER
}),
new TableOfContents('目录', { hyperlink: true, headingStyleRange: '1-1' }),
new Paragraph({ children: [new PageBreak()] })
]
for (const block of blocks) {
children.push(
new Paragraph({
text: block.title,
heading: HeadingLevel.HEADING_1
})
)
if (block.hasAiContent) {
children.push(
new Paragraph({
children: [new TextRun({ text: '[AI生成内容]', italics: true, color: '888888' })]
})
)
}
for (const p of block.paragraphs) {
children.push(new Paragraph({ children: [new TextRun(p)] }))
}
if (block.annotations.length > 0) {
children.push(
new Paragraph({
children: [new TextRun({ text: '批注', bold: true })]
})
)
for (const note of block.annotations) {
children.push(
new Paragraph({
children: [new TextRun({ text: `${note}`, size: 20 })]
})
)
}
}
}
const doc = new Document({
features: { updateFields: true },
sections: [{ children }]
})
return Packer.toBuffer(doc)
}
private buildPdfHtml(blocks: ExportChapterBlock[], bookName: string, penName: string): string {
const exportedAt = new Date().toLocaleDateString('zh-CN')
const chaptersHtml = blocks
.map((block, idx) => {
const ai = block.hasAiContent ? '<p class="ai-badge">[AI生成内容]</p>' : ''
const body = block.paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join('')
const notes =
block.annotations.length > 0
? `<div class="annotations"><strong>批注</strong><ul>${block.annotations.map((a) => `<li>${escapeHtml(a)}</li>`).join('')}</ul></div>`
: ''
return `<section class="chapter"><h2>${idx + 1}. ${escapeHtml(block.title)}</h2>${ai}${body}${notes}</section>`
})
.join('')
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<style>
@page { margin: 2cm 2cm 2.5cm 2cm; }
body { font-family: 'Noto Serif SC', Georgia, serif; font-size: 12pt; line-height: 1.8; color: #222; }
.print-header { font-size: 9pt; color: #666; border-bottom: 1px solid #ccc; padding-bottom: 6px; margin-bottom: 24px; display: flex; justify-content: space-between; }
.chapter { page-break-before: always; }
.chapter:first-of-type { page-break-before: auto; }
h2 { font-size: 16pt; margin: 0 0 12px; }
.ai-badge { font-size: 9pt; color: #888; }
.annotations { font-size: 9pt; color: #555; margin-top: 16px; border-top: 1px dashed #ccc; padding-top: 8px; }
.print-footer { font-size: 9pt; color: #666; margin-top: 32px; border-top: 1px solid #ccc; padding-top: 6px; display: flex; justify-content: space-between; }
</style>
</head>
<body>
<div class="print-header">
<span>${escapeHtml(bookName)}</span>
<span>笔临导出</span>
<span></span>
</div>
${chaptersHtml}
<div class="print-footer">
<span>${escapeHtml(penName)}</span>
<span>${exportedAt}</span>
</div>
</body>
</html>`
}
private async exportPdf(
blocks: ExportChapterBlock[],
bookName: string,
penName: string,
destPath: string
): Promise<void> {
const html = this.buildPdfHtml(blocks, bookName, penName)
const win = new BrowserWindow({
show: false,
webPreferences: { offscreen: true }
})
try {
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
await new Promise<void>((resolve) => setTimeout(resolve, 300))
const pdf = await win.webContents.printToPDF({
printBackground: true,
pageSize: 'A4',
margins: { top: 0.5, bottom: 0.5, left: 0.5, right: 0.5 }
})
writeFileSync(destPath, pdf)
} finally {
win.destroy()
}
}
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}