import { app, BrowserWindow } from 'electron' import fs from 'fs-extra' import os from 'os' import path from 'path' import { GroupReportExportRequest, GroupReportExportResult, GroupReportMetadata, GroupReportRenderSnapshot, GroupReportRenderSnapshotExportRequest, ReportHeat, ReportSectionMeta, selectHeroParticipantNames } from '../shared/group-report' import { resolveMd5, getGroupSnapshot } from './services/chat-service' import { imageInsightService } from './services/image-insight-service' import { getReportTemplate } from '../shared/report-templates' const LEGACY_TEMPLATE_FILES: Record = { v1: 'mobile_daily_report_v1.html', v2: 'mobile_daily_report_v2.html' } const templatePath = (templateId?: string): string => { const name = LEGACY_TEMPLATE_FILES[templateId || ''] || getReportTemplate(templateId).resourceFile const candidates = [ path.join(process.resourcesPath, 'resources', name), path.join(app.getAppPath(), 'resources', name), path.join(process.cwd(), 'resources', name) ] const found = candidates.find((candidate) => fs.existsSync(candidate)) if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`) return found } const escapeHtml = (value: unknown): string => String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') const sanitizeFileName = (value: string): string => value .replace(/[\\/:*?"<>|]/g, '_') .replace(/\s+/g, ' ') .trim() || '未命名群聊' const hashName = (name: string): number => { let hash = 0 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0 return hash } const fallbackAvatar = (name: string): string => { const hue = hashName(name) % 360 const initial = escapeHtml(Array.from(name.trim())[0] || '?') const svg = `${initial}` return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` } const imageMimeType = (contentType: string | null, source: string): string => { if (contentType?.startsWith('image/')) return contentType.split(';')[0] const extension = path.extname(source).toLowerCase() if (extension === '.png') return 'image/png' if (extension === '.webp') return 'image/webp' if (extension === '.gif') return 'image/gif' return 'image/jpeg' } const embedAvatar = async (source: string | undefined, name: string): Promise => { if (!source) return fallbackAvatar(name) if (/^data:image\/[a-z0-9.+/-]+;base64,[a-z0-9+/=]+$/i.test(source)) return source try { if (/^https?:\/\//i.test(source)) { const response = await fetch(source, { headers: { 'User-Agent': 'Mozilla/5.0 TraceMemo', Referer: 'https://weixin.qq.com/' }, signal: AbortSignal.timeout(8000) }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const mime = imageMimeType(response.headers.get('content-type'), source) return `data:${mime};base64,${Buffer.from(await response.arrayBuffer()).toString('base64')}` } const localPath = source.startsWith('file://') ? new URL(source) : source const buffer = await fs.readFile(localPath) return `data:${imageMimeType(null, source)};base64,${buffer.toString('base64')}` } catch (error) { console.warn(`[GroupReport] avatar fallback for ${name}:`, error) return fallbackAvatar(name) } } /** * 从群成员快照反推真头像,填进 metadata.avatars。 * - 没传 talker → 跳过(向后兼容) * - talker 解析失败 / snapshot 拿不到 → 200 + warn,继续走 fallback * - 客户端传的 avatars[name](非空)优先;否则从 snapshot 的 m_nsHeadImgUrl 补 * - 同名取首条(P2 风险:群里两人同名) */ const enrichAvatarsFromGroup = async (metadata: GroupReportMetadata): Promise => { if (!metadata.talker) return const resolved = resolveMd5(metadata.talker) if (!resolved) { metadata.warnings = metadata.warnings ?? [] metadata.warnings.push(`enrich skipped: talker "${metadata.talker}" not found`) return } const snapshot = getGroupSnapshot(resolved.md5) if (!snapshot) { metadata.warnings = metadata.warnings ?? [] metadata.warnings.push(`enrich skipped: group snapshot not available for "${metadata.talker}"`) return } const index = new Map() for (const member of snapshot.members) { if (member.nickname && member.avatar && !index.has(member.nickname)) { index.set(member.nickname, member.avatar) } } metadata.avatars = metadata.avatars ?? {} for (const [name, url] of index) { if (metadata.avatars[name]) continue metadata.avatars[name] = url } metadata.warnings = metadata.warnings ?? [] metadata.warnings.push( `enriched ${index.size} member avatars from snapshot (${snapshot.memberCount} members)` ) } const heatClass = (heat: ReportHeat): string => { if (heat === '高') return 'hot' if (heat === '低') return 'blue' return '' } const replacePlaceholder = (html: string, key: string, value: string): string => html.replaceAll(`{{${key}}}`, value) const sectionMeta = ( request: GroupReportExportRequest, key: keyof NonNullable ): ReportSectionMeta | undefined => request.report.sectionMeta?.[key] const sectionClass = ( request: GroupReportExportRequest, key: keyof NonNullable, hasContent: boolean ): string => (sectionMeta(request, key)?.enabled && hasContent ? '' : 'empty-section') const overflowNote = ( request: GroupReportExportRequest, key: keyof NonNullable ): string => { const meta = sectionMeta(request, key) if (request.metadata.reportMode !== 'compact' || !meta?.hiddenCount) return '' return `
另有 ${meta.hiddenCount} 条内容,请在完整版中查看
` } const renderReportHtml = async (request: GroupReportExportRequest): Promise => { const { report, metadata } = request const template = getReportTemplate(request.templateId) 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)) report.quotes.forEach((quote) => quote.messages.forEach((message) => avatarNames.add(message.sender)) ) report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name)) report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender)) report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner)) const avatars = new Map() await Promise.all( Array.from(avatarNames).map(async (name) => { avatars.set(name, await embedAvatar(metadata.avatars[name], name)) }) ) const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name) const heroNames = selectHeroParticipantNames(metadata.heroParticipants) const heroAvatars = heroNames .map((name) => `${escapeHtml(name)}`) .join('') const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section' const topicCards = report.topics .map( (topic) => `

${escapeHtml(topic.title)}

${escapeHtml(topic.heat)}热
${escapeHtml(topic.timeRange)}

${escapeHtml(topic.summary)}

${ topic.conclusions?.length ? `
${topic.conclusions .slice(0, 2) .map((entry) => `
${escapeHtml(entry.text)}
`) .join('')}
` : topic.conclusion ? `
${escapeHtml(topic.conclusion)}
` : '' } ${ topic.image ? (() => { // 优先用已有 imageUrl;若有 imageHash(来自 visionGallery),按 hash 取原图 let imageUrl = topic.image.imageUrl if (!imageUrl && topic.image.imageHash) { const insight = imageInsightService.getInsight(topic.image.imageHash) if (insight) { // insight 不含 imageUrl,需要按 md5/datName 重新拿;这里通过 ImageDecryptService 间接获取 // 走 ImageDecryptService.findImageFile + decryptImageToBase64 const decryptService = ( globalThis as { __imageDecrypt?: { findImageFile: (md5?: string, dat?: string) => string | null decryptImageToBase64: (p: string) => string | null } } ).__imageDecrypt if (decryptService) { const filePath = decryptService.findImageFile(insight.md5, insight.datName) if (filePath) imageUrl = decryptService.decryptImageToBase64(filePath) || undefined } } } if (!imageUrl) return '' return `
热点图片
${escapeHtml(topic.image.note)}
` })() : '' }
${topic.participants .slice(0, 5) .map( (name) => `${escapeHtml(name)}` ) .join('')}
${topic.keywords.map((word) => `${escapeHtml(word)}`).join('')}
` ) .join('') const resourceItems = report.resources .map( (resource) => `
${escapeHtml(resource.title)}${resource.sender ? ` · ${escapeHtml(resource.sender)}` : ''}
${escapeHtml(resource.description)}
` ) .join('') const importantMessages = report.importantMessages .map( (message) => `
${escapeHtml(message.sender)}${escapeHtml(message.time)}
${escapeHtml(message.content)}
${escapeHtml(message.note)}
` ) .join('') const quoteBlocks = report.quotes .map( (quote) => `
${quote.messages .map( ( message ) => `
${escapeHtml(message.sender)}
${escapeHtml(message.content)}
` ) .join('')}
${escapeHtml(quote.note)}
` ) .join('') const todoCards = (report.todos || []) .map( (item) => `
${escapeHtml(item.task)}
${[item.owner || '', item.deadline || '', item.topic || ''].filter(Boolean).map(escapeHtml).join(' · ')}
${item.note ? `
${escapeHtml(item.note)}
` : ''}
` ) .join('') const unresolvedCards = (report.unresolved || []) .map( (item) => `
${escapeHtml(item.question)}
${[item.owner || '', item.lastDiscussedAt || '', item.status].filter(Boolean).map(escapeHtml).join(' · ')}
${escapeHtml(item.note)}
` ) .join('') const storylineCards = (report.storylines || []) .map( (item) => `

${escapeHtml(item.title)}

${item.stages .map( (stage) => `
${escapeHtml(stage.time || '--:--')} ${escapeHtml(stage.event)}
` ) .join('')}
${item.result ? `

${escapeHtml(item.result)}

` : ''}
` ) .join('') const reversalCards = (report.reversals || []) .map( (item) => `
${escapeHtml(item.topic)}
最初:${escapeHtml(item.initialView)}
后来:${escapeHtml(item.finalView)}
${item.note ? `
${escapeHtml(item.note)}
` : ''}
` ) .join('') const chainCards = (report.participantChains || []) .map( (item) => `

${escapeHtml(item.topic)}

${item.chain.map((node) => `${escapeHtml(node)}`).join('→')}
${item.note ? `

${escapeHtml(item.note)}

` : ''}
` ) .join('') // AI 图片理解结果板块(ImageInsight) // 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文 const visionCards = (report.media?.visionGallery || []) .filter((item) => item.imageUrl) // 只显示加载成功的图 .map( (item) => `
AI 识别的图片
${escapeHtml(item.sender)}${escapeHtml(item.time)}
${escapeHtml(item.description)}
${item.ocrText ? `
📝 ${escapeHtml(item.ocrText)}
` : ''} ${item.tags.length ? `
${item.tags.map((t) => `${escapeHtml(t)}`).join('')}
` : ''}
AI 图片识别
` ) .join('') const voiceCards = (report.media?.voiceHighlights || []) .map( (item) => `
${escapeHtml(item.title)} · ${escapeHtml(item.sender)}
${escapeHtml(item.note)}
` ) .join('') const voiceRankCards = (report.analytics.voiceLeaderboard || []) .map( (item, index) => `
${index + 1}. ${escapeHtml(item.sender)} ${item.count} 条 · ${item.durationSec} 秒
` ) .join('') const badgeCards = (report.media?.funBadges || []) .map( (item) => `
${escapeHtml(item.title)} ${escapeHtml(item.owner)}

${escapeHtml(item.note)}

` ) .join('') const rankItems = report.analytics.topSpeakers .slice(0, 5) .map( (speaker, index) => `
${index + 1}. ${escapeHtml(speaker.name)}${Math.max(0, speaker.count)} 条
` ) .join('') // v1 模板使用的水平条形热度图,渲染 top speakers 排行 const heatSpeakers = report.analytics.topSpeakers.slice(0, 8) const maxSpeakerCount = Math.max(1, ...heatSpeakers.map((speaker) => Math.max(0, speaker.count))) const heatBarsHtml = heatSpeakers .map((speaker) => { const count = Math.max(0, speaker.count) const width = Math.max(4, Math.round((count / maxSpeakerCount) * 100)) return `
${escapeHtml(speaker.name)} ${count}
` }) .join('') const cloudTags = report.keywords .slice(0, 15) .map( (word, index) => `${escapeHtml(word)}` ) .join('') const qaCards = report.qa .map( (item) => `
Q:${escapeHtml(item.question)}
A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}
` ) .join('') const summaryStats = report.summaryStats || { messageCount: metadata.messageCount, activeUsers: metadata.activeUsers, topicCount: report.topics.length, mediaCount: metadata.mediaMessageCount || 0, imageCount: metadata.imageCount || 0, voiceCount: metadata.voiceCount || 0, stickerCount: metadata.stickerCount || 0, conclusionCount: 0, todoCount: report.todos.length, unresolvedCount: report.unresolved.length } let html = await fs.readFile(templatePath(request.templateId), 'utf8') const values: Record = { TEMPLATE_CLASS: template.cssClass, TEMPLATE_LABEL: escapeHtml(template.label), TEMPLATE_NAME: escapeHtml(template.name), REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`), REPORT_DATE: escapeHtml(metadata.reportDate), REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact', GROUP_NAME: escapeHtml(metadata.groupName), DATE_RANGE: escapeHtml(metadata.dateRange), RECORD_NOTE: escapeHtml(metadata.recordNote), // v1 模板使用的 OVERVIEW(经典版以概览段落呈现) OVERVIEW: escapeHtml( report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报' ), // v2 模板使用的 hero-* HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'), HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview), HERO_TAKEAWAY: escapeHtml(report.hero?.keyTakeaway || ''), HERO_PENDING: escapeHtml(report.hero?.pendingNote || ''), HERO_STATUS_LINE: escapeHtml(report.hero?.statusLine || ''), HERO_STATUS_EMPTY_CLASS: report.hero?.statusLine ? '' : 'empty-section', HERO_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section', HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section', HERO_AVATARS: heroAvatars, HERO_AVATAR_CLASS: heroAvatarClass, MESSAGE_COUNT: String(summaryStats.messageCount), ACTIVE_USERS: String(summaryStats.activeUsers), TIME_SPAN: escapeHtml(metadata.timeSpan || ''), TOPIC_COUNT: String(summaryStats.topicCount), MEDIA_COUNT: String(summaryStats.mediaCount), TOPIC_CARDS: topicCards, TOPICS_EMPTY_CLASS: sectionClass(request, 'topics', report.topics.length > 0), TOPICS_MORE_NOTE: overflowNote(request, 'topics'), RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0), RESOURCE_ITEMS: resourceItems, RESOURCES_MORE_NOTE: overflowNote(request, 'resources'), MESSAGES_EMPTY_CLASS: sectionClass( request, 'importantMessages', report.importantMessages.length > 0 ), IMPORTANT_MESSAGES: importantMessages, MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'), QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0), QUOTE_BLOCKS: quoteBlocks, QUOTES_MORE_NOTE: overflowNote(request, 'moments'), ACTIONS_EMPTY_CLASS: sectionClass( request, 'actions', report.todos.length + report.unresolved.length > 0 ), TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section', TODO_CARDS: todoCards, UNRESOLVED_EMPTY_CLASS: report.unresolved?.length ? '' : 'empty-section', UNRESOLVED_CARDS: unresolvedCards, ACTIONS_MORE_NOTE: overflowNote(request, 'actions'), QA_EMPTY_CLASS: sectionClass(request, 'qa', report.qa.length > 0), QA_CARDS: qaCards, QA_MORE_NOTE: overflowNote(request, 'qa'), STORYLINES_EMPTY_CLASS: sectionClass(request, 'storylines', report.storylines?.length > 0), STORYLINE_CARDS: storylineCards, STORYLINES_MORE_NOTE: overflowNote(request, 'storylines'), REVERSALS_EMPTY_CLASS: sectionClass(request, 'reversals', report.reversals?.length > 0), REVERSAL_CARDS: reversalCards, REVERSALS_MORE_NOTE: overflowNote(request, 'reversals'), CHAINS_EMPTY_CLASS: sectionClass(request, 'chains', report.participantChains?.length > 0), CHAIN_CARDS: chainCards, CHAINS_MORE_NOTE: overflowNote(request, 'chains'), // AI 图片识别板块 VISION_EMPTY_CLASS: sectionClass( request, 'vision', (report.media?.visionGallery?.length ?? 0) > 0 ), VISION_CARDS: visionCards, VISION_TITLE: '📸 AI 识别的图片精选', VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0), VOICE_CARDS: voiceCards, VOICE_MORE_NOTE: overflowNote(request, 'voices'), VOICE_RANK_EMPTY_CLASS: sectionClass( request, 'voices', report.analytics.voiceLeaderboard?.length > 0 ), VOICE_RANK_CARDS: voiceRankCards, BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0), BADGE_CARDS: badgeCards, BADGES_MORE_NOTE: overflowNote(request, 'badges'), RANK_ITEMS: rankItems, ACTIVITY_TIMELINE: escapeHtml(report.analytics.activeTimeline), CONCLUSION_COUNT: String(summaryStats.conclusionCount), TODO_COUNT: String(summaryStats.todoCount), UNRESOLVED_COUNT: String(summaryStats.unresolvedCount), CLOUD_TAGS: cloudTags, KEYWORDS_EMPTY_CLASS: sectionClass(request, 'keywords', report.keywords.length > 0), KEYWORDS_MORE_NOTE: overflowNote(request, 'keywords'), ANALYTICS_EMPTY_CLASS: sectionClass(request, 'analytics', true), GENERATED_AT: escapeHtml(metadata.generatedAt), FOOTER_NOTE: escapeHtml(metadata.footerNote), // v1 模板独有:从 analytics.topSpeakers 渲染水平条形热度图 HEAT_BARS: heatBarsHtml } for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value) // 清空模板中残留的未使用占位符(模板独有但 values 没提供的键) html = html.replace(/\{\{[A-Z_]+\}\}/g, '') return html } const renderReportSnapshotHtml = async ( request: GroupReportRenderSnapshotExportRequest ): Promise => { const template = getReportTemplate(request.templateId) let html = await fs.readFile(templatePath(request.templateId), 'utf8') const values = { ...request.snapshot.values, TEMPLATE_CLASS: template.cssClass, TEMPLATE_LABEL: escapeHtml(template.label), TEMPLATE_NAME: escapeHtml(template.name), REPORT_TITLE: request.snapshot.values.REPORT_TITLE || escapeHtml(`${request.snapshot.groupName}日报`), 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, '') } export const extractGroupReportRenderSnapshot = async ( htmlPath: string, fallback: { groupName: string reportDate: string dateRange: string messageCount: number generatedAt: string } ): Promise => { let reportWindow: BrowserWindow | null = null try { reportWindow = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false } }) await reportWindow.loadFile(htmlPath) const extracted = (await reportWindow.webContents.executeJavaScript(`(() => { const one = (selector) => document.querySelector(selector) const text = (...selectors) => { for (const selector of selectors) { const value = one(selector)?.textContent?.trim() if (value) return value } return '' } const html = (...selectors) => { for (const selector of selectors) { const value = one(selector)?.innerHTML if (value?.trim()) return value } return '' } const sectionChildren = (selector) => { const section = one(selector) if (!section) return '' return Array.from(section.children) .filter((child) => !child.classList.contains('section-title')) .map((child) => child.outerHTML) .join('') } const sectionClass = (...selectors) => { for (const selector of selectors) { const section = one(selector) if (!section) continue return section.classList.contains('empty-section') || !sectionChildren(selector).trim() ? 'empty-section' : '' } return 'empty-section' } const statValues = Array.from(document.querySelectorAll('.hero .stat b, .report-stats .stat-block strong')) .map((node) => node.textContent?.trim() || '') const activity = Array.from(document.querySelectorAll('.analytics > .card, .section-analytics-heat .card')) .find((node) => node.textContent?.includes('活跃时间线')) ?.textContent?.replace(/^.*?活跃时间线[::]?/, '') .trim() || '' const legacyRanks = Array.from(document.querySelectorAll('.analytics .rank')) .map((node) => node.outerHTML) .join('') const legacyHeat = Array.from(document.querySelectorAll('.analytics > .heat-row')) .map((node) => node.outerHTML) .join('') const footerText = text('.footer', '.report-footer') .replaceAll('\\n', ' ') .replaceAll('\\r', ' ') .replaceAll('\\t', ' ') return { reportTitle: text('.hero h1', '.report-masthead h1', 'title'), reportDate: text('.report-date strong'), overview: text('.overview', '.report-lede'), recordNote: text('.record-note'), heroAvatars: html('.avatar-grid', '.report-hero-avatars'), messageCount: statValues[0] || '', activeUsers: statValues[1] || '', timeSpan: statValues[2] || '', topicCount: statValues[3] || '', topicCards: sectionChildren('.topics') || html('.section-topics .topics-grid'), importantMessages: sectionChildren('.messages') || html('.section-messages .section-body'), quoteBlocks: sectionChildren('.quotes') || html('.section-quotes .section-body'), qaCards: sectionChildren('.qa') || html('.section-qa .section-body'), resourceItems: sectionChildren('.resources') || html('.section-resources .section-body'), visionCards: sectionChildren('.vision') || html('.section-vision .vision-grid'), rankItems: legacyRanks || html('.section-analytics-rank .rank-list'), heatBars: legacyHeat || html('.section-analytics-heat .section-body'), activityTimeline: activity, cloudTags: html('.cloud-tags', '.section-keywords .cloud-tags'), footerText, classes: { topics: sectionClass('.topics', '.section-topics'), messages: sectionClass('.messages', '.section-messages'), quotes: sectionClass('.quotes', '.section-quotes'), qa: sectionClass('.qa', '.section-qa'), resources: sectionClass('.resources', '.section-resources'), vision: sectionClass('.vision', '.section-vision'), keywords: sectionClass('.cloud', '.section-keywords') } } })()`)) as { reportTitle: string reportDate: string overview: string recordNote: string heroAvatars: string messageCount: string activeUsers: string timeSpan: string topicCount: string topicCards: string importantMessages: string quoteBlocks: string qaCards: string resourceItems: string visionCards: string rankItems: string heatBars: string activityTimeline: string cloudTags: string footerText: string classes: Record } if (!extracted.reportTitle || !extracted.topicCards) { throw new Error('旧日报 HTML 缺少可迁移的标题或主题内容') } const values: Record = { REPORT_TITLE: escapeHtml(extracted.reportTitle), REPORT_DATE: escapeHtml(extracted.reportDate || fallback.reportDate), DATE_RANGE: escapeHtml(fallback.dateRange), TIME_SPAN: escapeHtml(extracted.timeSpan), HERO_SUMMARY: escapeHtml(extracted.overview), HERO_TAKEAWAY: '', HERO_PENDING: '', HERO_STATUS_LINE: '', HERO_TAKEAWAY_EMPTY_CLASS: 'empty-section', HERO_PENDING_EMPTY_CLASS: 'empty-section', HERO_STATUS_EMPTY_CLASS: 'empty-section', HERO_AVATARS: extracted.heroAvatars, HERO_AVATAR_CLASS: extracted.heroAvatars ? '' : 'empty-section', MESSAGE_COUNT: escapeHtml(extracted.messageCount || String(fallback.messageCount)), ACTIVE_USERS: escapeHtml(extracted.activeUsers), TOPIC_COUNT: escapeHtml(extracted.topicCount), RECORD_NOTE: escapeHtml(extracted.recordNote), GENERATED_AT: escapeHtml(fallback.generatedAt), FOOTER_NOTE: escapeHtml(extracted.footerText), TOPIC_CARDS: extracted.topicCards, IMPORTANT_MESSAGES: extracted.importantMessages, QUOTE_BLOCKS: extracted.quoteBlocks, QA_CARDS: extracted.qaCards, RESOURCE_ITEMS: extracted.resourceItems, VISION_TITLE: '📸 AI 识别的图片精选', VISION_CARDS: extracted.visionCards, RANK_ITEMS: extracted.rankItems, HEAT_BARS: extracted.heatBars, ACTIVITY_TIMELINE: escapeHtml(extracted.activityTimeline), CLOUD_TAGS: extracted.cloudTags, TOPICS_EMPTY_CLASS: extracted.classes.topics, MESSAGES_EMPTY_CLASS: extracted.classes.messages, QUOTES_EMPTY_CLASS: extracted.classes.quotes, QA_EMPTY_CLASS: extracted.classes.qa, RESOURCES_EMPTY_CLASS: extracted.classes.resources, VISION_EMPTY_CLASS: extracted.classes.vision, KEYWORDS_EMPTY_CLASS: extracted.classes.keywords, ANALYTICS_EMPTY_CLASS: extracted.heatBars || extracted.rankItems ? '' : 'empty-section', ACTIONS_EMPTY_CLASS: 'empty-section', STORYLINES_EMPTY_CLASS: 'empty-section', REVERSALS_EMPTY_CLASS: 'empty-section', CHAINS_EMPTY_CLASS: 'empty-section', VOICE_EMPTY_CLASS: 'empty-section', VOICE_RANK_EMPTY_CLASS: 'empty-section', BADGES_EMPTY_CLASS: 'empty-section', TODO_CARDS: '', UNRESOLVED_CARDS: '', STORYLINE_CARDS: '', REVERSAL_CARDS: '', CHAIN_CARDS: '', VOICE_CARDS: '', VOICE_RANK_CARDS: '', BADGE_CARDS: '', TOPICS_MORE_NOTE: '', MESSAGES_MORE_NOTE: '', QUOTES_MORE_NOTE: '', QA_MORE_NOTE: '', RESOURCES_MORE_NOTE: '', ACTIONS_MORE_NOTE: '', STORYLINES_MORE_NOTE: '', REVERSALS_MORE_NOTE: '', CHAINS_MORE_NOTE: '', VOICE_MORE_NOTE: '', BADGES_MORE_NOTE: '', KEYWORDS_MORE_NOTE: '' } return { groupName: fallback.groupName, reportDate: extracted.reportDate || fallback.reportDate, values } } finally { if (reportWindow && !reportWindow.isDestroyed()) reportWindow.destroy() } } const captureFullPage = async ( htmlPath: string, pngPath: string, templateId?: string ): Promise => { const template = getReportTemplate(templateId) const captureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 430 : template.captureWidth const maxCaptureWidth = LEGACY_TEMPLATE_FILES[templateId || ''] ? 1200 : template.maxCaptureWidth console.log(`[GroupReport] capture begin html=${htmlPath}`) const reportWindow = new BrowserWindow({ show: false, width: captureWidth, height: 800, frame: false, backgroundColor: '#f3f5f7', webPreferences: { sandbox: true } }) try { await reportWindow.loadFile(htmlPath) console.log('[GroupReport] capture loaded html') await reportWindow.webContents.executeJavaScript(`Promise.all([ document.fonts.ready, ...Array.from(document.images).map((img) => img.complete ? Promise.resolve() : new Promise((resolve) => { img.addEventListener('load', resolve, { once: true }); img.addEventListener('error', resolve, { once: true }); })) ])`) console.log('[GroupReport] capture assets ready') const metrics = (await reportWindow.webContents.executeJavaScript(`({ width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, ${captureWidth})), 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))) reportWindow.setContentSize(width, height) await new Promise((resolve) => setTimeout(resolve, 100)) console.log(`[GroupReport] capture native page width=${width} height=${height}`) const image = await reportWindow.webContents.capturePage({ x: 0, y: 0, width, height }) const png = image.toPNG() if (png.length < 1000) throw new Error('生成的日报图片为空') await fs.writeFile(pngPath, png) console.log(`[GroupReport] capture ok bytes=${png.length} png=${pngPath}`) return `data:image/png;base64,${png.toString('base64')}` } finally { reportWindow.destroy() } } export const exportGroupReport = async ( request: GroupReportExportRequest ): Promise => { try { // === enrich 在 render 之前:从群成员快照反推真头像 === await enrichAvatarsFromGroup(request.metadata) const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录') await fs.ensureDir(outputDir) const templateLabel = request.templateId === 'v1' ? '经典版' : request.templateId === 'v2' ? '丰富版' : getReportTemplate(request.templateId).fileLabel const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${templateLabel}` const htmlPath = path.join(outputDir, `${baseName}.html`) const pngPath = path.join(outputDir, `${baseName}.png`) const htmlStartedAt = new Date() const html = await renderReportHtml(request) await fs.writeFile(htmlPath, html, 'utf8') const htmlEndedAt = new Date() const pngStartedAt = new Date() const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId) const pngEndedAt = new Date() return { success: true, htmlPath, pngPath, imageDataUrl, exportTimings: { html: { startedAt: htmlStartedAt.toISOString(), endedAt: htmlEndedAt.toISOString(), duration: htmlEndedAt.getTime() - htmlStartedAt.getTime() }, png: { startedAt: pngStartedAt.toISOString(), endedAt: pngEndedAt.toISOString(), duration: pngEndedAt.getTime() - pngStartedAt.getTime() } }, warnings: request.metadata.warnings?.length ? request.metadata.warnings : undefined } } catch (error) { console.error('[GroupReport] export failed:', error) return { success: false, error: error instanceof Error ? error.message : String(error) } } } export const exportGroupReportSnapshot = async ( request: GroupReportRenderSnapshotExportRequest ): Promise => { try { const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录') await fs.ensureDir(outputDir) const templateLabel = getReportTemplate(request.templateId).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`) const htmlStartedAt = new Date() const html = await renderReportSnapshotHtml(request) await fs.writeFile(htmlPath, html, 'utf8') const htmlEndedAt = new Date() const pngStartedAt = new Date() const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId) const pngEndedAt = new Date() return { success: true, htmlPath, pngPath, imageDataUrl, exportTimings: { html: { startedAt: htmlStartedAt.toISOString(), endedAt: htmlEndedAt.toISOString(), duration: htmlEndedAt.getTime() - htmlStartedAt.getTime() }, png: { startedAt: pngStartedAt.toISOString(), endedAt: pngEndedAt.toISOString(), duration: pngEndedAt.getTime() - pngStartedAt.getTime() } } } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } } }