${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}
}