diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 471be5f..aa0755c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ rollupOptions: { input: { index: resolve('src/main/index.ts'), + reportTemplateTest: resolve('src/main/report-template-test-entry.ts'), voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts'), knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts') }, diff --git a/examples/report-template-basic.zip b/examples/report-template-basic.zip new file mode 100644 index 0000000..52445cf Binary files /dev/null and b/examples/report-template-basic.zip differ diff --git a/examples/report-template-basic/README.md b/examples/report-template-basic/README.md new file mode 100644 index 0000000..ac4fbb4 --- /dev/null +++ b/examples/report-template-basic/README.md @@ -0,0 +1,9 @@ +# TraceMemo 日报模板示例 + +此目录是可安装的最小日报模板源文件,使用全部虚构数据进行预览。运行 `node scripts/build-report-template-example.cjs` 会生成 `examples/report-template-basic.zip`。 + +模板只能调整已有日报模块的 HTML/CSS 排版。作者不能加入 JavaScript、事件属性、外部网络资源、嵌套页面、数据库访问、AI Prompt 或新的业务分析。 + +占位符分为三类:普通文本(会被 HTML 转义)、应用生成的 HTML 片段(只能作为元素内容使用)、受限样式类(只能放进 `class` 属性,并由应用输出合法 token)。`*_MORE_NOTE` 是 HTML 片段,不是样式类。 + +允许的标签和属性由 `src/shared/report-template-package.ts` 统一定义;图片资源只能是包内 `assets/` 下的 PNG/JPEG/WebP。缺少可选模块时应用输出空字符串和 `*_EMPTY_CLASS`,模板应允许该模块隐藏。 diff --git a/examples/report-template-basic/manifest.json b/examples/report-template-basic/manifest.json new file mode 100644 index 0000000..d48d43e --- /dev/null +++ b/examples/report-template-basic/manifest.json @@ -0,0 +1,14 @@ +{ + "protocolVersion": "1.0", + "kind": "daily-report", + "id": "community.github.example.basic-feed", + "name": "基础信息流", + "author": { "name": "example" }, + "templateVersion": "1.0.0", + "interfaceVersion": "1", + "entry": "template.html", + "preview": "preview.png", + "capture": { "width": 430, "maxWidth": 430, "maxHeight": 20000 }, + "license": { "spdx": "MIT" }, + "platform": "mobile" +} diff --git a/examples/report-template-basic/template.html b/examples/report-template-basic/template.html new file mode 100644 index 0000000..085b472 --- /dev/null +++ b/examples/report-template-basic/template.html @@ -0,0 +1,41 @@ + + + + + + {{REPORT_TITLE}} + + + +
+
+

{{REPORT_TITLE}}

+
{{REPORT_DATE}} · {{DATE_RANGE}}
+

{{HERO_SUMMARY}}

+
{{HERO_STATUS_LINE}}
+
+
+

今日话题

+
{{TOPIC_CARDS}}
+ {{TOPICS_MORE_NOTE}} +
+
+

重要消息

+
{{IMPORTANT_MESSAGES}}
+
+ +
+ + diff --git a/package.json b/package.json index 562badd..2194bb0 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cross-env": "^10.1.0", + "css-tree": "^3.0.1", "electron-updater": "^6.6.2", "ffmpeg-static": "5.3.0", "fs-extra": "^11.3.2", @@ -98,11 +99,13 @@ "jsonrepair": "^3.15.0", "koffi": "^3.1.0", "openai": "^6.10.0", + "parse5": "^8.0.0", "pinyin-pro": "^3.26.0", "qrcode": "^1.5.4", "sherpa-onnx-node": "1.13.3", "silk-wasm": "^3.7.1", "tailwind-merge": "^3.6.0", + "unzipper": "^0.12.0", "wechat-emojis": "^1.0.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3b1958..90372c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,7 @@ specifiers: class-variance-authority: ^0.7.1 clsx: ^2.1.1 cross-env: ^10.1.0 + css-tree: ^3.0.1 electron: ^43.0.0 electron-builder: ^26.0.12 electron-updater: ^6.6.2 @@ -61,6 +62,7 @@ specifiers: jsonrepair: ^3.15.0 koffi: ^3.1.0 openai: ^6.10.0 + parse5: ^8.0.0 pinyin-pro: ^3.26.0 postcss: ^8.5.26 prettier: ^3.7.4 @@ -74,6 +76,7 @@ specifiers: tailwindcss: 3.4.17 tailwindcss-animate: ^1.0.7 typescript: ^5.9.3 + unzipper: ^0.12.0 vite: ^7.2.6 vitest: ^4.1.10 wechat-emojis: ^1.0.2 @@ -102,6 +105,7 @@ dependencies: class-variance-authority: 0.7.1 clsx: 2.1.1 cross-env: 10.1.0 + css-tree: 3.2.1 electron-updater: 6.8.9 ffmpeg-static: 5.3.0 fs-extra: 11.3.2 @@ -109,11 +113,13 @@ dependencies: jsonrepair: 3.15.0 koffi: 3.1.0 openai: 6.10.0 + parse5: 8.0.1 pinyin-pro: 3.29.3 qrcode: 1.5.4 sherpa-onnx-node: 1.13.3 silk-wasm: 3.7.1 tailwind-merge: 3.6.0 + unzipper: 0.12.5 wechat-emojis: 1.0.2 devDependencies: @@ -3900,6 +3906,10 @@ packages: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} dev: true + /bluebird/3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + dev: false + /brace-expansion/1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} dependencies: @@ -4352,7 +4362,6 @@ packages: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 - dev: true /css.escape/1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -4572,6 +4581,12 @@ packages: gopd: 1.2.0 dev: true + /duplexer2/0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + dependencies: + readable-stream: 2.3.8 + dev: false + /eastasianwidth/0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true @@ -4699,7 +4714,6 @@ packages: /entities/8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} - dev: true /env-paths/2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} @@ -5303,6 +5317,15 @@ packages: jsonfile: 6.2.0 universalify: 2.0.1 + /fs-extra/11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + dev: false + /fs-extra/11.3.2: resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} engines: {node: '>=14.14'} @@ -6328,7 +6351,6 @@ packages: /mdn-data/2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - dev: true /merge2/1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} @@ -6554,6 +6576,10 @@ packages: semver: 7.7.3 dev: true + /node-int64/0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + dev: false + /node-releases/2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} dev: true @@ -6773,7 +6799,6 @@ packages: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} dependencies: entities: 8.0.0 - dev: true /path-exists/4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} @@ -7665,7 +7690,6 @@ packages: /source-map-js/1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - dev: true /source-map-support/0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -8210,6 +8234,16 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + /unzipper/0.12.5: + resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.1 + graceful-fs: 4.2.11 + node-int64: 0.4.0 + dev: false + /update-browserslist-db/1.2.2_browserslist@4.28.1: resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} hasBin: true diff --git a/scripts/build-report-template-example.cjs b/scripts/build-report-template-example.cjs new file mode 100644 index 0000000..404076b --- /dev/null +++ b/scripts/build-report-template-example.cjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +const fs = require('node:fs') +const path = require('node:path') +const { ZipArchive } = require('archiver') + +const root = path.resolve(__dirname, '..') +const source = path.join(root, 'examples', 'report-template-basic') +const outputPath = path.join(root, 'examples', 'report-template-basic.zip') +const output = fs.createWriteStream(outputPath) +const archive = new ZipArchive({ zlib: { level: 9 } }) +output.on('close', () => console.log(`wrote ${outputPath} (${archive.pointer()} bytes)`)) +archive.on('error', (error) => { throw error }) +archive.pipe(output) +archive.file(path.join(source, 'manifest.json'), { name: 'manifest.json' }) +archive.file(path.join(source, 'template.html'), { name: 'template.html' }) +// 使用 1x1 PNG 作为虚构预览占位图,避免引入真实用户媒体。 +archive.append(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), { name: 'preview.png' }) +archive.finalize() diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts index 497f6a3..eca7ba8 100644 --- a/src/main/group-report-service.ts +++ b/src/main/group-report-service.ts @@ -1,7 +1,10 @@ import { app, BrowserWindow } from 'electron' +import crypto from 'node:crypto' +import { appendFileSync } from 'node:fs' import fs from 'fs-extra' import os from 'os' import path from 'path' +import { fileURLToPath } from 'node:url' import { GroupReportExportRequest, GroupReportExportResult, @@ -15,6 +18,8 @@ import { import { resolveMd5, getGroupSnapshot } from './services/chat-service' import { imageInsightService } from './services/image-insight-service' import { getReportTemplate } from '../shared/report-templates' +import type { ReportTemplateRef } from '../shared/report-template-package' +import { reportTemplateService, validateReportTemplateHtml } from './report-template-service' const LEGACY_TEMPLATE_FILES: Record = { v1: 'mobile_daily_report_v1.html', @@ -33,6 +38,40 @@ const templatePath = (templateId?: string): string => { return found } +const resolveTemplateSelection = async (templateId?: string, templateRef?: ReportTemplateRef) => { + if (!templateRef) { + const definition = getReportTemplate(templateId) + return { + definition, + entryPath: templatePath(templateId), + captureMaxHeight: 20000, + source: 'builtin' as const + } + } + const installed = await reportTemplateService.resolve(templateRef) + return { + definition: { + id: installed.id, + order: 999, + platform: 'default' as const, + label: installed.name, + name: installed.name, + tagline: `${installed.author} · ${installed.version}`, + fileLabel: installed.name, + cssClass: `template-external-${installed.id.replace(/[^a-z0-9-]/gi, '-')}`, + resourceFile: installed.entryPath, + captureWidth: installed.capture.width, + maxCaptureWidth: installed.capture.maxWidth + }, + entryPath: installed.entryPath, + captureMaxHeight: installed.capture.maxHeight, + source: 'installed' as const + } +} + +const resolveTemplate = async (request: GroupReportExportRequest) => + resolveTemplateSelection(request.templateId, request.templateRef) + const escapeHtml = (value: unknown): string => String(value ?? '') .replace(/&/g, '&') @@ -148,6 +187,52 @@ const heatClass = (heat: ReportHeat): string => { const replacePlaceholder = (html: string, key: string, value: string): string => html.replaceAll(`{{${key}}}`, value) +const addReportCsp = (html: string, allowFileImages = true): string => + html.replace( + /]*)?>/i, + (head) => + `${head}` + ) + +const inlineTemplateAssets = async (html: string, templateRoot: string): Promise => { + const matches = [...html.matchAll(/\bsrc=(['"])(assets\/[A-Za-z0-9._/-]+)\1/g)] + let result = html + for (const match of matches) { + const relative = match[2] + const assetPath = path.resolve(templateRoot, relative) + if (!assetPath.startsWith(`${path.resolve(templateRoot)}${path.sep}`)) continue + const extension = path.extname(assetPath).toLowerCase() + const mime = + extension === '.png' ? 'image/png' : extension === '.webp' ? 'image/webp' : 'image/jpeg' + const data = (await fs.readFile(assetPath)).toString('base64') + result = result.replace(match[0], `src="data:${mime};base64,${data}"`) + } + return result +} + +const recordBlockedTemplateRequest = ( + details: Electron.OnBeforeRequestListenerDetails, + reason: string +): void => { + // 仅测试入口设置该路径,用于保留运行时拦截证据;生产默认不记录请求明细。 + const logPath = process.env.TRACEMEMO_TEMPLATE_SECURITY_LOG + if (!logPath) return + try { + appendFileSync( + logPath, + `${JSON.stringify({ + url: details.url, + method: details.method, + resourceType: details.resourceType, + reason + })}\n`, + 'utf8' + ) + } catch (error) { + console.warn('[GroupReport] failed to record template security block:', error) + } +} + const sectionMeta = ( request: GroupReportExportRequest, key: keyof NonNullable @@ -170,7 +255,8 @@ const overflowNote = ( const renderReportHtml = async (request: GroupReportExportRequest): Promise => { const { report, metadata } = request - const template = getReportTemplate(request.templateId) + const resolvedTemplate = await resolveTemplate(request) + const template = resolvedTemplate.definition const avatarNames = new Set(metadata.heroParticipants) report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name))) report.importantMessages.forEach((message) => avatarNames.add(message.sender)) @@ -237,7 +323,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise热点图片
${escapeHtml(topic.image.note)}
` + return `
热点图片
${escapeHtml(topic.image.note)}
` })() : '' } @@ -349,7 +435,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise item.imageUrl) // 只显示加载成功的图 .map( (item) => `
- AI 识别的图片 + AI 识别的图片
${escapeHtml(item.sender)}${escapeHtml(item.time)}
${escapeHtml(item.description)}
@@ -441,7 +527,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise = { TEMPLATE_CLASS: template.cssClass, TEMPLATE_LABEL: escapeHtml(template.label), @@ -547,14 +637,19 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise => { - const template = getReportTemplate(request.templateId) - let html = await fs.readFile(templatePath(request.templateId), 'utf8') + const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef) + const template = resolvedTemplate.definition + let html = await fs.readFile(resolvedTemplate.entryPath, 'utf8') + if (resolvedTemplate.source === 'installed') { + validateReportTemplateHtml(html, path.basename(resolvedTemplate.entryPath)) + html = await inlineTemplateAssets(html, path.dirname(resolvedTemplate.entryPath)) + } const values = { ...request.snapshot.values, TEMPLATE_CLASS: template.cssClass, @@ -565,7 +660,7 @@ const renderReportSnapshotHtml = async ( REPORT_DATE: request.snapshot.values.REPORT_DATE || escapeHtml(request.snapshot.reportDate) } for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value) - return html.replace(/\{\{[A-Z0-9_]+\}\}/g, '') + return addReportCsp(html.replace(/\{\{[A-Z0-9_]+\}\}/g, ''), resolvedTemplate.source === 'builtin') } export const extractGroupReportRenderSnapshot = async ( @@ -777,20 +872,61 @@ export const extractGroupReportRenderSnapshot = async ( const captureFullPage = async ( htmlPath: string, pngPath: string, - templateId?: string + templateId?: string, + templateOverride?: { + captureWidth: number + maxCaptureWidth: number + maxCaptureHeight?: number + } ): Promise => { - const template = getReportTemplate(templateId) + const template = templateOverride || getReportTemplate(templateId) const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth + const maxCaptureHeight = templateOverride?.maxCaptureHeight || 20000 console.log(`[GroupReport] capture begin html=${htmlPath}`) + const reportSessionPartition = `report-template-${crypto.randomUUID()}` const reportWindow = new BrowserWindow({ show: false, width: captureWidth, height: 800, frame: false, backgroundColor: '#f3f5f7', - webPreferences: { sandbox: true } + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + partition: reportSessionPartition + } }) + const allowedDocumentPath = path.resolve(htmlPath) + const requestHandler = ( + details: Electron.OnBeforeRequestListenerDetails, + callback: (response: { cancel: boolean }) => void + ): void => { + if (details.url.startsWith('file:')) { + try { + const requestedPath = path.resolve(fileURLToPath(details.url)) + if (requestedPath === allowedDocumentPath) { + callback({ cancel: false }) + } else { + recordBlockedTemplateRequest(details, 'file-path-not-allowed') + callback({ cancel: true }) + } + } catch { + recordBlockedTemplateRequest(details, 'invalid-file-url') + callback({ cancel: true }) + } + return + } + recordBlockedTemplateRequest(details, 'scheme-not-allowed') + callback({ cancel: true }) + } + reportWindow.webContents.session.webRequest.onBeforeRequest( + { urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*', 'file://*/*'] }, + requestHandler + ) + reportWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + reportWindow.webContents.on('will-navigate', (event) => event.preventDefault()) try { await reportWindow.loadFile(htmlPath) @@ -808,7 +944,7 @@ const captureFullPage = async ( height: Math.ceil(Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 800)) })`)) as { width: number; height: number } const width = Math.max(captureWidth, Math.min(maxCaptureWidth, Math.ceil(metrics.width))) - const height = Math.max(800, Math.min(20000, Math.ceil(metrics.height))) + const height = Math.max(800, Math.min(maxCaptureHeight, Math.ceil(metrics.height))) reportWindow.setContentSize(width, height) await new Promise((resolve) => setTimeout(resolve, 100)) console.log(`[GroupReport] capture native page width=${width} height=${height}`) @@ -819,6 +955,7 @@ const captureFullPage = async ( console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`) return `data:image/png;base64,${png.toString('base64')}` } finally { + reportWindow.webContents.session.webRequest.onBeforeRequest(null) reportWindow.destroy() } } @@ -830,10 +967,14 @@ export const exportGroupReport = async ( // === enrich 在 render 之前:从群成员快照反推真头像 === await enrichAvatarsFromGroup(request.metadata) - const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录') + const outputDir = + process.env.TRACEMEMO_REPORT_OUTPUT_DIR || + path.join(os.homedir(), 'Documents', '微信聊天记录') await fs.ensureDir(outputDir) - const templateLabel = - request.templateId === 'v1' + const resolvedTemplate = await resolveTemplate(request) + const templateLabel = request.templateRef + ? resolvedTemplate.definition.fileLabel + : request.templateId === 'v1' ? '经典版' : request.templateId === 'v2' ? '丰富版' @@ -846,7 +987,15 @@ export const exportGroupReport = async ( await fs.writeFile(htmlPath, html, 'utf8') const htmlEndedAt = new Date() const pngStartedAt = new Date() - const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId) + const imageDataUrl = await captureFullPage( + htmlPath, + pngPath, + request.templateId, + { + ...resolvedTemplate.definition, + maxCaptureHeight: resolvedTemplate.captureMaxHeight + } + ) const pngEndedAt = new Date() return { success: true, @@ -877,9 +1026,12 @@ export const exportGroupReportSnapshot = async ( request: GroupReportRenderSnapshotExportRequest ): Promise => { try { - const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录') + const outputDir = + process.env.TRACEMEMO_REPORT_OUTPUT_DIR || + path.join(os.homedir(), 'Documents', '微信聊天记录') await fs.ensureDir(outputDir) - const templateLabel = getReportTemplate(request.templateId).fileLabel + const resolvedTemplate = await resolveTemplateSelection(request.templateId, request.templateRef) + const templateLabel = resolvedTemplate.definition.fileLabel const baseName = `${sanitizeFileName(request.snapshot.groupName)}日报_${request.snapshot.reportDate}_${templateLabel}` const htmlPath = path.join(outputDir, `${baseName}.html`) const pngPath = path.join(outputDir, `${baseName}.png`) @@ -888,7 +1040,15 @@ export const exportGroupReportSnapshot = async ( await fs.writeFile(htmlPath, html, 'utf8') const htmlEndedAt = new Date() const pngStartedAt = new Date() - const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId) + const imageDataUrl = await captureFullPage( + htmlPath, + pngPath, + request.templateId, + { + ...resolvedTemplate.definition, + maxCaptureHeight: resolvedTemplate.captureMaxHeight + } + ) const pngEndedAt = new Date() return { success: true, diff --git a/src/main/http-server.ts b/src/main/http-server.ts index 0546589..8e6eba9 100644 --- a/src/main/http-server.ts +++ b/src/main/http-server.ts @@ -334,6 +334,11 @@ const routes: Record = { if (!request?.report || !request?.metadata) { return sendError(res, 400, '请求体需包含 report 和 metadata 字段') } + if (request.templateRef !== undefined) { + return sendError(res, 400, 'HTTP API 暂不支持外部日报模板,请使用内置 templateId', { + code: 'external_template_unsupported' + }) + } const result = await exportGroupReport(request) sendJson(res, result.success ? 200 : 500, result) }, diff --git a/src/main/index.ts b/src/main/index.ts index e8873f2..cf3f6c4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,7 +32,6 @@ import { type DecodedImage } from './image-decrypt-service' import { - exportGroupReport, exportGroupReportSnapshot, extractGroupReportRenderSnapshot } from './group-report-service' @@ -43,8 +42,9 @@ import { saveGeneratedReport, updateGeneratedReportTemplate } from './report-history-service' +import { reportTemplateService } from './report-template-service' +import { registerReportTemplateIpc } from './report-template-ipc' import type { - GroupReportExportRequest, GroupReportRenderSnapshotExportRequest } from '../shared/group-report' import type { @@ -667,6 +667,8 @@ app.whenReady().then(async () => { }) // Create the renderer before native WCDB bootstrap so startup progress is visible immediately. + await reportTemplateService.recover() + registerReportTemplateIpc() createWindow() wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => { console.log('[WCDB4] async bootstrap complete') @@ -1281,9 +1283,6 @@ app.whenReady().then(async () => { } }) - ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => { - return exportGroupReport(request) - }) ipcMain.handle( 'report:exportSnapshot', async (_, request: GroupReportRenderSnapshotExportRequest) => exportGroupReportSnapshot(request) diff --git a/src/main/report-template-ipc.ts b/src/main/report-template-ipc.ts new file mode 100644 index 0000000..7172d62 --- /dev/null +++ b/src/main/report-template-ipc.ts @@ -0,0 +1,41 @@ +import { ipcMain } from 'electron' +import { exportGroupReport } from './group-report-service' +import { reportTemplateService } from './report-template-service' +import { reportTemplateMarketService } from './report-template-market-service' +import type { GroupReportExportRequest } from '../shared/group-report' + +let registered = false + +export function registerReportTemplateIpc(): void { + if (registered) return + registered = true + ipcMain.handle('report-template:list', () => reportTemplateService.list()) + ipcMain.handle('report-template:install', async (_, packagePath: string) => { + try { + return { success: true, template: await reportTemplateService.install(packagePath) } + } catch (error) { + return { + success: false, + code: error instanceof Error && 'code' in error ? String((error as { code?: unknown }).code) : 'install_failed', + error: error instanceof Error ? error.message : String(error) + } + } + }) + ipcMain.handle('report-template:uninstall', async (_, id: string, version: string) => { + try { + await reportTemplateService.uninstall(id, version) + return { success: true } + } catch (error) { + return { + success: false, + code: error instanceof Error && 'code' in error ? String((error as { code?: unknown }).code) : 'uninstall_failed', + error: error instanceof Error ? error.message : String(error) + } + } + }) + ipcMain.handle('report-template-market:list', () => reportTemplateMarketService.listCatalog()) + ipcMain.handle('report-template-market:install', (_, id: string, version: string) => + reportTemplateMarketService.installFromCatalog(String(id || ''), String(version || '')) + ) + ipcMain.handle('report:export', (_, request: GroupReportExportRequest) => exportGroupReport(request)) +} diff --git a/src/main/report-template-market-service.ts b/src/main/report-template-market-service.ts new file mode 100644 index 0000000..a909b18 --- /dev/null +++ b/src/main/report-template-market-service.ts @@ -0,0 +1,243 @@ +import crypto from 'node:crypto' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { app } from 'electron' +import { + REPORT_TEMPLATE_INTERFACE_VERSION, + REPORT_TEMPLATE_LIMITS, + ReportTemplateError, + type ReportTemplateOperationResult +} from '../shared/report-template-package' +import { + REPORT_TEMPLATE_CATALOG_URL, + type ReportTemplateCatalog, + type ReportTemplateCatalogEntry, + type ReportTemplateCatalogInstallResult, + type ReportTemplateCatalogResult +} from '../shared/report-template-market' +import { reportTemplateService } from './report-template-service' + +const CATALOG_MAX_BYTES = 2 * 1024 * 1024 +const REQUEST_TIMEOUT_MS = 15_000 +const SAFE_ID = /^community\.github\.[a-z0-9][a-z0-9-]{0,38}\.[a-z0-9][a-z0-9-]{0,63}$/ +const SAFE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/ +const SHA256 = /^[a-f0-9]{64}$/ +const COMMIT = /^[a-f0-9]{40}$/ +const RAW_HOST = 'raw.githubusercontent.com' +const RAW_REPOSITORY_PREFIX = '/Wxw-Gu/TraceMemo-Templates/' + +const operationError = (fallbackCode: string, error: unknown): ReportTemplateOperationResult => ({ + success: false, + code: error instanceof ReportTemplateError ? error.code : fallbackCode, + error: error instanceof Error ? error.message : String(error) +}) + +const assertAllowedRemoteUrl = (value: unknown, label: string): URL => { + let parsed: URL + try { + parsed = new URL(String(value || '')) + } catch { + throw new ReportTemplateError('invalid_catalog', `${label} URL 无效`) + } + if ( + parsed.protocol !== 'https:' || + parsed.hostname !== RAW_HOST || + parsed.port !== '' || + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + !parsed.pathname.startsWith(RAW_REPOSITORY_PREFIX) + ) { + throw new ReportTemplateError('invalid_catalog', `${label} 只允许 GitHub raw HTTPS 地址`) + } + return parsed +} + +const assertAllowedPackageUrl = (value: unknown, label: string): URL => { + const parsed = assertAllowedRemoteUrl(value, label) + const segments = parsed.pathname.split('/').filter(Boolean) + if (segments.length < 4 || !COMMIT.test(segments[2])) { + throw new ReportTemplateError('invalid_catalog', `${label} 必须固定到模板仓库 commit`) + } + return parsed +} + +const remoteCommit = (value: string): string | undefined => { + try { + return new URL(value).pathname.split('/').filter(Boolean)[2] + } catch { + return undefined + } +} + +const fetchBytes = async (url: URL, maxBytes: number): Promise => { + const response = await fetch(url, { + headers: { Accept: 'application/json, application/zip, image/png', 'User-Agent': 'TraceMemo' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + redirect: 'error' + }) + if (!response.ok) throw new ReportTemplateError('catalog_fetch_failed', `远端请求失败:HTTP ${response.status}`) + const contentLength = Number(response.headers.get('content-length') || 0) + if (contentLength > maxBytes) throw new ReportTemplateError('catalog_too_large', '远端模板目录或包超过大小限制') + if (!response.body) { + const bytes = Buffer.from(await response.arrayBuffer()) + if (bytes.length > maxBytes) throw new ReportTemplateError('catalog_too_large', '远端模板目录或包超过大小限制') + return bytes + } + const reader = response.body.getReader() + const chunks: Buffer[] = [] + let total = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + await reader.cancel() + throw new ReportTemplateError('catalog_too_large', '远端模板目录或包超过大小限制') + } + chunks.push(Buffer.from(value)) + } + } finally { + reader.releaseLock() + } + return Buffer.concat(chunks, total) +} + +const validateCatalogEntry = (value: unknown): ReportTemplateCatalogEntry => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ReportTemplateError('invalid_catalog', '目录条目必须是对象') + } + const entry = value as Record + const id = String(entry.id || '') + const version = String(entry.version || '') + const interfaceVersion = String(entry.interfaceVersion || '') + const sizeBytes = Number(entry.sizeBytes) + if (!SAFE_ID.test(id) || !SAFE_VERSION.test(version)) { + throw new ReportTemplateError('invalid_catalog', '目录模板 ID 或版本无效') + } + if (interfaceVersion !== REPORT_TEMPLATE_INTERFACE_VERSION) { + throw new ReportTemplateError('unsupported_interface', `模板接口版本不兼容:${interfaceVersion}`) + } + if (entry.status !== 'published' || !Number.isSafeInteger(sizeBytes) || sizeBytes <= 0 || sizeBytes > REPORT_TEMPLATE_LIMITS.maxCompressedBytes) { + throw new ReportTemplateError('invalid_catalog', `${id}@${version} 目录状态或大小无效`) + } + if (typeof entry.name !== 'string' || !entry.name.trim() || typeof entry.description !== 'string' || typeof entry.author !== 'string') { + throw new ReportTemplateError('invalid_catalog', `${id}@${version} 目录名称或作者无效`) + } + if (!SHA256.test(String(entry.sha256 || ''))) throw new ReportTemplateError('invalid_catalog', `${id}@${version} SHA-256 无效`) + const download = assertAllowedPackageUrl(entry.download, 'download') + const preview = entry.preview === undefined || entry.preview === null ? undefined : assertAllowedPackageUrl(entry.preview, 'preview') + const platform = entry.platform === 'mobile' || entry.platform === 'desktop' || entry.platform === 'default' ? entry.platform : undefined + const tags = Array.isArray(entry.tags) && entry.tags.every((tag) => typeof tag === 'string') ? entry.tags : [] + return { + id, + version, + interfaceVersion, + name: entry.name.trim(), + description: entry.description, + author: entry.author, + ...(platform ? { platform } : {}), + tags, + license: typeof entry.license === 'string' ? entry.license : '', + minAppVersion: typeof entry.minAppVersion === 'string' ? entry.minAppVersion : null, + download: download.toString(), + sizeBytes, + sha256: String(entry.sha256), + ...(preview ? { preview: preview.toString() } : {}), + publishedAt: typeof entry.publishedAt === 'string' ? entry.publishedAt : null, + status: 'published' + } +} + +const parseCatalog = (bytes: Buffer): ReportTemplateCatalog => { + let value: unknown + try { + value = JSON.parse(bytes.toString('utf8')) + } catch { + throw new ReportTemplateError('invalid_catalog', '远端模板目录不是有效 JSON') + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ReportTemplateError('invalid_catalog', '远端模板目录格式无效') + } + const raw = value as Record + if (raw.schemaVersion !== '1' || raw.status !== 'published' || !Array.isArray(raw.templates) || raw.templates.length > 100) { + throw new ReportTemplateError('invalid_catalog', '远端模板目录版本或状态无效') + } + const seen = new Set() + const templates = raw.templates.map(validateCatalogEntry) + for (const entry of templates) { + const key = `${entry.id}@${entry.version}` + if (seen.has(key)) throw new ReportTemplateError('invalid_catalog', `目录包含重复模板:${key}`) + seen.add(key) + } + const source = raw.source && typeof raw.source === 'object' && !Array.isArray(raw.source) + ? raw.source as Record + : undefined + if ( + !source || + source.repository !== 'Wxw-Gu/TraceMemo-Templates' || + !COMMIT.test(String(source.commit || '')) + ) { + throw new ReportTemplateError('invalid_catalog', '目录 source.repository 或 source.commit 无效') + } + for (const entry of templates) { + if ( + remoteCommit(entry.download) !== source.commit || + (entry.preview !== undefined && remoteCommit(entry.preview) !== source.commit) + ) { + throw new ReportTemplateError('invalid_catalog', `${entry.id}@${entry.version} 未固定到目录 source.commit`) + } + } + return { + schemaVersion: '1', + ...(typeof raw.generatedAt === 'string' ? { generatedAt: raw.generatedAt } : {}), + ...(source ? { source: { ...(typeof source.repository === 'string' ? { repository: source.repository } : {}), ...(typeof source.commit === 'string' ? { commit: source.commit } : {}) } } : {}), + status: 'published', + templates + } +} + +const fetchCatalog = async (): Promise => { + const url = assertAllowedRemoteUrl(REPORT_TEMPLATE_CATALOG_URL, 'catalog') + return parseCatalog(await fetchBytes(url, CATALOG_MAX_BYTES)) +} + +export class ReportTemplateMarketService { + async listCatalog(): Promise { + try { + return { success: true, catalog: await fetchCatalog() } + } catch (error) { + return operationError('catalog_fetch_failed', error) as ReportTemplateCatalogResult + } + } + + async installFromCatalog(id: string, version: string): Promise { + let temporaryDirectory: string | undefined + try { + const catalog = await fetchCatalog() + const entry = catalog.templates.find((candidate) => candidate.id === id && candidate.version === version) + if (!entry) return { success: false, code: 'catalog_template_not_found', error: `远端目录不存在:${id}@${version}` } + const bytes = await fetchBytes(assertAllowedPackageUrl(entry.download, 'download'), REPORT_TEMPLATE_LIMITS.maxCompressedBytes) + const digest = crypto.createHash('sha256').update(bytes).digest('hex') + if (bytes.length !== entry.sizeBytes || digest !== entry.sha256) { + return { success: false, code: 'download_integrity_failed', error: `模板包校验失败:${id}@${version}`, catalogEntry: entry } + } + temporaryDirectory = await fs.mkdtemp(path.join(app.getPath('temp'), 'tracememo-template-market-')) + const packagePath = path.join(temporaryDirectory, `${id}-${version}.zip`) + await fs.writeFile(packagePath, bytes, { flag: 'wx' }) + const template = await reportTemplateService.install(packagePath, { + id: entry.id, + version: entry.version + }) + return { success: true, template, catalogEntry: entry } + } catch (error) { + return { ...(operationError('market_install_failed', error) as ReportTemplateCatalogInstallResult) } + } finally { + if (temporaryDirectory) await fs.rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined) + } + } +} + +export const reportTemplateMarketService = new ReportTemplateMarketService() diff --git a/src/main/report-template-service.ts b/src/main/report-template-service.ts new file mode 100644 index 0000000..0fb2ff2 --- /dev/null +++ b/src/main/report-template-service.ts @@ -0,0 +1,403 @@ +import crypto from 'node:crypto' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { Open } from 'unzipper' +import { parseFragment } from 'parse5' +import * as csstree from 'css-tree' +import { app } from 'electron' +import { + DEFAULT_REPORT_TEMPLATE, + REPORT_TEMPLATES, + type ReportTemplateDefinition +} from '../shared/report-templates' +import { + REPORT_TEMPLATE_ALLOWED_ASSET_EXTENSIONS, + REPORT_TEMPLATE_ALLOWED_ATTRS, + REPORT_TEMPLATE_ALLOWED_TAGS, + REPORT_TEMPLATE_INTERFACE_VERSION, + REPORT_TEMPLATE_LIMITS, + REPORT_TEMPLATE_PACKAGE_PROTOCOL_VERSION, + REPORT_TEMPLATE_PLACEHOLDERS, + type InstalledReportTemplate, + type ReportTemplateManifest, + type ReportTemplateRef, + ReportTemplateError +} from '../shared/report-template-package' + +const INDEX_FILE = 'index.json' +const INSTALLED_DIR = 'installed' +const STAGING_DIR = 'staging' +const SAFE_ID = /^community\.github\.[a-z0-9][a-z0-9-]{0,38}\.[a-z0-9][a-z0-9-]{0,63}$/ +const SAFE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/ +const SAFE_RELATIVE_PATH = /^[^\\/][^:]*$/ + +interface TemplateIndex { + version: 1 + templates: InstalledReportTemplate[] +} + +interface EntryInfo { + path: string + type: 'file' | 'directory' + size: number + entry: Awaited>['files'][number] +} + +function rootPath(): string { + return path.join(app.getPath('userData'), 'report-templates') +} + +function isWithin(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate) + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} + +function validateRelativePath(value: unknown, label: string): string { + if (typeof value !== 'string' || !value || !SAFE_RELATIVE_PATH.test(value)) { + throw new ReportTemplateError('invalid_path', `${label} 必须是包内相对路径`) + } + const normalized = path.posix.normalize(value.replace(/\\/g, '/')) + if (normalized === '.' || normalized.startsWith('../') || normalized.includes('/../') || path.posix.isAbsolute(normalized)) { + throw new ReportTemplateError('invalid_path', `${label} 不能越出模板包目录`) + } + return normalized +} + +function parseManifest(value: unknown): ReportTemplateManifest { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ReportTemplateError('invalid_manifest', 'manifest.json 必须是对象') + } + const manifest = value as Record + const author = manifest.author as Record | undefined + const capture = manifest.capture as Record | undefined + const license = manifest.license as Record | undefined + if (manifest.protocolVersion !== REPORT_TEMPLATE_PACKAGE_PROTOCOL_VERSION) { + throw new ReportTemplateError('unsupported_protocol', '模板包协议版本不受支持') + } + if (manifest.kind !== 'daily-report' || typeof manifest.id !== 'string' || !SAFE_ID.test(manifest.id)) { + throw new ReportTemplateError('invalid_manifest', '模板类型或 ID 无效') + } + if (typeof manifest.name !== 'string' || !manifest.name.trim() || !author || typeof author.name !== 'string') { + throw new ReportTemplateError('invalid_manifest', '模板名称和作者不能为空') + } + if (typeof manifest.templateVersion !== 'string' || !SAFE_VERSION.test(manifest.templateVersion)) { + throw new ReportTemplateError('invalid_version', 'templateVersion 必须是 semver') + } + if (manifest.interfaceVersion !== REPORT_TEMPLATE_INTERFACE_VERSION) { + throw new ReportTemplateError('unsupported_interface', '占位符接口版本不受支持') + } + const entry = validateRelativePath(manifest.entry, 'entry') + const preview = manifest.preview === undefined ? undefined : validateRelativePath(manifest.preview, 'preview') + const captureWidth = capture?.width + const captureMaxWidth = capture?.maxWidth + const captureMaxHeight = capture?.maxHeight + if (!capture || !Number.isInteger(captureWidth) || !Number.isInteger(captureMaxWidth) || !Number.isInteger(captureMaxHeight)) { + throw new ReportTemplateError('invalid_manifest', 'capture 尺寸无效') + } + if ((captureWidth as number) < 320 || (captureWidth as number) > 1920 || (captureMaxWidth as number) < (captureWidth as number) || (captureMaxWidth as number) > 1920 || (captureMaxHeight as number) < 800 || (captureMaxHeight as number) > 20000) { + throw new ReportTemplateError('invalid_manifest', 'capture 尺寸超出允许范围') + } + if (!license || typeof license.spdx !== 'string' || !license.spdx.trim()) { + throw new ReportTemplateError('invalid_manifest', '必须声明 license.spdx') + } + return { + protocolVersion: manifest.protocolVersion, + kind: 'daily-report', + id: manifest.id, + name: manifest.name.trim(), + author: { name: author.name.trim(), ...(typeof author.homepage === 'string' ? { homepage: author.homepage } : {}) }, + templateVersion: manifest.templateVersion, + interfaceVersion: manifest.interfaceVersion, + entry, + ...(preview ? { preview } : {}), + capture: { width: captureWidth as number, maxWidth: captureMaxWidth as number, maxHeight: captureMaxHeight as number }, + license: { spdx: license.spdx.trim(), ...(typeof license.notice === 'string' ? { notice: license.notice } : {}) }, + ...(typeof manifest.minAppVersion === 'string' ? { minAppVersion: manifest.minAppVersion } : {}), + ...(manifest.platform === 'mobile' || manifest.platform === 'desktop' || manifest.platform === 'default' ? { platform: manifest.platform } : {}) + } +} + +function validateCss(css: string, fileName: string): void { + if (Buffer.byteLength(css, 'utf8') > REPORT_TEMPLATE_LIMITS.maxCssBytes) { + throw new ReportTemplateError('file_too_large', `${fileName} 超过 CSS 大小限制`) + } + let ast: csstree.CssNode + try { + ast = csstree.parse(css, { positions: false }) + } catch (error) { + throw new ReportTemplateError('invalid_css', `${fileName} CSS 解析失败:${String(error)}`) + } + csstree.walk(ast, (node) => { + if (node.type === 'Atrule' && node.name.toLowerCase() === 'import') { + throw new ReportTemplateError('unsafe_css', `${fileName} 不允许 @import`) + } + if (node.type === 'Url' || (node.type === 'Function' && node.name.toLowerCase() === 'url')) { + const raw = csstree.generate(node) + if (/^(?:url\()?\s*(?:https?:|file:|data:|javascript:)/i.test(raw.trim())) { + throw new ReportTemplateError('unsafe_url', `${fileName} 包含危险资源 URL`) + } + } + }) +} + +export function validateReportTemplateHtml(html: string, fileName = 'template.html'): void { + if (Buffer.byteLength(html, 'utf8') > REPORT_TEMPLATE_LIMITS.maxHtmlBytes) { + throw new ReportTemplateError('file_too_large', `${fileName} 超过 HTML 大小限制`) + } + const placeholders = [...html.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)].map((match) => match[1]) + for (const key of placeholders) { + if (!REPORT_TEMPLATE_PLACEHOLDERS[key]) throw new ReportTemplateError('unknown_placeholder', `不支持占位符 {{${key}}}`) + } + const fragment = parseFragment(html) + const visit = (node: { nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }): void => { + const tag = node.nodeName.toLowerCase() + if (tag === '#text' || tag === '#comment' || tag === '#document-fragment') { + for (const child of (node.childNodes || []) as Array<{ nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }>) visit(child) + return + } + if (!REPORT_TEMPLATE_ALLOWED_TAGS.has(tag)) throw new ReportTemplateError('unsafe_html', `${fileName} 不允许标签 <${tag}>`) + for (const attr of node.attrs || []) { + const name = attr.name.toLowerCase() + if (!REPORT_TEMPLATE_ALLOWED_ATTRS.has(name) || name.startsWith('on')) throw new ReportTemplateError('unsafe_html', `${fileName} 不允许属性 ${attr.name}`) + if (/\{\{[A-Z0-9_]+\}\}/.test(attr.value)) { + const keys = [...attr.value.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)].map((match) => match[1]) + if (name !== 'class' || keys.some((key) => REPORT_TEMPLATE_PLACEHOLDERS[key] !== 'class')) { + throw new ReportTemplateError('invalid_placeholder_context', `占位符不能出现在 ${name} 属性中`) + } + } + if (name === 'src') { + const assetPath = path.posix.normalize(attr.value.replace(/\\/g, '/')) + if (!assetPath.startsWith('assets/') || !REPORT_TEMPLATE_ALLOWED_ASSET_EXTENSIONS.has(path.posix.extname(assetPath).toLowerCase())) { + throw new ReportTemplateError('unsafe_url', `${fileName} 只允许 assets/ 下的图片资源`) + } + } + if (['href', 'action', 'poster'].includes(name)) throw new ReportTemplateError('unsafe_url', `${fileName} 不允许 URL 属性`) + } + if (tag === 'style') { + const text = (node.childNodes || []).map((child) => (child as { value?: string }).value || '').join('') + if (/\{\{[A-Z0-9_]+\}\}/.test(text)) throw new ReportTemplateError('invalid_placeholder_context', '占位符不能出现在 style 中') + validateCss(text, fileName) + } + for (const child of (node.childNodes || []) as Array<{ nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }>) visit(child) + } + visit(fragment) +} + +function collectTemplateAssetRefs(html: string): string[] { + const refs: string[] = [] + const fragment = parseFragment(html) + const visit = (node: { nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }): void => { + if (node.nodeName.toLowerCase() === 'img') { + const src = node.attrs?.find((attr) => attr.name.toLowerCase() === 'src')?.value + if (src) refs.push(path.posix.normalize(src.replace(/\\/g, '/'))) + } + for (const child of (node.childNodes || []) as Array<{ nodeName: string; attrs?: Array<{ name: string; value: string }>; childNodes?: unknown[] }>) visit(child) + } + visit(fragment) + return refs +} + +async function readIndex(directory: string): Promise { + try { + const value = JSON.parse(await fs.readFile(path.join(directory, INDEX_FILE), 'utf8')) as Partial + if (value.version !== 1 || !Array.isArray(value.templates)) throw new Error('invalid index') + return { version: 1, templates: value.templates } + } catch { + return { version: 1, templates: [] } + } +} + +async function writeIndex(directory: string, index: TemplateIndex): Promise { + const temporary = `${path.join(directory, INDEX_FILE)}.tmp-${process.pid}-${Date.now()}` + await fs.writeFile(temporary, JSON.stringify(index, null, 2), 'utf8') + await fs.rename(temporary, path.join(directory, INDEX_FILE)) +} + +function builtInTemplate(template: ReportTemplateDefinition): InstalledReportTemplate { + const resourceRoot = path.join(process.resourcesPath || process.cwd(), 'resources') + const entryPath = path.join(resourceRoot, template.resourceFile) + return { + id: template.id, + version: 'builtin', + interfaceVersion: REPORT_TEMPLATE_INTERFACE_VERSION, + source: 'builtin', + name: template.name, + author: 'TraceMemo', + entryPath, + capture: { width: template.captureWidth, maxWidth: template.maxCaptureWidth, maxHeight: 20000 }, + license: { spdx: 'NOASSERTION' } + } +} + +export class ReportTemplateService { + private readonly directory = rootPath() + + async list(): Promise { + await fs.mkdir(path.join(this.directory, INSTALLED_DIR), { recursive: true }) + const index = await readIndex(this.directory) + return [builtInTemplate(DEFAULT_REPORT_TEMPLATE), ...REPORT_TEMPLATES.map(builtInTemplate), ...index.templates] + } + + async resolve(ref: ReportTemplateRef): Promise { + const id = String(ref?.id || '').trim() + if (!id) throw new ReportTemplateError('missing_template', '缺少模板 ID') + const templates = await this.list() + const candidates = templates.filter((template) => template.id === id) + if (!candidates.length) throw new ReportTemplateError('template_not_found', `模板不存在:${id}`) + if (candidates[0].source === 'builtin') { + throw new ReportTemplateError('builtin_template_ref', `外部模板引用不能使用内置模板:${id}`) + } + const found = ref.version ? candidates.find((template) => template.version === ref.version) : candidates.sort((a, b) => b.version.localeCompare(a.version))[0] + if (!found) throw new ReportTemplateError('template_version_not_found', `模板版本不存在:${id}@${ref.version}`) + if (found.interfaceVersion !== REPORT_TEMPLATE_INTERFACE_VERSION) throw new ReportTemplateError('unsupported_interface', `模板接口版本不兼容:${found.interfaceVersion}`) + return found + } + + async install(packagePath: string, expectedRef?: ReportTemplateRef): Promise { + const stat = await fs.stat(packagePath) + if (!stat.isFile() || stat.size > REPORT_TEMPLATE_LIMITS.maxCompressedBytes) throw new ReportTemplateError('package_too_large', '模板 ZIP 超过大小限制') + const archive = await Open.file(packagePath) + if (archive.files.length === 0 || archive.files.length > REPORT_TEMPLATE_LIMITS.maxFiles) throw new ReportTemplateError('too_many_files', '模板包文件数量无效') + const seen = new Set() + const entries: EntryInfo[] = [] + let declaredTotal = 0 + for (const entry of archive.files) { + const normalized = validateRelativePath(entry.path, 'ZIP 条目') + const collisionKey = normalized.toLowerCase() + if (seen.has(collisionKey)) throw new ReportTemplateError('duplicate_entry', `ZIP 条目规范化后重复:${normalized}`) + seen.add(collisionKey) + if (entry.type !== 'Directory' && entry.type !== 'File') throw new ReportTemplateError('unsafe_entry', `ZIP 条目类型不受支持:${normalized}`) + const type = entry.type === 'Directory' ? 'directory' : 'file' + const size = Number(entry.uncompressedSize || 0) + if (type === 'file' && size > REPORT_TEMPLATE_LIMITS.maxFileBytes) throw new ReportTemplateError('file_too_large', `ZIP 条目过大:${normalized}`) + if (type === 'file') declaredTotal += size + if (declaredTotal > REPORT_TEMPLATE_LIMITS.maxExtractedBytes) throw new ReportTemplateError('package_too_large', '模板包解压体积超过限制') + if (type === 'file' && (normalized.endsWith('.html') || normalized.endsWith('.css')) && size > REPORT_TEMPLATE_LIMITS.maxHtmlBytes) throw new ReportTemplateError('file_too_large', `模板文件过大:${normalized}`) + entries.push({ path: normalized, type, size, entry }) + } + const manifestEntry = entries.find((entry) => entry.path === 'manifest.json' && entry.type === 'file') + if (!manifestEntry) throw new ReportTemplateError('invalid_manifest', '模板包缺少 manifest.json') + const manifest = parseManifest(JSON.parse((await manifestEntry.entry.buffer()).toString('utf8'))) + if (manifest.id.startsWith('builtin.')) throw new ReportTemplateError('reserved_id', '外部模板不能使用内置命名空间') + if ( + expectedRef && + (manifest.id !== expectedRef.id || manifest.templateVersion !== expectedRef.version) + ) { + throw new ReportTemplateError( + 'catalog_manifest_mismatch', + `模板包 manifest 与目录条目不一致:${manifest.id}@${manifest.templateVersion}` + ) + } + const entry = entries.find((item) => item.path === manifest.entry && item.type === 'file') + if (!entry) throw new ReportTemplateError('missing_entry', 'manifest.entry 文件不存在') + const html = (await entry.entry.buffer()).toString('utf8') + validateReportTemplateHtml(html, manifest.entry) + const assetPaths = new Set(entries.filter((item) => item.type === 'file').map((item) => item.path)) + for (const asset of collectTemplateAssetRefs(html)) { + if (!assetPaths.has(asset)) throw new ReportTemplateError('missing_asset', `模板图片资源不存在:${asset}`) + } + const preview = manifest.preview ? entries.find((item) => item.path === manifest.preview && item.type === 'file') : undefined + if (manifest.preview && !preview) throw new ReportTemplateError('missing_preview', 'manifest.preview 文件不存在') + for (const item of entries.filter((candidate) => candidate.type === 'file')) { + const ext = path.extname(item.path).toLowerCase() + if (item.path !== 'manifest.json' && item.path !== manifest.entry && item.path !== manifest.preview && !REPORT_TEMPLATE_ALLOWED_ASSET_EXTENSIONS.has(ext)) { + throw new ReportTemplateError('unsupported_resource', `不支持的模板资源:${item.path}`) + } + } + const staging = path.join(this.directory, STAGING_DIR, `${process.pid}-${Date.now()}-${crypto.randomUUID()}`) + await fs.mkdir(staging, { recursive: true }) + try { + let actualTotal = 0 + for (const item of entries) { + const destination = path.resolve(staging, item.path) + if (!isWithin(staging, destination)) throw new ReportTemplateError('invalid_path', `ZIP 条目越界:${item.path}`) + if (item.type === 'directory') { + await fs.mkdir(destination, { recursive: true }) + continue + } + await fs.mkdir(path.dirname(destination), { recursive: true }) + const stream = item.entry.stream() + const chunks: Buffer[] = [] + for await (const chunk of stream) { + const buffer = Buffer.from(chunk as Uint8Array) + actualTotal += buffer.length + if (actualTotal > REPORT_TEMPLATE_LIMITS.maxExtractedBytes || buffer.length > REPORT_TEMPLATE_LIMITS.maxFileBytes) throw new ReportTemplateError('package_too_large', '模板包实际解压体积超过限制') + chunks.push(buffer) + } + await fs.writeFile(destination, Buffer.concat(chunks)) + const fileStat = await fs.lstat(destination) + if (!fileStat.isFile()) throw new ReportTemplateError('invalid_file', `模板文件类型无效:${item.path}`) + } + const target = path.join(this.directory, INSTALLED_DIR, manifest.id, manifest.templateVersion) + const existingIndex = await readIndex(this.directory) + const existing = existingIndex.templates.find((item) => item.id === manifest.id && item.version === manifest.templateVersion) + const digest = crypto.createHash('sha256').update(await fs.readFile(packagePath)).digest('hex') + if (existing) { + if (existing.sha256 !== digest) throw new ReportTemplateError('version_conflict', `模板版本已存在但内容不同:${manifest.id}@${manifest.templateVersion}`) + return existing + } + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.rename(staging, target) + const installed: InstalledReportTemplate = { + id: manifest.id, version: manifest.templateVersion, interfaceVersion: manifest.interfaceVersion, + source: 'installed', name: manifest.name, author: manifest.author.name, + entryPath: path.join(target, manifest.entry), ...(manifest.preview ? { previewPath: path.join(target, manifest.preview) } : {}), + capture: manifest.capture, license: manifest.license, packagePath, sha256: digest, installedAt: new Date().toISOString() + } + existingIndex.templates = [...existingIndex.templates.filter((item) => !(item.id === installed.id && item.version === installed.version)), installed] + await writeIndex(this.directory, existingIndex) + return installed + } finally { + // 同版本幂等返回也必须清理 staging,避免重复安装逐渐堆积临时目录。 + await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined) + } + } + + async uninstall(id: string, version: string): Promise { + if (!SAFE_ID.test(id) || !SAFE_VERSION.test(version)) { + throw new ReportTemplateError('invalid_template_ref', '模板 ID 或版本无效') + } + const index = await readIndex(this.directory) + const target = index.templates.find((item) => item.id === id && item.version === version) + if (!target) throw new ReportTemplateError('template_not_found', `模板版本不存在:${id}@${version}`) + await fs.rm(path.join(this.directory, INSTALLED_DIR, id, version), { recursive: true, force: true }) + index.templates = index.templates.filter((item) => item !== target) + await writeIndex(this.directory, index) + } + + async recover(): Promise { + const installedRoot = path.join(this.directory, INSTALLED_DIR) + await fs.mkdir(installedRoot, { recursive: true }) + const previous = await readIndex(this.directory) + const recovered: InstalledReportTemplate[] = [] + for (const id of await fs.readdir(installedRoot)) { + for (const version of await fs.readdir(path.join(installedRoot, id))) { + const entryPath = path.join(installedRoot, id, version) + try { + const manifest = parseManifest(JSON.parse(await fs.readFile(path.join(entryPath, 'manifest.json'), 'utf8'))) + const recoveredEntryPath = path.join(entryPath, manifest.entry) + const entryStat = await fs.lstat(recoveredEntryPath) + if (!entryStat.isFile()) continue + const candidate = previous.templates.find((item) => item.id === manifest.id && item.version === manifest.templateVersion) + recovered.push(candidate && candidate.source === 'installed' + ? { ...candidate, entryPath: recoveredEntryPath, previewPath: manifest.preview ? path.join(entryPath, manifest.preview) : undefined } + : { + id: manifest.id, + version: manifest.templateVersion, + interfaceVersion: manifest.interfaceVersion, + source: 'installed', + name: manifest.name, + author: manifest.author.name, + entryPath: recoveredEntryPath, + ...(manifest.preview ? { previewPath: path.join(entryPath, manifest.preview) } : {}), + capture: manifest.capture, + license: manifest.license + }) + } catch { /* 忽略损坏的安装目录,保留其他模板 */ } + } + } + await writeIndex(this.directory, { version: 1, templates: recovered }) + } +} + +export const reportTemplateService = new ReportTemplateService() diff --git a/src/main/report-template-test-entry.ts b/src/main/report-template-test-entry.ts new file mode 100644 index 0000000..75b3ae4 --- /dev/null +++ b/src/main/report-template-test-entry.ts @@ -0,0 +1,44 @@ +import { app, BrowserWindow, ipcMain } from 'electron' +import path from 'node:path' + +const userData = process.env.TRACEMEMO_TEMPLATE_TEST_USER_DATA +if (!userData) throw new Error('TRACEMEMO_TEMPLATE_TEST_USER_DATA is required') +app.setPath('userData', userData) +app.setPath('logs', path.join(userData, 'logs')) + +app.whenReady().then(async () => { + const { registerReportTemplateIpc } = await import('./report-template-ipc') + const { reportTemplateService } = await import('./report-template-service') + const { + deleteGeneratedReport, + listGeneratedReports, + prepareGeneratedReportTemplateSwitch, + saveGeneratedReport, + updateGeneratedReportTemplate + } = await import('./report-history-service') + const { exportGroupReportSnapshot, extractGroupReportRenderSnapshot } = await import('./group-report-service') + await reportTemplateService.recover() + registerReportTemplateIpc() + ipcMain.handle('report:listGenerated', () => listGeneratedReports()) + ipcMain.handle('report:saveGenerated', (_, request) => saveGeneratedReport(request)) + ipcMain.handle('report:updateGeneratedTemplate', (_, request) => updateGeneratedReportTemplate(request)) + ipcMain.handle('report:prepareTemplateSwitch', (_, request: { reportId: string }) => + prepareGeneratedReportTemplateSwitch(request.reportId, extractGroupReportRenderSnapshot) + ) + ipcMain.handle('report:deleteGenerated', (_, reportId: string) => deleteGeneratedReport(reportId)) + ipcMain.handle('report:exportSnapshot', (_, request) => exportGroupReportSnapshot(request)) + const window = new BrowserWindow({ + width: 1200, + height: 800, + show: true, + webPreferences: { + preload: path.join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false + } + }) + await window.loadFile(path.join(__dirname, '../renderer/index.html')) +}) + +app.on('window-all-closed', () => app.quit()) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index be39348..28dab2c 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -5,6 +5,11 @@ import { GroupReportExportResult, GroupReportRenderSnapshotExportRequest } from '../shared/group-report' +import type { InstalledReportTemplate, ReportTemplateOperationResult } from '../shared/report-template-package' +import type { + ReportTemplateCatalogInstallResult, + ReportTemplateCatalogResult +} from '../shared/report-template-market' import { LocalApiTestRequest, LocalApiTestResponse } from '../shared/local-api-test' import { DeleteGeneratedReportResult, @@ -400,6 +405,17 @@ declare global { exportGroupReportSnapshot: ( request: GroupReportRenderSnapshotExportRequest ) => Promise + listReportTemplates: () => Promise + installReportTemplate: (packagePath: string) => Promise + uninstallReportTemplate: ( + id: string, + version: string + ) => Promise + listReportTemplateCatalog: () => Promise + installReportTemplateFromCatalog: ( + id: string, + version: string + ) => Promise prepareGeneratedReportTemplateSwitch: ( reportId: string ) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index be7f9b1..4ccf673 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,11 @@ import type { GroupReportExportRequest, GroupReportRenderSnapshotExportRequest } from '../shared/group-report' +import type { ReportTemplateOperationResult } from '../shared/report-template-package' +import type { + ReportTemplateCatalogInstallResult, + ReportTemplateCatalogResult +} from '../shared/report-template-market' import type { SaveGeneratedReportRequest, UpdateGeneratedReportTemplateRequest @@ -295,6 +300,18 @@ const api = { ipcRenderer.invoke('report:export', request), exportGroupReportSnapshot: (request: GroupReportRenderSnapshotExportRequest) => ipcRenderer.invoke('report:exportSnapshot', request), + listReportTemplates: () => ipcRenderer.invoke('report-template:list'), + installReportTemplate: (packagePath: string): Promise => + ipcRenderer.invoke('report-template:install', packagePath), + uninstallReportTemplate: (id: string, version: string): Promise => + ipcRenderer.invoke('report-template:uninstall', id, version), + listReportTemplateCatalog: (): Promise => + ipcRenderer.invoke('report-template-market:list'), + installReportTemplateFromCatalog: ( + id: string, + version: string + ): Promise => + ipcRenderer.invoke('report-template-market:install', id, version), prepareGeneratedReportTemplateSwitch: (reportId: string) => ipcRenderer.invoke('report:prepareTemplateSwitch', { reportId }), listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5499a5a..74ca2e6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -37,7 +37,7 @@ import { } from './utils/message-pages' import { isRelevantMessageMonitorEvent, parseWcdbMonitorEvent } from './utils/message-monitor' import { enrichQuotedMessages } from './utils/quoted-messages' -import type { SelectableReportTemplateId } from '../../shared/report-templates' +import type { ReportTemplateSelectionId } from '../../shared/report-templates' import { switchGeneratedReportTemplate } from './utils/report-template-switch' import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-environment' import { useToast } from './components/ui' @@ -1704,7 +1704,7 @@ function App(): React.ReactElement { const handleSwitchReportTemplate = async ( report: GeneratedReportRecord, - templateId: SelectableReportTemplateId + templateId: ReportTemplateSelectionId ): Promise<{ success: boolean; error?: string }> => { try { const updated = await withTimeout( diff --git a/src/renderer/src/components/reports/AiReportWorkspace.tsx b/src/renderer/src/components/reports/AiReportWorkspace.tsx index e0d010a..a31e989 100644 --- a/src/renderer/src/components/reports/AiReportWorkspace.tsx +++ b/src/renderer/src/components/reports/AiReportWorkspace.tsx @@ -16,7 +16,7 @@ import { ReportRangeSelector } from './ReportRangeSelector' import { ReportMemberNameSelector } from './ReportMemberNameSelector' import { ReportGroupMemberSelector } from './ReportGroupMemberSelector' import { ReportSectionSelector } from './ReportSectionSelector' -import { ReportTemplateSelector, SelectableReportTemplateId } from './ReportTemplateSelector' +import { ReportTemplateSelector, ReportTemplateSelectionId } from './ReportTemplateSelector' import { Button, Input } from '../ui' interface AiReportWorkspaceProps { @@ -47,8 +47,8 @@ interface AiReportWorkspaceProps { onRevealReport: () => Promise<{ success: boolean; error?: string }> onViewResult: () => void hasReportResult: boolean - templateId: SelectableReportTemplateId - onTemplateIdChange: (value: SelectableReportTemplateId) => void + templateId: ReportTemplateSelectionId + onTemplateIdChange: (value: ReportTemplateSelectionId) => void memberNamePreference: ReportMemberNamePreference onMemberNamePreferenceChange: (value: ReportMemberNamePreference) => void reportTimeoutSeconds: number diff --git a/src/renderer/src/components/reports/ReportTemplateSelector.tsx b/src/renderer/src/components/reports/ReportTemplateSelector.tsx index c465cfe..d3e813f 100644 --- a/src/renderer/src/components/reports/ReportTemplateSelector.tsx +++ b/src/renderer/src/components/reports/ReportTemplateSelector.tsx @@ -1,10 +1,16 @@ -import React, { useRef, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { DEFAULT_REPORT_TEMPLATE, REPORT_TEMPLATES, + encodeExternalReportTemplateId, type ReportTemplateDefinition, - type SelectableReportTemplateId + type ReportTemplateSelectionId } from '../../../../shared/report-templates' +import type { + ReportTemplateCatalog, + ReportTemplateCatalogEntry +} from '../../../../shared/report-template-market' +import type { InstalledReportTemplate } from '../../../../shared/report-template-package' import { Button, Dialog, @@ -18,14 +24,30 @@ import { RadioGroupItem } from '../ui' -export type { SelectableReportTemplateId } from '../../../../shared/report-templates' +export type { + SelectableReportTemplateId, + ReportTemplateSelectionId +} from '../../../../shared/report-templates' interface ReportTemplateSelectorProps { - value: SelectableReportTemplateId - onChange: (value: SelectableReportTemplateId) => void + value: ReportTemplateSelectionId + onChange: (value: ReportTemplateSelectionId) => void disabled?: boolean } +type PreviewItem = + | { kind: 'builtin'; template: ReportTemplateDefinition } + | { + kind: 'external' + id: string + version: string + name: string + description: string + installed: boolean + platform?: 'default' | 'mobile' | 'desktop' + preview?: string + } + const TemplateDiagram = ({ template }: { @@ -69,11 +91,100 @@ export const ReportTemplateSelector: React.FC = ({ onChange, disabled }) => { - const [previewing, setPreviewing] = useState(null) + const [previewing, setPreviewing] = useState(null) + const [installed, setInstalled] = useState([]) + const [catalog, setCatalog] = useState(null) + const [marketError, setMarketError] = useState('') + const [marketBusyKey, setMarketBusyKey] = useState('') const previewTriggerRef = useRef(null) const mobileTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'mobile') const desktopTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'desktop') + const refreshMarket = async (): Promise => { + if (typeof window === 'undefined' || !window.api) return + if (typeof window.api.listReportTemplates === 'function') { + try { + const listed = await window.api.listReportTemplates() + if (Array.isArray(listed)) setInstalled(listed) + } catch (error) { + setMarketError(error instanceof Error ? error.message : '已安装模板加载失败') + } + } + if (typeof window.api.listReportTemplateCatalog !== 'function') return + try { + const result = await window.api.listReportTemplateCatalog() + if (result.success && result.catalog) { + setCatalog(result.catalog) + setMarketError('') + } else { + setMarketError(result.error || '远端模板目录加载失败') + } + } catch (error) { + setMarketError(error instanceof Error ? error.message : '远端模板目录加载失败') + } + } + + useEffect(() => { + void refreshMarket() + }, []) + + const externalInstalled = useMemo( + () => installed.filter((template) => template.source === 'installed'), + [installed] + ) + const catalogEntries = catalog?.templates || [] + const installedKeys = useMemo( + () => new Set(externalInstalled.map((template) => encodeExternalReportTemplateId(template.id, template.version))), + [externalInstalled] + ) + + const platformLabel = (platform?: 'default' | 'mobile' | 'desktop'): string => + platform === 'desktop' ? '桌面宽屏' : platform === 'default' ? '经典长图' : '手机长图' + + const catalogEntryFor = (template: InstalledReportTemplate): ReportTemplateCatalogEntry | undefined => + catalogEntries.find((entry) => entry.id === template.id && entry.version === template.version) + + const installFromCatalog = async (entry: ReportTemplateCatalogEntry): Promise => { + const key = encodeExternalReportTemplateId(entry.id, entry.version) + if (typeof window.api.installReportTemplateFromCatalog !== 'function') { + setMarketError('当前 TraceMemo 版本不支持模板市场安装') + return + } + setMarketBusyKey(key) + setMarketError('') + try { + const result = await window.api.installReportTemplateFromCatalog(entry.id, entry.version) + if (!result.success) { + setMarketError(result.error || `模板安装失败:${entry.name}`) + return + } + await refreshMarket() + } catch (error) { + setMarketError(error instanceof Error ? error.message : `模板安装失败:${entry.name}`) + } finally { + setMarketBusyKey('') + } + } + + const uninstallExternal = async (template: InstalledReportTemplate): Promise => { + const key = encodeExternalReportTemplateId(template.id, template.version) + setMarketBusyKey(key) + setMarketError('') + try { + const result = await window.api.uninstallReportTemplate(template.id, template.version) + if (!result.success) { + setMarketError(result.error || `模板卸载失败:${template.name}`) + return + } + if (value === key) onChange('v1') + await refreshMarket() + } catch (error) { + setMarketError(error instanceof Error ? error.message : `模板卸载失败:${template.name}`) + } finally { + setMarketBusyKey('') + } + } + const renderGroup = ( title: string, templates: readonly ReportTemplateDefinition[] @@ -106,7 +217,7 @@ export const ReportTemplateSelector: React.FC = ({ size="sm" onClick={(event) => { previewTriggerRef.current = event.currentTarget - setPreviewing(template) + setPreviewing({ kind: 'builtin', template }) }} > 查看版式 @@ -128,12 +239,134 @@ export const ReportTemplateSelector: React.FC = ({ className="report-template-catalog" value={value} disabled={disabled} - onValueChange={(nextValue) => onChange(nextValue as SelectableReportTemplateId)} + onValueChange={(nextValue) => onChange(nextValue as ReportTemplateSelectionId)} > {renderGroup('默认模板', [DEFAULT_REPORT_TEMPLATE])} {renderGroup('手机端 · 375–414 px', mobileTemplates)} {renderGroup('电脑端 · 1280–1920 px', desktopTemplates)} + {externalInstalled.length > 0 && ( +
+
已安装市场模板
+
+ {externalInstalled.map((template) => { + const key = encodeExternalReportTemplateId(template.id, template.version) + const entry = catalogEntryFor(template) + const active = value === key + return ( +
+ +
+ + +
+
+ ) + })} +
+
+ )} + {catalogEntries.filter((entry) => !installedKeys.has(encodeExternalReportTemplateId(entry.id, entry.version))).length > 0 && ( +
+
模板市场
+
+ {catalogEntries + .filter((entry) => !installedKeys.has(encodeExternalReportTemplateId(entry.id, entry.version))) + .map((entry) => { + const key = encodeExternalReportTemplateId(entry.id, entry.version) + return ( +
+
+
远端 · v{entry.version}
+
{entry.name}
+
{entry.description}
+
+ 接口 {entry.interfaceVersion} · {platformLabel(entry.platform)} · {entry.sizeBytes} bytes +
+
+
+ {entry.preview && ( + + )} + +
+
+ ) + })} +
+
+ )} + {marketError &&

模板市场:{marketError}

} { @@ -151,20 +384,32 @@ export const ReportTemplateSelector: React.FC = ({
- {previewing.label} - {previewing.name} + + {previewing.kind === 'builtin' + ? previewing.template.label + : `市场 · v${previewing.version}`} + + + {previewing.kind === 'builtin' ? previewing.template.name : previewing.name} +
- {previewing.platform === 'desktop' - ? '桌面宽屏' - : previewing.platform === 'default' - ? '经典长图' - : '手机长图'} + {platformLabel( + previewing.kind === 'builtin' ? previewing.template.platform : previewing.platform + )}
- {previewing.tagline} + + {previewing.kind === 'builtin' ? previewing.template.tagline : previewing.description} +
- + {previewing.kind === 'builtin' ? ( + + ) : previewing.preview ? ( + {`${previewing.name} + ) : ( + + )} 生成时会自动代入当前群聊的真实头像、昵称、消息、讨论摘要、Q&A、统计与关键词。 @@ -172,14 +417,22 @@ export const ReportTemplateSelector: React.FC = ({ - + {previewing.kind === 'builtin' || previewing.installed ? ( + + ) : ( + + )} )} diff --git a/src/renderer/src/components/reports/ReportToolbar.tsx b/src/renderer/src/components/reports/ReportToolbar.tsx index 6165eb8..c2acafd 100644 --- a/src/renderer/src/components/reports/ReportToolbar.tsx +++ b/src/renderer/src/components/reports/ReportToolbar.tsx @@ -1,8 +1,10 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import { SELECTABLE_REPORT_TEMPLATES, - type SelectableReportTemplateId + encodeExternalReportTemplateId, + type ReportTemplateSelectionId } from '../../../../shared/report-templates' +import type { InstalledReportTemplate } from '../../../../shared/report-template-package' import { Button, DropdownMenu, @@ -20,9 +22,9 @@ interface ReportToolbarProps { canSwitchTemplate: boolean canSendToGroup?: boolean sendToGroupHint?: string - currentTemplateId?: SelectableReportTemplateId + currentTemplateId?: ReportTemplateSelectionId isSwitchingTemplate: boolean - onSwitchTemplate: (templateId: SelectableReportTemplateId) => void + onSwitchTemplate: (templateId: ReportTemplateSelectionId) => void onRegenerate: () => void onCopyImage: () => void onReveal: () => void @@ -46,6 +48,16 @@ export function ReportToolbar({ onShare, onSendToGroup }: ReportToolbarProps): React.ReactElement { + const [installedTemplates, setInstalledTemplates] = useState([]) + + useEffect(() => { + if (typeof window === 'undefined' || !window.api || typeof window.api.listReportTemplates !== 'function') return + void window.api + .listReportTemplates() + .then((templates) => setInstalledTemplates(Array.isArray(templates) ? templates.filter((template) => template.source === 'installed') : [])) + .catch(() => setInstalledTemplates([])) + }, []) + return (
@@ -81,6 +93,24 @@ export function ReportToolbar({ )} ))} + {installedTemplates.map((template) => { + const templateId = encodeExternalReportTemplateId(template.id, template.version) + return ( + onSwitchTemplate(templateId)} + > + 市场 + + {template.name} + + {templateId === currentTemplateId && ( + 当前 + )} + + ) + })}