mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 增加日报模板
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -464,32 +464,6 @@
|
||||
font-style: normal;
|
||||
color: #98a2b3;
|
||||
}
|
||||
.gallery-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #f7faf9;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.gallery-image {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.gallery-stats {
|
||||
display: inline-flex;
|
||||
margin-top: 7px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -644,7 +618,6 @@
|
||||
padding: 11px;
|
||||
}
|
||||
.compact .important-card,
|
||||
.compact .gallery-card,
|
||||
.compact .chat-block {
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -801,11 +774,6 @@
|
||||
{{VISION_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section {{GALLERY_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日群相册</div>
|
||||
{{GALLERY_CARDS}}
|
||||
{{GALLERY_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VOICE_EMPTY_CLASS}}">
|
||||
<div class="section-title">语音之最</div>
|
||||
|
||||
@@ -465,32 +465,6 @@
|
||||
font-style: normal;
|
||||
color: #98a2b3;
|
||||
}
|
||||
.gallery-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #f7faf9;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.gallery-image {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.gallery-stats {
|
||||
display: inline-flex;
|
||||
margin-top: 7px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef5ff;
|
||||
color: #1677ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -645,7 +619,6 @@
|
||||
padding: 11px;
|
||||
}
|
||||
.compact .important-card,
|
||||
.compact .gallery-card,
|
||||
.compact .chat-block {
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -801,11 +774,6 @@
|
||||
{{VISION_CARDS}}
|
||||
</section>
|
||||
|
||||
<section class="section {{GALLERY_EMPTY_CLASS}}">
|
||||
<div class="section-title">今日群相册</div>
|
||||
{{GALLERY_CARDS}}
|
||||
{{GALLERY_MORE_NOTE}}
|
||||
</section>
|
||||
|
||||
<section class="section {{VOICE_EMPTY_CLASS}}">
|
||||
<div class="section-title">语音之最</div>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { JSDOM } = require('jsdom')
|
||||
|
||||
const templates = [
|
||||
{
|
||||
id: 'mobile-feed',
|
||||
className: 'template-mobile-feed',
|
||||
label: 'Mobile 01',
|
||||
name: '微信信息流',
|
||||
width: 390
|
||||
},
|
||||
{
|
||||
id: 'mobile-magazine',
|
||||
className: 'template-mobile-magazine',
|
||||
label: 'Mobile 02',
|
||||
name: 'AI Magazine',
|
||||
width: 390
|
||||
},
|
||||
{
|
||||
id: 'mobile-dashboard',
|
||||
className: 'template-mobile-dashboard',
|
||||
label: 'Mobile 03',
|
||||
name: 'AI Command Center',
|
||||
width: 390
|
||||
},
|
||||
{
|
||||
id: 'desktop-workspace',
|
||||
className: 'template-desktop-workspace',
|
||||
label: 'Desktop 01',
|
||||
name: '三栏 AI 工作台',
|
||||
width: 1440
|
||||
},
|
||||
{
|
||||
id: 'desktop-editorial',
|
||||
className: 'template-desktop-editorial',
|
||||
label: 'Desktop 02',
|
||||
name: 'Editorial 科技日报',
|
||||
width: 1440
|
||||
}
|
||||
]
|
||||
|
||||
const sourcePath = path.resolve(process.argv[2] || '')
|
||||
const outputDir = path.resolve(
|
||||
process.argv[3] || path.join(process.cwd(), '.codex', 'report-template-preview')
|
||||
)
|
||||
const templatePath = path.join(process.cwd(), 'resources', 'daily_report_templates.html')
|
||||
|
||||
if (!sourcePath || !fs.existsSync(sourcePath)) {
|
||||
console.error(
|
||||
'用法: node scripts/generate-report-template-preview.cjs <现有日报.html> [输出目录]'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
if (!fs.existsSync(templatePath)) {
|
||||
console.error(`模板资源不存在: ${templatePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const document = new JSDOM(fs.readFileSync(sourcePath, 'utf8')).window.document
|
||||
const templateHtml = fs.readFileSync(templatePath, 'utf8')
|
||||
|
||||
const one = (selector) => document.querySelector(selector)
|
||||
const html = (selector) => one(selector)?.innerHTML || ''
|
||||
const childrenAfterTitle = (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 = (selector) => {
|
||||
const section = one(selector)
|
||||
return !section || section.classList.contains('empty-section') ? 'empty-section' : ''
|
||||
}
|
||||
|
||||
const statValues = Array.from(document.querySelectorAll('.stat')).map((node) => {
|
||||
const strong = node.querySelector('strong')?.textContent?.trim()
|
||||
if (strong) return strong
|
||||
return node.textContent?.trim().match(/[\d.]+\s*h?/i)?.[0] || ''
|
||||
})
|
||||
const title = one('.hero h1')?.textContent?.trim() || document.title
|
||||
const subItems = Array.from(document.querySelectorAll('.sub > *'))
|
||||
const dateTimeRange = subItems[0]?.textContent?.trim() || ''
|
||||
const reportDate = dateTimeRange.match(/\d{4}-\d{2}-\d{2}/)?.[0] || ''
|
||||
const recordNote = one('.record-note')?.textContent?.trim() || ''
|
||||
const overview = one('.overview')?.textContent?.trim() || ''
|
||||
const footer = one('.footer')?.textContent?.trim().replace(/\s+/g, ' ') || ''
|
||||
const generatedAt = footer.match(/生成时间[::]\s*([^基]+?)(?:基于|$)/)?.[1]?.trim() || ''
|
||||
const activityLine = Array.from(document.querySelectorAll('.analytics > .card')).find((node) =>
|
||||
node.textContent?.includes('活跃时间线')
|
||||
)
|
||||
|
||||
const values = {
|
||||
REPORT_TITLE: title,
|
||||
REPORT_DATE: reportDate,
|
||||
DATE_RANGE: '今天',
|
||||
TIME_SPAN: statValues[2] || dateTimeRange,
|
||||
HERO_SUMMARY: overview,
|
||||
HERO_TAKEAWAY: '',
|
||||
HERO_PENDING: '',
|
||||
HERO_STATUS_LINE: '',
|
||||
HERO_AVATARS: html('.avatar-grid'),
|
||||
HERO_AVATAR_CLASS: sectionClass('.avatar-grid'),
|
||||
MESSAGE_COUNT: statValues[0] || '',
|
||||
ACTIVE_USERS: statValues[1] || '',
|
||||
TOPIC_COUNT: statValues[3] || '',
|
||||
RECORD_NOTE: recordNote,
|
||||
GENERATED_AT: generatedAt,
|
||||
FOOTER_NOTE: footer,
|
||||
TOPIC_CARDS: childrenAfterTitle('.topics'),
|
||||
IMPORTANT_MESSAGES: childrenAfterTitle('.messages'),
|
||||
QUOTE_BLOCKS: childrenAfterTitle('.quotes'),
|
||||
QA_CARDS: childrenAfterTitle('.qa'),
|
||||
HEAT_BARS: Array.from(document.querySelectorAll('.analytics > .heat-row'))
|
||||
.map((node) => node.outerHTML)
|
||||
.join(''),
|
||||
RANK_ITEMS: Array.from(document.querySelectorAll('.analytics .rank'))
|
||||
.map((node) => node.outerHTML)
|
||||
.join(''),
|
||||
ACTIVITY_TIMELINE: activityLine?.textContent?.trim() || '',
|
||||
CLOUD_TAGS: html('.cloud-tags'),
|
||||
RESOURCE_ITEMS: childrenAfterTitle('.resources'),
|
||||
TODO_CARDS: '',
|
||||
UNRESOLVED_CARDS: '',
|
||||
STORYLINE_CARDS: '',
|
||||
REVERSAL_CARDS: '',
|
||||
CHAIN_CARDS: '',
|
||||
VISION_TITLE: 'AI 识别的图片精选',
|
||||
VISION_CARDS: childrenAfterTitle('.vision'),
|
||||
VOICE_CARDS: '',
|
||||
VOICE_RANK_CARDS: '',
|
||||
BADGE_CARDS: '',
|
||||
KEYWORDS_EMPTY_CLASS: sectionClass('.cloud'),
|
||||
ANALYTICS_EMPTY_CLASS: sectionClass('.analytics'),
|
||||
MESSAGES_EMPTY_CLASS: sectionClass('.messages'),
|
||||
TOPICS_EMPTY_CLASS: sectionClass('.topics'),
|
||||
QUOTES_EMPTY_CLASS: sectionClass('.quotes'),
|
||||
RESOURCES_EMPTY_CLASS: sectionClass('.resources'),
|
||||
QA_EMPTY_CLASS: sectionClass('.qa'),
|
||||
ACTIONS_EMPTY_CLASS: 'empty-section',
|
||||
STORYLINES_EMPTY_CLASS: 'empty-section',
|
||||
REVERSALS_EMPTY_CLASS: 'empty-section',
|
||||
CHAINS_EMPTY_CLASS: 'empty-section',
|
||||
VISION_EMPTY_CLASS: sectionClass('.vision'),
|
||||
VOICE_EMPTY_CLASS: 'empty-section',
|
||||
VOICE_RANK_EMPTY_CLASS: 'empty-section',
|
||||
BADGES_EMPTY_CLASS: 'empty-section',
|
||||
HERO_TAKEAWAY_EMPTY_CLASS: 'empty-section',
|
||||
HERO_PENDING_EMPTY_CLASS: 'empty-section',
|
||||
HERO_STATUS_EMPTY_CLASS: 'empty-section'
|
||||
}
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const render = (template) => {
|
||||
let result = templateHtml
|
||||
const replacements = {
|
||||
...values,
|
||||
TEMPLATE_CLASS: template.className,
|
||||
TEMPLATE_LABEL: template.label,
|
||||
TEMPLATE_NAME: template.name
|
||||
}
|
||||
const htmlKeys = new Set([
|
||||
'HERO_AVATARS',
|
||||
'TOPIC_CARDS',
|
||||
'IMPORTANT_MESSAGES',
|
||||
'QUOTE_BLOCKS',
|
||||
'QA_CARDS',
|
||||
'HEAT_BARS',
|
||||
'RANK_ITEMS',
|
||||
'CLOUD_TAGS',
|
||||
'RESOURCE_ITEMS',
|
||||
'TODO_CARDS',
|
||||
'UNRESOLVED_CARDS',
|
||||
'STORYLINE_CARDS',
|
||||
'REVERSAL_CARDS',
|
||||
'CHAIN_CARDS',
|
||||
'VISION_CARDS',
|
||||
'VOICE_CARDS',
|
||||
'VOICE_RANK_CARDS',
|
||||
'BADGE_CARDS'
|
||||
])
|
||||
for (const [key, value] of Object.entries(replacements)) {
|
||||
const safeValue = htmlKeys.has(key) ? String(value || '') : escapeHtml(value)
|
||||
result = result.replaceAll(`{{${key}}}`, safeValue)
|
||||
}
|
||||
return result.replace(/\{\{[A-Z0-9_]+\}\}/g, '')
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
for (const template of templates) {
|
||||
fs.writeFileSync(path.join(outputDir, `${template.id}.html`), render(template), 'utf8')
|
||||
}
|
||||
|
||||
const reportName = title.replace(/日报$/, '')
|
||||
const buttons = templates
|
||||
.map(
|
||||
(template, index) => `
|
||||
<button class="${index === 0 ? 'active' : ''}" data-src="${template.id}.html" data-width="${template.width}">
|
||||
<span>${template.label}</span><b>${template.name}</b>
|
||||
</button>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const indexHtml = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(reportName)} · 五套日报模板预览</title>
|
||||
<style>
|
||||
*{box-sizing:border-box} body{margin:0;background:#e9eeeb;color:#17211d;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}
|
||||
header{position:sticky;top:0;z-index:2;padding:18px 24px 14px;background:rgba(255,255,255,.95);border-bottom:1px solid #d7e0da;backdrop-filter:blur(14px)}
|
||||
h1{margin:0;font-size:20px} p{margin:5px 0 0;color:#68736c;font-size:12px}.toolbar{display:flex;gap:8px;overflow-x:auto;margin-top:14px;padding-bottom:2px}
|
||||
button{display:grid;flex:0 0 auto;gap:2px;min-width:142px;padding:9px 12px;border:1px solid #d8e1db;border-radius:9px;background:#fff;color:#24332b;text-align:left;cursor:pointer}
|
||||
button span{color:#708078;font-size:9px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}button b{font-size:12px}button.active{border-color:#16835b;background:#eaf6ef;color:#0d6744}
|
||||
.stage{display:flex;justify-content:center;min-height:calc(100vh - 132px);padding:24px;overflow:auto}.frame-shell{width:390px;max-width:100%;overflow:hidden;border:1px solid #cbd6cf;border-radius:14px;background:white;box-shadow:0 18px 48px rgba(30,55,43,.15);transition:width .2s ease}
|
||||
iframe{display:block;width:100%;height:calc(100vh - 180px);min-height:680px;border:0;background:white}
|
||||
@media(max-width:640px){header{padding:14px 12px 12px}.stage{padding:12px}.frame-shell{border-radius:10px}button{min-width:132px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>${escapeHtml(reportName)} · 五套日报模板</h1><p>同一份 2026-08-11 真实日报数据,可直接切换比较布局、排版和信息密度。</p><div class="toolbar">${buttons}</div></header>
|
||||
<main class="stage"><div class="frame-shell"><iframe title="日报模板预览" src="mobile-feed.html"></iframe></div></main>
|
||||
<script>
|
||||
const frame=document.querySelector('iframe');const shell=document.querySelector('.frame-shell');
|
||||
document.querySelectorAll('button').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('button').forEach(item=>item.classList.remove('active'));button.classList.add('active');frame.src=button.dataset.src;shell.style.width=button.dataset.width+'px'}));
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
fs.writeFileSync(path.join(outputDir, 'index.html'), indexHtml, 'utf8')
|
||||
console.log(path.join(outputDir, 'index.html'))
|
||||
@@ -182,7 +182,6 @@ fullRequest.report.sectionMeta = {
|
||||
qa: { enabled: true, importance: 0.62, confidence: 0.8, totalCount: 2, displayedCount: 2 },
|
||||
storylines: { enabled: true, importance: 0.68, confidence: 0.74, totalCount: 2, displayedCount: 2 },
|
||||
reversals: { enabled: true, importance: 0.55, confidence: 0.72, totalCount: 1, displayedCount: 1 },
|
||||
gallery: { enabled: true, importance: 0.64, confidence: 0.82, totalCount: 2, displayedCount: 2 },
|
||||
voices: { enabled: true, importance: 0.6, confidence: 0.83, totalCount: 2, displayedCount: 2 },
|
||||
badges: { enabled: true, importance: 0.45, confidence: 0.68, totalCount: 2, displayedCount: 2 },
|
||||
chains: { enabled: true, importance: 0.58, confidence: 0.72, totalCount: 1, displayedCount: 1 }
|
||||
@@ -218,24 +217,7 @@ fullRequest.report.reversals = [
|
||||
{ topic: '接口异常', initialView: '最初以为后端服务不稳定。', finalView: '最终判断更像缓存与配置问题。', note: '多轮验证后,排查方向明显收敛。' }
|
||||
]
|
||||
fullRequest.report.media = {
|
||||
gallery: [
|
||||
{
|
||||
sender: '阿宇',
|
||||
time: '09:52',
|
||||
imageUrl: sampleImage,
|
||||
note: '图片发出后,群里立刻围绕异常现象、返回结构和复现环境展开讨论。',
|
||||
stats: '12 条后续消息 · 6 人接话',
|
||||
inferenceLabel: '基于图片后的聊天上下文推断'
|
||||
},
|
||||
{
|
||||
sender: '佩佩',
|
||||
time: '17:14',
|
||||
imageUrl: sampleImage,
|
||||
note: '第二张图带起一轮轻松但有效的快速确认。',
|
||||
stats: '5 条后续消息 · 3 人接话',
|
||||
inferenceLabel: '基于图片后的聊天上下文推断'
|
||||
}
|
||||
],
|
||||
gallery: [],
|
||||
voiceHighlights: [
|
||||
{ title: '语音输出王', sender: '老周', note: '共发送 3 条语音,累计 97 秒。' },
|
||||
{ title: '连续发言时刻', sender: '阿宇', note: '16:32 连发 2 条语音,共 54 秒。' }
|
||||
@@ -281,7 +263,6 @@ async function renderRequest(request, targetBase) {
|
||||
const qaCards = (report.qa || []).map((item) => `<div class="qa-card"><b>Q:${escapeHtml(item.question)}</b><div>A:${escapeHtml(item.answer)}${item.answerer ? ` — ${escapeHtml(item.answerer)}` : ''}</div></div>`).join('')
|
||||
const storylineCards = (report.storylines || []).map((item) => `<div class="card storyline-card"><div class="topic-title-row"><h3>${escapeHtml(item.title)}</h3></div><div class="storyline-steps">${item.stages.map((stage) => `<div class="storyline-step"><span>${escapeHtml(stage.time)}</span><b>${escapeHtml(stage.event)}</b></div>`).join('')}</div>${item.result ? `<p class="muted">${escapeHtml(item.result)}</p>` : ''}</div>`).join('')
|
||||
const reversalCards = (report.reversals || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.topic)}</b><div>最初:${escapeHtml(item.initialView)}</div><div>后来:${escapeHtml(item.finalView)}</div>${item.note ? `<div>${escapeHtml(item.note)}</div>` : ''}</div>`).join('')
|
||||
const galleryCards = (report.media.gallery || []).map((item) => `<div class="gallery-card"><img class="gallery-image" src="${item.imageUrl}" alt=""><div class="gallery-body"><div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}<div class="important-text">${escapeHtml(item.note)}</div>${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}</div></div>`).join('')
|
||||
const voiceCards = (report.media.voiceHighlights || []).map((item) => `<div class="qa-card"><b>${escapeHtml(item.title)} · ${escapeHtml(item.sender)}</b><div>${escapeHtml(item.note)}</div></div>`).join('')
|
||||
const voiceRankCards = (report.analytics.voiceLeaderboard || []).map((item, index) => `<div class="rank"><img src="${avatars[item.sender] || avatarSvg(item.sender[0], '#e5e7eb')}" alt=""><b>${index + 1}. ${escapeHtml(item.sender)}</b><span>${item.count} 条 · ${item.durationSec} 秒</span></div>`).join('')
|
||||
const badgeCards = (report.media.funBadges || []).map((item) => `<div class="badge-card"><span class="tag">${escapeHtml(item.title)}</span><b>${escapeHtml(item.owner)}</b><p>${escapeHtml(item.note)}</p></div>`).join('')
|
||||
@@ -343,9 +324,6 @@ async function renderRequest(request, targetBase) {
|
||||
REVERSALS_EMPTY_CLASS: report.sectionMeta.reversals?.enabled ? '' : 'empty-section',
|
||||
REVERSAL_CARDS: reversalCards,
|
||||
REVERSALS_MORE_NOTE: '',
|
||||
GALLERY_EMPTY_CLASS: report.sectionMeta.gallery?.enabled ? '' : 'empty-section',
|
||||
GALLERY_CARDS: galleryCards,
|
||||
GALLERY_MORE_NOTE: '',
|
||||
VOICE_EMPTY_CLASS: report.sectionMeta.voices?.enabled ? '' : 'empty-section',
|
||||
VOICE_CARDS: voiceCards,
|
||||
VOICE_MORE_NOTE: '',
|
||||
|
||||
@@ -6,21 +6,23 @@ 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 TEMPLATE_FILES: Record<string, string> = {
|
||||
const LEGACY_TEMPLATE_FILES: Record<string, string> = {
|
||||
v1: 'mobile_daily_report_v1.html',
|
||||
v2: 'mobile_daily_report_v2.html'
|
||||
}
|
||||
const DEFAULT_TEMPLATE = TEMPLATE_FILES.v1
|
||||
|
||||
const templatePath = (templateId?: string): string => {
|
||||
const name = TEMPLATE_FILES[templateId || ''] || DEFAULT_TEMPLATE
|
||||
const name = LEGACY_TEMPLATE_FILES[templateId || ''] || getReportTemplate(templateId).resourceFile
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, 'resources', name),
|
||||
path.join(app.getAppPath(), 'resources', name),
|
||||
@@ -168,6 +170,7 @@ const overflowNote = (
|
||||
|
||||
const renderReportHtml = async (request: GroupReportExportRequest): Promise<string> => {
|
||||
const { report, metadata } = request
|
||||
const template = getReportTemplate(request.templateId)
|
||||
const avatarNames = new Set<string>(metadata.heroParticipants)
|
||||
report.topics.forEach((topic) => topic.participants.forEach((name) => avatarNames.add(name)))
|
||||
report.importantMessages.forEach((message) => avatarNames.add(message.sender))
|
||||
@@ -175,7 +178,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
quote.messages.forEach((message) => avatarNames.add(message.sender))
|
||||
)
|
||||
report.analytics.topSpeakers.forEach((speaker) => avatarNames.add(speaker.name))
|
||||
report.media?.gallery?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.voiceHighlights?.forEach((item) => avatarNames.add(item.sender))
|
||||
report.media?.funBadges?.forEach((item) => avatarNames.add(item.owner))
|
||||
|
||||
@@ -341,20 +343,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
)
|
||||
.join('')
|
||||
|
||||
const galleryCards = (report.media?.gallery || [])
|
||||
.map(
|
||||
(item) => `<div class="gallery-card">
|
||||
<img class="gallery-image" src="${item.imageUrl}" alt="群聊图片">
|
||||
<div class="gallery-body">
|
||||
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||
${item.stats ? `<div class="gallery-stats">${escapeHtml(item.stats)}</div>` : ''}
|
||||
<div class="important-text">${escapeHtml(item.note)}</div>
|
||||
${item.inferenceLabel ? `<div class="topic-meta">${escapeHtml(item.inferenceLabel)}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
// AI 图片理解结果板块(ImageInsight)
|
||||
// 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文
|
||||
const visionCards = (report.media?.visionGallery || [])
|
||||
@@ -411,11 +399,12 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
.join('')
|
||||
|
||||
// v1 模板使用的水平条形热度图,渲染 top speakers 排行
|
||||
const heatBarsHtml = report.analytics.topSpeakers
|
||||
.slice(0, 8)
|
||||
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.min(100, count * 12)
|
||||
const width = Math.max(4, Math.round((count / maxSpeakerCount) * 100))
|
||||
return `<div class="heat-row">
|
||||
<span class="heat-name">${escapeHtml(speaker.name)}</span>
|
||||
<span class="heat-bar"><i style="width:${width}%"></i></span>
|
||||
@@ -454,7 +443,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
|
||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||
const values: Record<string, string> = {
|
||||
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),
|
||||
@@ -525,9 +518,6 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
),
|
||||
VISION_CARDS: visionCards,
|
||||
VISION_TITLE: '📸 AI 识别的图片精选',
|
||||
GALLERY_EMPTY_CLASS: sectionClass(request, 'gallery', report.media?.gallery?.length > 0),
|
||||
GALLERY_CARDS: galleryCards,
|
||||
GALLERY_MORE_NOTE: overflowNote(request, 'gallery'),
|
||||
VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
|
||||
VOICE_CARDS: voiceCards,
|
||||
VOICE_MORE_NOTE: overflowNote(request, 'voices'),
|
||||
@@ -560,11 +550,242 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
return html
|
||||
}
|
||||
|
||||
const captureFullPage = async (htmlPath: string, pngPath: string): Promise<string> => {
|
||||
const renderReportSnapshotHtml = async (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
): Promise<string> => {
|
||||
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<GroupReportRenderSnapshot> => {
|
||||
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<string, string>
|
||||
}
|
||||
if (!extracted.reportTitle || !extracted.topicCards) {
|
||||
throw new Error('旧日报 HTML 缺少可迁移的标题或主题内容')
|
||||
}
|
||||
|
||||
const values: Record<string, string> = {
|
||||
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<string> => {
|
||||
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: 430,
|
||||
width: captureWidth,
|
||||
height: 800,
|
||||
frame: false,
|
||||
backgroundColor: '#f3f5f7',
|
||||
@@ -583,10 +804,10 @@ const captureFullPage = async (htmlPath: string, pngPath: string): Promise<strin
|
||||
])`)
|
||||
console.log('[GroupReport] capture assets ready')
|
||||
const metrics = (await reportWindow.webContents.executeJavaScript(`({
|
||||
width: Math.ceil(Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 430)),
|
||||
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(430, Math.min(1200, Math.ceil(metrics.width)))
|
||||
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))
|
||||
@@ -611,7 +832,12 @@ export const exportGroupReport = async (
|
||||
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const templateLabel = request.templateId === 'v1' ? '经典版' : '模板2'
|
||||
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`)
|
||||
@@ -620,7 +846,7 @@ export const exportGroupReport = async (
|
||||
await fs.writeFile(htmlPath, html, 'utf8')
|
||||
const htmlEndedAt = new Date()
|
||||
const pngStartedAt = new Date()
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath)
|
||||
const imageDataUrl = await captureFullPage(htmlPath, pngPath, request.templateId)
|
||||
const pngEndedAt = new Date()
|
||||
return {
|
||||
success: true,
|
||||
@@ -646,3 +872,43 @@ export const exportGroupReport = async (
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export const exportGroupReportSnapshot = async (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
): Promise<GroupReportExportResult> => {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
+34
-4
@@ -32,14 +32,27 @@ import {
|
||||
inspectImageDecoderStatus,
|
||||
type DecodedImage
|
||||
} from './image-decrypt-service'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import {
|
||||
exportGroupReport,
|
||||
exportGroupReportSnapshot,
|
||||
extractGroupReportRenderSnapshot
|
||||
} from './group-report-service'
|
||||
import {
|
||||
deleteGeneratedReport,
|
||||
listGeneratedReports,
|
||||
saveGeneratedReport
|
||||
prepareGeneratedReportTemplateSwitch,
|
||||
saveGeneratedReport,
|
||||
updateGeneratedReportTemplate
|
||||
} from './report-history-service'
|
||||
import type { GroupReportExportRequest } from '../shared/group-report'
|
||||
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
||||
import type {
|
||||
GroupReportExportRequest,
|
||||
GroupReportRenderSnapshotExportRequest
|
||||
} from '../shared/group-report'
|
||||
import type {
|
||||
SaveGeneratedReportRequest,
|
||||
PrepareGeneratedReportTemplateSwitchRequest,
|
||||
UpdateGeneratedReportTemplateRequest
|
||||
} from '../shared/report-history'
|
||||
import type {
|
||||
AIChatRequestOptions,
|
||||
AiSearchExternalAuthorizationRequest,
|
||||
@@ -1132,6 +1145,10 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
|
||||
return exportGroupReport(request)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'report:exportSnapshot',
|
||||
async (_, request: GroupReportRenderSnapshotExportRequest) => exportGroupReportSnapshot(request)
|
||||
)
|
||||
|
||||
ipcMain.handle('export:start', async (event, request: ExportRequest) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
@@ -1158,6 +1175,19 @@ app.whenReady().then(async () => {
|
||||
return saveGeneratedReport(request)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'report:updateGeneratedTemplate',
|
||||
async (_, request: UpdateGeneratedReportTemplateRequest) => {
|
||||
return updateGeneratedReportTemplate(request)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'report:prepareTemplateSwitch',
|
||||
async (_, request: PrepareGeneratedReportTemplateSwitchRequest) =>
|
||||
prepareGeneratedReportTemplateSwitch(request.reportId, extractGroupReportRenderSnapshot)
|
||||
)
|
||||
|
||||
ipcMain.handle('report:deleteGenerated', async (_, reportId: string) => {
|
||||
return deleteGeneratedReport(reportId)
|
||||
})
|
||||
|
||||
@@ -7,8 +7,11 @@ import type {
|
||||
ReportAssetStatus,
|
||||
ReportHistoryResult,
|
||||
SaveGeneratedReportRequest,
|
||||
SaveGeneratedReportResult
|
||||
SaveGeneratedReportResult,
|
||||
UpdateGeneratedReportTemplateRequest,
|
||||
UpdateGeneratedReportTemplateResult
|
||||
} from '../shared/report-history'
|
||||
import type { GroupReportRenderSnapshot } from '../shared/group-report'
|
||||
|
||||
const REPORTS_DIR = 'reports'
|
||||
|
||||
@@ -184,7 +187,10 @@ export async function saveGeneratedReport(
|
||||
html: await readFileSize(savedHtmlPath),
|
||||
png: await readFileSize(savedPngPath)
|
||||
},
|
||||
generationLogs: request.generationLogs
|
||||
generationLogs: request.generationLogs,
|
||||
reportSnapshot: request.reportSnapshot,
|
||||
reportMetadata: request.reportMetadata,
|
||||
templateId: request.templateId
|
||||
}
|
||||
|
||||
await fs.writeFile(jsonPath, JSON.stringify(record, null, 2), 'utf8')
|
||||
@@ -200,6 +206,124 @@ export async function saveGeneratedReport(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGeneratedReportTemplate(
|
||||
request: UpdateGeneratedReportTemplateRequest
|
||||
): Promise<UpdateGeneratedReportTemplateResult> {
|
||||
try {
|
||||
const jsonFiles = await walkJsonFiles(getReportsRoot())
|
||||
for (const jsonPath of jsonFiles) {
|
||||
try {
|
||||
const content = await fs.readFile(jsonPath, 'utf8')
|
||||
const record = JSON.parse(content) as GeneratedReportRecord
|
||||
if (record.id !== request.reportId) continue
|
||||
if ((!record.reportSnapshot || !record.reportMetadata) && !record.reportRenderSnapshot) {
|
||||
return { success: false, error: '旧报告未保存结构化数据,无法无损切换模板' }
|
||||
}
|
||||
|
||||
const directory = path.dirname(jsonPath)
|
||||
const baseName = path.basename(jsonPath, '.json')
|
||||
const htmlPath = record.htmlPath || path.join(directory, `${baseName}.html`)
|
||||
const pngPath = record.pngPath || path.join(directory, `${baseName}.png`)
|
||||
|
||||
const imageBuffer = request.generatedImage ? parseDataUrl(request.generatedImage) : null
|
||||
const hasPngFile = Boolean(request.pngPath && (await exists(request.pngPath)))
|
||||
if (!request.htmlPath || !(await exists(request.htmlPath))) {
|
||||
return { success: false, error: '新模板 HTML 文件不存在' }
|
||||
}
|
||||
if (!imageBuffer && !hasPngFile) {
|
||||
return { success: false, error: '新模板 PNG 文件不存在' }
|
||||
}
|
||||
await fs.copyFile(request.htmlPath, htmlPath)
|
||||
if (imageBuffer) {
|
||||
await fs.writeFile(pngPath, imageBuffer)
|
||||
} else if (request.pngPath) {
|
||||
await fs.copyFile(request.pngPath, pngPath)
|
||||
}
|
||||
|
||||
const updated: GeneratedReportRecord = {
|
||||
...record,
|
||||
htmlPath,
|
||||
pngPath,
|
||||
jsonPath,
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
imageSize: await readPngSize(pngPath),
|
||||
fileSize: {
|
||||
html: await readFileSize(htmlPath),
|
||||
png: await readFileSize(pngPath)
|
||||
},
|
||||
templateId: request.templateId
|
||||
}
|
||||
delete updated.generatedImage
|
||||
await fs.writeFile(jsonPath, JSON.stringify(updated, null, 2), 'utf8')
|
||||
return {
|
||||
success: true,
|
||||
record: {
|
||||
...updated,
|
||||
generatedImage: await readPngAsDataUrl(pngPath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ReportHistory] skip invalid report record while switching template: ${jsonPath}`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
return { success: false, error: '未找到要切换模板的日报记录' }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareGeneratedReportTemplateSwitch(
|
||||
reportId: string,
|
||||
extractSnapshot: (
|
||||
htmlPath: string,
|
||||
fallback: {
|
||||
groupName: string
|
||||
reportDate: string
|
||||
dateRange: string
|
||||
messageCount: number
|
||||
generatedAt: string
|
||||
}
|
||||
) => Promise<GroupReportRenderSnapshot>
|
||||
): Promise<import('../shared/report-history').PrepareGeneratedReportTemplateSwitchResult> {
|
||||
try {
|
||||
const jsonFiles = await walkJsonFiles(getReportsRoot())
|
||||
for (const jsonPath of jsonFiles) {
|
||||
try {
|
||||
const content = await fs.readFile(jsonPath, 'utf8')
|
||||
const record = JSON.parse(content) as GeneratedReportRecord
|
||||
if (record.id !== reportId) continue
|
||||
if (record.reportRenderSnapshot)
|
||||
return { success: true, snapshot: record.reportRenderSnapshot }
|
||||
if (!record.htmlPath || !(await exists(record.htmlPath))) {
|
||||
return { success: false, error: '当前日报缺少 HTML 文件,无法迁移旧模板数据' }
|
||||
}
|
||||
const snapshot = await extractSnapshot(record.htmlPath, {
|
||||
groupName: record.contactName,
|
||||
reportDate: record.reportDate,
|
||||
dateRange: record.dateRange,
|
||||
messageCount: record.messageCount,
|
||||
generatedAt: record.generatedAt
|
||||
})
|
||||
const updated = { ...record, reportRenderSnapshot: snapshot }
|
||||
await fs.writeFile(jsonPath, JSON.stringify(updated, null, 2), 'utf8')
|
||||
return { success: true, snapshot }
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ReportHistory] skip invalid report record while preparing template switch: ${jsonPath}`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
return { success: false, error: '未找到要切换模板的日报记录' }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGeneratedReport(
|
||||
reportId: string
|
||||
): Promise<DeleteGeneratedReportResult> {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果)
|
||||
// 2. 同图(imageHash)走缓存,绝不重复调 AI
|
||||
// 3. 失败不抛,日志记录 + 返回原状(不阻塞日报)
|
||||
// 4. 第一阶段:Top 3 热点图 + 缓存命中即返回,未命中并发调 AI
|
||||
// 4. 日报最多识别 3 张达到热点门槛的图片;缓存命中即返回,未命中并发调 AI
|
||||
|
||||
import crypto from 'crypto'
|
||||
import { randomUUID } from 'crypto'
|
||||
@@ -22,6 +22,10 @@ import type {
|
||||
ImageCandidateQuery,
|
||||
ImageInsight
|
||||
} from '../../shared/image-insight'
|
||||
import {
|
||||
calculateImageHeatScore,
|
||||
isHotImageCandidate
|
||||
} from '../../shared/image-insight'
|
||||
|
||||
/**
|
||||
* 单张图片的最小信息(由 renderer 从已加载的 messages 中提取并传入 main)。
|
||||
@@ -234,7 +238,7 @@ class ImageInsightService {
|
||||
query: ImageCandidateQuery,
|
||||
inputs: ImageCandidateInput[] = []
|
||||
): Promise<ImageCandidate[]> {
|
||||
const limit = query.limit ?? 3
|
||||
const limit = Math.min(3, Math.max(0, query.limit ?? 3))
|
||||
const candidates: ImageCandidate[] = []
|
||||
console.log('[ImageInsightService] listTopHotImages received %d inputs', inputs.length)
|
||||
for (const input of inputs) {
|
||||
@@ -248,7 +252,16 @@ class ImageInsightService {
|
||||
)
|
||||
continue
|
||||
}
|
||||
const heatScore = input.responseCount * 3 + input.interactionCount * 2 + 1
|
||||
if (!isHotImageCandidate(input)) {
|
||||
console.log(
|
||||
'[ImageInsightService] skip %s: not hot (responses=%d interactions=%d)',
|
||||
input.messageId,
|
||||
input.responseCount,
|
||||
input.interactionCount
|
||||
)
|
||||
continue
|
||||
}
|
||||
const heatScore = calculateImageHeatScore(input)
|
||||
const candidate: ImageCandidate = {
|
||||
messageId: input.messageId,
|
||||
imageHash: hash,
|
||||
|
||||
Vendored
+18
-2
@@ -1,12 +1,19 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import { Contact, Message } from '../shared/types'
|
||||
import { GroupReportExportRequest, GroupReportExportResult } from '../shared/group-report'
|
||||
import {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
GroupReportRenderSnapshotExportRequest
|
||||
} from '../shared/group-report'
|
||||
import { LocalApiTestRequest, LocalApiTestResponse } from '../shared/local-api-test'
|
||||
import {
|
||||
DeleteGeneratedReportResult,
|
||||
ReportHistoryResult,
|
||||
SaveGeneratedReportRequest,
|
||||
SaveGeneratedReportResult
|
||||
SaveGeneratedReportResult,
|
||||
PrepareGeneratedReportTemplateSwitchResult,
|
||||
UpdateGeneratedReportTemplateRequest,
|
||||
UpdateGeneratedReportTemplateResult
|
||||
} from '../shared/report-history'
|
||||
import type {
|
||||
DatabaseKeyEnvironment,
|
||||
@@ -305,10 +312,19 @@ declare global {
|
||||
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
|
||||
onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
|
||||
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||
exportGroupReportSnapshot: (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
) => Promise<GroupReportExportResult>
|
||||
prepareGeneratedReportTemplateSwitch: (
|
||||
reportId: string
|
||||
) => Promise<PrepareGeneratedReportTemplateSwitchResult>
|
||||
listGeneratedReports: () => Promise<ReportHistoryResult>
|
||||
saveGeneratedReport: (
|
||||
request: SaveGeneratedReportRequest
|
||||
) => Promise<SaveGeneratedReportResult>
|
||||
updateGeneratedReportTemplate: (
|
||||
request: UpdateGeneratedReportTemplateRequest
|
||||
) => Promise<UpdateGeneratedReportTemplateResult>
|
||||
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
|
||||
|
||||
+14
-2
@@ -1,7 +1,13 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type { GroupReportExportRequest } from '../shared/group-report'
|
||||
import type { SaveGeneratedReportRequest } from '../shared/report-history'
|
||||
import type {
|
||||
GroupReportExportRequest,
|
||||
GroupReportRenderSnapshotExportRequest
|
||||
} from '../shared/group-report'
|
||||
import type {
|
||||
SaveGeneratedReportRequest,
|
||||
UpdateGeneratedReportTemplateRequest
|
||||
} from '../shared/report-history'
|
||||
import type {
|
||||
AIChatRequestOptions,
|
||||
AiSearchExternalAuthorizationRequest,
|
||||
@@ -206,9 +212,15 @@ const api = {
|
||||
},
|
||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||
ipcRenderer.invoke('report:export', request),
|
||||
exportGroupReportSnapshot: (request: GroupReportRenderSnapshotExportRequest) =>
|
||||
ipcRenderer.invoke('report:exportSnapshot', request),
|
||||
prepareGeneratedReportTemplateSwitch: (reportId: string) =>
|
||||
ipcRenderer.invoke('report:prepareTemplateSwitch', { reportId }),
|
||||
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
|
||||
saveGeneratedReport: (request: SaveGeneratedReportRequest) =>
|
||||
ipcRenderer.invoke('report:saveGenerated', request),
|
||||
updateGeneratedReportTemplate: (request: UpdateGeneratedReportTemplateRequest) =>
|
||||
ipcRenderer.invoke('report:updateGeneratedTemplate', request),
|
||||
deleteGeneratedReport: (reportId: string) =>
|
||||
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
sortMessagesChronologically
|
||||
} from './utils/message-pages'
|
||||
import { enrichQuotedMessages } from './utils/quoted-messages'
|
||||
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
||||
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
@@ -1462,7 +1464,9 @@ function App(): React.ReactElement {
|
||||
reportGeneration.phase !== 'success' ||
|
||||
!reportSourceContact ||
|
||||
!reportGeneration.generatedImage ||
|
||||
!reportGeneration.reportPaths
|
||||
!reportGeneration.reportPaths ||
|
||||
!reportGeneration.reportSnapshot ||
|
||||
!reportGeneration.reportMetadata
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -1472,6 +1476,8 @@ function App(): React.ReactElement {
|
||||
lastCapturedReportKeyRef.current = recordKey
|
||||
setLatestGeneratedReportId(null)
|
||||
setIsSavingGeneratedReport(true)
|
||||
const reportSnapshot = reportGeneration.reportSnapshot
|
||||
const reportMetadata = reportGeneration.reportMetadata
|
||||
|
||||
const saveReport = async (): Promise<void> => {
|
||||
const result = await window.api.saveGeneratedReport({
|
||||
@@ -1492,7 +1498,10 @@ function App(): React.ReactElement {
|
||||
duration: reportGeneration.generationMetadata.durationMs,
|
||||
modelName: reportGeneration.generationMetadata.modelName || aiModelConfig.model,
|
||||
tokenUsage: reportGeneration.generationMetadata.tokenUsage,
|
||||
generationLogs: reportGeneration.generationMetadata.generationLogs
|
||||
generationLogs: reportGeneration.generationMetadata.generationLogs,
|
||||
reportSnapshot,
|
||||
reportMetadata,
|
||||
templateId: reportGeneration.templateId
|
||||
})
|
||||
|
||||
if (!result.success || !result.record) {
|
||||
@@ -1517,7 +1526,10 @@ function App(): React.ReactElement {
|
||||
reportGeneration.generationMetadata,
|
||||
reportGeneration.phase,
|
||||
reportGeneration.reportMessages.length,
|
||||
reportGeneration.reportMetadata,
|
||||
reportGeneration.reportPaths,
|
||||
reportGeneration.reportSnapshot,
|
||||
reportGeneration.templateId,
|
||||
reportSourceContact,
|
||||
summaryDateRange
|
||||
])
|
||||
@@ -1564,6 +1576,30 @@ function App(): React.ReactElement {
|
||||
return window.api.revealGroupReport(filePath)
|
||||
}
|
||||
|
||||
const handleSwitchReportTemplate = async (
|
||||
report: GeneratedReportRecord,
|
||||
templateId: SelectableReportTemplateId
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const updated = await withTimeout(
|
||||
switchGeneratedReportTemplate(report, templateId, window.api),
|
||||
120_000,
|
||||
'模板切换超时,请稍后重试'
|
||||
)
|
||||
if (!updated.success || !updated.record) {
|
||||
return { success: false, error: updated.error || '日报模板更新失败' }
|
||||
}
|
||||
|
||||
setGeneratedReports((current) =>
|
||||
current.map((item) => (item.id === report.id ? updated.record! : item))
|
||||
)
|
||||
reportGeneration.setTemplateId(templateId)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteReport = async (
|
||||
reportId: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -1634,6 +1670,7 @@ function App(): React.ReactElement {
|
||||
onRegenerate={handleRegenerateReport}
|
||||
onCopyImage={handleCopyReportImage}
|
||||
onReveal={handleRevealReport}
|
||||
onSwitchTemplate={handleSwitchReportTemplate}
|
||||
/>
|
||||
<ReportInfoPanel report={selectedReport} onReveal={handleRevealReport} />
|
||||
</div>
|
||||
@@ -1686,10 +1723,13 @@ function App(): React.ReactElement {
|
||||
error={reportGeneration.error}
|
||||
voiceTranscriptionProgress={reportGeneration.voiceTranscriptionProgress}
|
||||
voiceTranscriptionEnabled={summaryMessageTypes.includes('voice')}
|
||||
onRetry={() => {
|
||||
reportGeneration.resetGenerationStatus()
|
||||
void reportGeneration.retry()
|
||||
}}
|
||||
preparationProgress={reportGeneration.preparationProgress}
|
||||
imageInsightSummary={reportGeneration.imageInsightSummary}
|
||||
canRetryModelStep={reportGeneration.canRetryModelStep}
|
||||
currentModel={aiModelConfig}
|
||||
onRetry={(model) => void reportGeneration.retry(model)}
|
||||
onContinueAfterImageFailures={() => void reportGeneration.continueAfterImageFailures()}
|
||||
onCancelAfterImageFailures={reportGeneration.cancelAfterImageFailures}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ReportRangeSelector } from './ReportRangeSelector'
|
||||
import { ReportMemberNameSelector } from './ReportMemberNameSelector'
|
||||
import { ReportGroupMemberSelector } from './ReportGroupMemberSelector'
|
||||
import { ReportSectionSelector } from './ReportSectionSelector'
|
||||
import { ReportTemplateId, ReportTemplateSelector } from './ReportTemplateSelector'
|
||||
import { ReportTemplateSelector, SelectableReportTemplateId } from './ReportTemplateSelector'
|
||||
|
||||
interface AiReportWorkspaceProps {
|
||||
sourceContact: Contact | null
|
||||
@@ -40,8 +40,8 @@ interface AiReportWorkspaceProps {
|
||||
onRevealReport: () => Promise<{ success: boolean; error?: string }>
|
||||
onViewResult: () => void
|
||||
hasReportResult: boolean
|
||||
templateId: ReportTemplateId
|
||||
onTemplateIdChange: (value: ReportTemplateId) => void
|
||||
templateId: SelectableReportTemplateId
|
||||
onTemplateIdChange: (value: SelectableReportTemplateId) => void
|
||||
memberNamePreference: ReportMemberNamePreference
|
||||
onMemberNamePreferenceChange: (value: ReportMemberNamePreference) => void
|
||||
reportTimeoutSeconds: number
|
||||
|
||||
@@ -1,31 +1,89 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import type { AIProviderSummary } from '../../../../shared/ai-provider'
|
||||
import {
|
||||
AiModelConfig,
|
||||
REPORT_TASK_STEPS,
|
||||
ReportGenerationPhase,
|
||||
VoiceTranscriptionProgress
|
||||
} from '../../hooks/useGroupReportGeneration'
|
||||
import type {
|
||||
ReportImageInsightSummary,
|
||||
ReportPreparationProgress
|
||||
} from '../../utils/group-report-facts'
|
||||
|
||||
interface ReportTaskStatusPanelProps {
|
||||
phase: ReportGenerationPhase
|
||||
error: string
|
||||
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
|
||||
voiceTranscriptionEnabled: boolean
|
||||
onRetry: () => void
|
||||
preparationProgress: ReportPreparationProgress | null
|
||||
imageInsightSummary: ReportImageInsightSummary
|
||||
canRetryModelStep: boolean
|
||||
currentModel: AiModelConfig
|
||||
onRetry: (model?: AiModelConfig) => void
|
||||
onContinueAfterImageFailures: () => void
|
||||
onCancelAfterImageFailures: () => void
|
||||
}
|
||||
|
||||
interface ModelChoice {
|
||||
key: string
|
||||
label: string
|
||||
config: AiModelConfig
|
||||
}
|
||||
|
||||
const providerCanRun = (provider: AIProviderSummary): boolean =>
|
||||
Boolean(
|
||||
provider.models.some((model) => model.capabilities.chat) &&
|
||||
(provider.hasApiKey || provider.type === 'ollama' || provider.auth.type === 'none')
|
||||
)
|
||||
|
||||
const modelChoices = (providers: AIProviderSummary[]): ModelChoice[] =>
|
||||
providers.flatMap((provider) =>
|
||||
providerCanRun(provider)
|
||||
? provider.models
|
||||
.filter((model) => model.capabilities.chat)
|
||||
.map((model) => ({
|
||||
key: `${provider.id}::${model.id}`,
|
||||
label: `${provider.name} · ${model.name || model.id}`,
|
||||
config: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
model: model.id,
|
||||
modelName: model.name || model.id,
|
||||
configured: true,
|
||||
status: provider.status,
|
||||
timeoutMs: provider.advanced.timeoutMs
|
||||
}
|
||||
}))
|
||||
: []
|
||||
)
|
||||
|
||||
export function ReportTaskStatusPanel({
|
||||
phase,
|
||||
error,
|
||||
voiceTranscriptionProgress,
|
||||
voiceTranscriptionEnabled,
|
||||
onRetry
|
||||
preparationProgress,
|
||||
imageInsightSummary,
|
||||
canRetryModelStep,
|
||||
currentModel,
|
||||
onRetry,
|
||||
onContinueAfterImageFailures,
|
||||
onCancelAfterImageFailures
|
||||
}: ReportTaskStatusPanelProps): React.ReactElement {
|
||||
const taskSteps = voiceTranscriptionEnabled
|
||||
? REPORT_TASK_STEPS
|
||||
: REPORT_TASK_STEPS.filter((step) => step.id !== 'transcribingVoice')
|
||||
const activeIndex = taskSteps.findIndex((step) => step.id === phase)
|
||||
const effectiveActiveIndex =
|
||||
phase === 'awaitingImageDecision'
|
||||
? taskSteps.findIndex((step) => step.id === 'preparingInput')
|
||||
: activeIndex
|
||||
const completedAll = phase === 'success'
|
||||
const [logPath, setLogPath] = useState('')
|
||||
const [choices, setChoices] = useState<ModelChoice[]>([])
|
||||
const [selectedModelKey, setSelectedModelKey] = useState('')
|
||||
const [modelLoadError, setModelLoadError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
void window.api
|
||||
@@ -34,6 +92,39 @@ export function ReportTaskStatusPanel({
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRetryModelStep) return
|
||||
let active = true
|
||||
void window.api
|
||||
.listAIProviders()
|
||||
.then((result) => {
|
||||
if (!active) return
|
||||
if (!result.success) {
|
||||
setModelLoadError(result.error || '模型列表读取失败')
|
||||
return
|
||||
}
|
||||
const nextChoices = modelChoices(result.providers)
|
||||
setChoices(nextChoices)
|
||||
const currentKey = `${currentModel.providerId || ''}::${currentModel.model}`
|
||||
setSelectedModelKey(
|
||||
nextChoices.some((choice) => choice.key === currentKey)
|
||||
? currentKey
|
||||
: nextChoices[0]?.key || ''
|
||||
)
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (active) {
|
||||
setModelLoadError(loadError instanceof Error ? loadError.message : '模型列表读取失败')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [canRetryModelStep, currentModel.model, currentModel.providerId])
|
||||
|
||||
const selectedModel = choices.find((choice) => choice.key === selectedModelKey)?.config
|
||||
const imageDecisionPending = phase === 'awaitingImageDecision'
|
||||
|
||||
return (
|
||||
<aside className="report-task-panel">
|
||||
<div className="report-task-header">
|
||||
@@ -41,19 +132,21 @@ export function ReportTaskStatusPanel({
|
||||
<p>
|
||||
{completedAll
|
||||
? '生成完成'
|
||||
: phase === 'error'
|
||||
? '生成失败'
|
||||
: activeIndex >= 0
|
||||
? `${activeIndex + 1}/${taskSteps.length}`
|
||||
: '等待开始'}
|
||||
: imageDecisionPending
|
||||
? '等待确认'
|
||||
: phase === 'error'
|
||||
? '生成失败'
|
||||
: effectiveActiveIndex >= 0
|
||||
? `${effectiveActiveIndex + 1}/${taskSteps.length}`
|
||||
: '等待开始'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="report-task-steps">
|
||||
{taskSteps.map((step, index) => {
|
||||
const state =
|
||||
completedAll || (activeIndex >= 0 && index < activeIndex)
|
||||
completedAll || (effectiveActiveIndex >= 0 && index < effectiveActiveIndex)
|
||||
? 'done'
|
||||
: activeIndex === index
|
||||
: effectiveActiveIndex === index
|
||||
? 'active'
|
||||
: 'waiting'
|
||||
return (
|
||||
@@ -69,6 +162,76 @@ export function ReportTaskStatusPanel({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{(phase === 'preparingInput' || phase === 'requestingModel' || imageDecisionPending) &&
|
||||
preparationProgress && (
|
||||
<div className="report-preparation-progress">
|
||||
<div>
|
||||
<strong>{preparationProgress.label}</strong>
|
||||
{preparationProgress.total ? (
|
||||
<span>
|
||||
{preparationProgress.completed || 0}/{preparationProgress.total}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{preparationProgress.total ? (
|
||||
<progress
|
||||
value={preparationProgress.completed || 0}
|
||||
max={preparationProgress.total}
|
||||
aria-label="图片识别进度"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{imageInsightSummary.total > 0 && (
|
||||
<details className="report-image-insights" open={imageDecisionPending}>
|
||||
<summary>
|
||||
图片识别:成功 {imageInsightSummary.succeeded} 张
|
||||
{imageInsightSummary.failed > 0 ? ` · 失败 ${imageInsightSummary.failed} 张` : ''}
|
||||
</summary>
|
||||
<div className="report-image-insight-list">
|
||||
{imageInsightSummary.items.map((item) => (
|
||||
<article key={`${item.messageId}:${item.time}`}>
|
||||
<div>
|
||||
<b>{item.sender}</b>
|
||||
<time>{item.time}</time>
|
||||
</div>
|
||||
<p>{item.description}</p>
|
||||
{item.ocrText ? <small>OCR:{item.ocrText}</small> : null}
|
||||
{item.tags.length ? <small>标签:{item.tags.join(' / ')}</small> : null}
|
||||
</article>
|
||||
))}
|
||||
{imageInsightSummary.failures.map((failure, index) => (
|
||||
<article
|
||||
key={`${failure.messageId || failure.sender}:${failure.time || index}`}
|
||||
className="failed"
|
||||
>
|
||||
<div>
|
||||
<b>{failure.sender}</b>
|
||||
{failure.time ? <time>{failure.time}</time> : null}
|
||||
</div>
|
||||
<p>识别失败:{failure.error}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{imageDecisionPending && (
|
||||
<div className="report-image-decision">
|
||||
<b>有 {imageInsightSummary.failed} 张图片识别失败</b>
|
||||
<p>
|
||||
已成功识别的 {imageInsightSummary.succeeded}{' '}
|
||||
张图片仍会参与总结。失败图片只按消息类型和聊天上下文处理,不会猜测具体内容。
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" onClick={onContinueAfterImageFailures}>
|
||||
继续文字总结
|
||||
</button>
|
||||
<button type="button" onClick={onCancelAfterImageFailures}>
|
||||
停止生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{phase === 'transcribingVoice' && voiceTranscriptionProgress && (
|
||||
<div className="report-voice-progress">
|
||||
<div>
|
||||
@@ -92,9 +255,35 @@ export function ReportTaskStatusPanel({
|
||||
<div className="report-task-error">
|
||||
<b>错误摘要</b>
|
||||
<p>{error}</p>
|
||||
<button type="button" onClick={onRetry}>
|
||||
重试
|
||||
</button>
|
||||
{canRetryModelStep ? (
|
||||
<div className="report-model-retry">
|
||||
<label htmlFor="report-retry-model">切换模型</label>
|
||||
<select
|
||||
id="report-retry-model"
|
||||
value={selectedModelKey}
|
||||
onChange={(event) => setSelectedModelKey(event.target.value)}
|
||||
>
|
||||
{choices.map((choice) => (
|
||||
<option key={choice.key} value={choice.key}>
|
||||
{choice.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>重新生成将直接复用已整理的聊天记录和图片识别结果,从第三步继续。</small>
|
||||
{modelLoadError ? <small className="error">{modelLoadError}</small> : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedModel}
|
||||
onClick={() => onRetry(selectedModel)}
|
||||
>
|
||||
使用所选模型重新生成
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => onRetry()}>
|
||||
从头重试
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => void window.api.revealAppLog()}>
|
||||
打开诊断日志
|
||||
</button>
|
||||
|
||||
@@ -1,136 +1,159 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
DEFAULT_REPORT_TEMPLATE,
|
||||
REPORT_TEMPLATES,
|
||||
type ReportTemplateDefinition,
|
||||
type SelectableReportTemplateId
|
||||
} from '../../../../shared/report-templates'
|
||||
|
||||
export type ReportTemplateId = 'v1' | 'v2'
|
||||
|
||||
interface TemplateMeta {
|
||||
id: ReportTemplateId
|
||||
label: string
|
||||
tagline: string
|
||||
preview: { title: string; sections: string[] }
|
||||
}
|
||||
|
||||
const TEMPLATES: TemplateMeta[] = [
|
||||
{
|
||||
id: 'v1',
|
||||
label: '模板1 · 经典日报',
|
||||
tagline: '实用信息 / 重要消息 / 金句 / 问答 / 数据可视化',
|
||||
preview: {
|
||||
title: '经典日报',
|
||||
sections: [
|
||||
'今日讨论热点',
|
||||
'AI 识别的图片精选',
|
||||
'实用信息与资源',
|
||||
'重要消息汇总',
|
||||
'有趣对话或金句',
|
||||
'问题与解答',
|
||||
'群内数据可视化',
|
||||
'词云/关键词'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
label: '模板2 · 丰富日报',
|
||||
tagline: '包含更多群聊分析板块;勾选图片后会尝试生成图片精选',
|
||||
preview: {
|
||||
title: '支持图片板块',
|
||||
sections: [
|
||||
'今日讨论热点',
|
||||
'重要消息',
|
||||
'待办事项和未解决问题',
|
||||
'今日名场面',
|
||||
'今日群数据',
|
||||
'关键词',
|
||||
'实用信息与资源',
|
||||
'问题与解答',
|
||||
'今日剧情时间线',
|
||||
'群聊反转现场',
|
||||
'AI 识别的图片精选',
|
||||
'今日群相册',
|
||||
'语音之最',
|
||||
'语音时长榜',
|
||||
'今日临时人设',
|
||||
'话题参与链路'
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
export type { SelectableReportTemplateId } from '../../../../shared/report-templates'
|
||||
|
||||
interface ReportTemplateSelectorProps {
|
||||
value: ReportTemplateId
|
||||
onChange: (value: ReportTemplateId) => void
|
||||
value: SelectableReportTemplateId
|
||||
onChange: (value: SelectableReportTemplateId) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const TemplateDiagram = ({
|
||||
template
|
||||
}: {
|
||||
template: ReportTemplateDefinition
|
||||
}): React.ReactElement => (
|
||||
<div className={`report-template-diagram diagram-${template.id}`} aria-hidden="true">
|
||||
<div className="diagram-masthead">
|
||||
<i />
|
||||
<b />
|
||||
<span />
|
||||
</div>
|
||||
<div className="diagram-kpis">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<div className="diagram-content">
|
||||
<div className="diagram-column diagram-column-a">
|
||||
<b />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<div className="diagram-column diagram-column-b">
|
||||
<b />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<div className="diagram-column diagram-column-c">
|
||||
<b />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled
|
||||
}) => {
|
||||
const [previewing, setPreviewing] = useState<TemplateMeta | null>(null)
|
||||
return (
|
||||
<section className="report-section">
|
||||
<h3>日报模板</h3>
|
||||
<p className="report-section-desc">
|
||||
选择日报呈现风格。勾选图片后会尝试生成图片精选;识别失败不会影响文字日报。
|
||||
</p>
|
||||
const [previewing, setPreviewing] = useState<ReportTemplateDefinition | null>(null)
|
||||
const mobileTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'mobile')
|
||||
const desktopTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'desktop')
|
||||
|
||||
const renderGroup = (
|
||||
title: string,
|
||||
templates: readonly ReportTemplateDefinition[]
|
||||
): React.ReactElement => (
|
||||
<div className="report-template-group">
|
||||
<div className="report-template-group-title">{title}</div>
|
||||
<div className="report-template-list">
|
||||
{TEMPLATES.map((tpl) => {
|
||||
const active = value === tpl.id
|
||||
{templates.map((template) => {
|
||||
const active = value === template.id
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
key={template.id}
|
||||
className={`report-template-item ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="report-template"
|
||||
value={tpl.id}
|
||||
value={template.id}
|
||||
checked={active}
|
||||
disabled={disabled}
|
||||
onChange={() => onChange(tpl.id)}
|
||||
onChange={() => onChange(template.id)}
|
||||
/>
|
||||
<TemplateDiagram template={template} />
|
||||
<div className="report-template-body">
|
||||
<div className="report-template-title">{tpl.label}</div>
|
||||
<div className="report-template-tagline">{tpl.tagline}</div>
|
||||
<div className="report-template-eyebrow">{template.label}</div>
|
||||
<div className="report-template-title">{template.name}</div>
|
||||
<div className="report-template-tagline">{template.tagline}</div>
|
||||
</div>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="report-template-preview-btn"
|
||||
onClick={() => setPreviewing(tpl)}
|
||||
onClick={() => setPreviewing(template)}
|
||||
>
|
||||
预览
|
||||
查看版式
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="report-section">
|
||||
<h3>日报模板</h3>
|
||||
<p className="report-section-desc">
|
||||
默认模板与五套新版模板读取同一份真实日报数据。手机模板适合长图和群内分享,桌面模板适合宽屏阅读与归档。
|
||||
</p>
|
||||
<div className="report-template-catalog">
|
||||
{renderGroup('默认模板', [DEFAULT_REPORT_TEMPLATE])}
|
||||
{renderGroup('手机端 · 375–414 px', mobileTemplates)}
|
||||
{renderGroup('电脑端 · 1280–1920 px', desktopTemplates)}
|
||||
</div>
|
||||
{previewing && (
|
||||
<div className="report-template-preview-mask" onClick={() => setPreviewing(null)}>
|
||||
<div className="report-template-preview-card" onClick={(e) => e.stopPropagation()}>
|
||||
<h4>{previewing.preview.title}</h4>
|
||||
<p className="muted">{previewing.tagline}</p>
|
||||
<div className="report-template-preview-frame">
|
||||
<div className="fake-card fake-hero">
|
||||
<div className="fake-title">群聊日报 · 预览</div>
|
||||
<div className="fake-sub">2026-xx-xx · 基于已加载记录</div>
|
||||
<div
|
||||
className="report-template-preview-card"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="report-template-preview-heading">
|
||||
<div>
|
||||
<span>{previewing.label}</span>
|
||||
<h4>{previewing.name}</h4>
|
||||
</div>
|
||||
{previewing.preview.sections.map((s) => (
|
||||
<div className="fake-card fake-section" key={s}>
|
||||
<div className="fake-bar" />
|
||||
<div className="fake-section-title">{s}</div>
|
||||
</div>
|
||||
))}
|
||||
<em>
|
||||
{previewing.platform === 'desktop'
|
||||
? '桌面宽屏'
|
||||
: previewing.platform === 'default'
|
||||
? '经典长图'
|
||||
: '手机长图'}
|
||||
</em>
|
||||
</div>
|
||||
<p className="muted">{previewing.tagline}</p>
|
||||
<TemplateDiagram template={previewing} />
|
||||
<p className="report-template-preview-note">
|
||||
生成时会自动代入当前群聊的真实头像、昵称、消息、讨论摘要、Q&A、统计与关键词。
|
||||
</p>
|
||||
<div className="report-template-preview-actions">
|
||||
<button type="button" className="secondary" onClick={() => setPreviewing(null)}>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(previewing.id)
|
||||
setPreviewing(null)
|
||||
}}
|
||||
>
|
||||
选择此模板
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="report-template-preview-close"
|
||||
onClick={() => setPreviewing(null)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
SELECTABLE_REPORT_TEMPLATES,
|
||||
type SelectableReportTemplateId
|
||||
} from '../../../../shared/report-templates'
|
||||
|
||||
interface ReportToolbarProps {
|
||||
canCopyImage: boolean
|
||||
canReveal: boolean
|
||||
canSwitchTemplate: boolean
|
||||
currentTemplateId?: SelectableReportTemplateId
|
||||
isSwitchingTemplate: boolean
|
||||
onSwitchTemplate: (templateId: SelectableReportTemplateId) => void
|
||||
onRegenerate: () => void
|
||||
onCopyImage: () => void
|
||||
onReveal: () => void
|
||||
@@ -11,24 +19,66 @@ interface ReportToolbarProps {
|
||||
export function ReportToolbar({
|
||||
canCopyImage,
|
||||
canReveal,
|
||||
canSwitchTemplate,
|
||||
currentTemplateId,
|
||||
isSwitchingTemplate,
|
||||
onSwitchTemplate,
|
||||
onRegenerate,
|
||||
onCopyImage,
|
||||
onReveal
|
||||
}: ReportToolbarProps): React.ReactElement {
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [templateOpen, setTemplateOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const templateMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return
|
||||
if (!moreOpen && !templateOpen) return
|
||||
const close = (event: PointerEvent): void => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) setMoreOpen(false)
|
||||
if (!templateMenuRef.current?.contains(event.target as Node)) setTemplateOpen(false)
|
||||
}
|
||||
window.addEventListener('pointerdown', close)
|
||||
return () => window.removeEventListener('pointerdown', close)
|
||||
}, [moreOpen])
|
||||
}, [moreOpen, templateOpen])
|
||||
|
||||
return (
|
||||
<div className="report-viewer-toolbar">
|
||||
<div className="report-template-switch-menu" ref={templateMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSwitchTemplate || isSwitchingTemplate}
|
||||
title={
|
||||
canSwitchTemplate
|
||||
? '使用已生成的数据或本地 HTML 更换展示模板,不会重新调用 AI'
|
||||
: '当前报告缺少可复用数据和 HTML,无法切换模板'
|
||||
}
|
||||
onClick={() => setTemplateOpen((open) => !open)}
|
||||
>
|
||||
{isSwitchingTemplate ? '切换中…' : '切换模板'}
|
||||
</button>
|
||||
{templateOpen && canSwitchTemplate && (
|
||||
<div className="report-template-switch-popover" role="menu" aria-label="切换日报模板">
|
||||
<p>仅重新排版,不调用 AI</p>
|
||||
{SELECTABLE_REPORT_TEMPLATES.map((template) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={template.id === currentTemplateId ? 'active' : undefined}
|
||||
key={template.id}
|
||||
onClick={() => {
|
||||
setTemplateOpen(false)
|
||||
onSwitchTemplate(template.id)
|
||||
}}
|
||||
>
|
||||
<span>{template.label}</span>
|
||||
<b>{template.name}</b>
|
||||
{template.id === currentTemplateId && <i>当前</i>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={onRegenerate}>
|
||||
重新生成
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { GeneratedReportRecord } from './types'
|
||||
import { ReportEmptyState } from './ReportEmptyState'
|
||||
import { ReportToolbar } from './ReportToolbar'
|
||||
import { ReportZoomBar } from './ReportZoomBar'
|
||||
import type { SelectableReportTemplateId } from '../../../../shared/report-templates'
|
||||
|
||||
interface ReportViewerProps {
|
||||
report: GeneratedReportRecord | null
|
||||
@@ -11,19 +12,43 @@ interface ReportViewerProps {
|
||||
onRegenerate: () => void
|
||||
onCopyImage: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
|
||||
onReveal: (report: GeneratedReportRecord) => Promise<{ success: boolean; error?: string }>
|
||||
onSwitchTemplate: (
|
||||
report: GeneratedReportRecord,
|
||||
templateId: SelectableReportTemplateId
|
||||
) => Promise<{ success: boolean; error?: string }>
|
||||
}
|
||||
|
||||
const calculateFitZoom = (
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
imageWidth: number,
|
||||
imageHeight: number
|
||||
): number =>
|
||||
Math.min(
|
||||
1,
|
||||
Math.min(
|
||||
Math.max(1, viewportWidth - 44) / imageWidth,
|
||||
Math.max(1, viewportHeight - 44) / imageHeight
|
||||
)
|
||||
)
|
||||
|
||||
const normalizeFitZoom = (value: number): number =>
|
||||
Math.max(0.0001, Math.floor(value * 10_000) / 10_000)
|
||||
|
||||
export function ReportViewer({
|
||||
report,
|
||||
hasReports,
|
||||
onBackToConfigure,
|
||||
onRegenerate,
|
||||
onCopyImage,
|
||||
onReveal
|
||||
onReveal,
|
||||
onSwitchTemplate
|
||||
}: ReportViewerProps): React.ReactElement {
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [fitZoom, setFitZoom] = useState(1)
|
||||
const [status, setStatus] = useState('')
|
||||
const [imageError, setImageError] = useState('')
|
||||
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false)
|
||||
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -31,7 +56,9 @@ export function ReportViewer({
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
setStatus('')
|
||||
setImageError('')
|
||||
setIsSwitchingTemplate(false)
|
||||
setZoom(1)
|
||||
setFitZoom(1)
|
||||
setNaturalSize(null)
|
||||
})
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
@@ -39,13 +66,47 @@ export function ReportViewer({
|
||||
|
||||
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
|
||||
|
||||
const fitWidth = (): void => {
|
||||
const measureFitZoom = (): number | null => {
|
||||
const viewport = viewportRef.current
|
||||
if (!viewport || !naturalSize?.width) return
|
||||
const nextZoom = Math.min(2, Math.max(0.25, (viewport.clientWidth - 48) / naturalSize.width))
|
||||
setZoom(Number(nextZoom.toFixed(2)))
|
||||
if (!viewport || !naturalSize?.width || !naturalSize.height) return null
|
||||
return calculateFitZoom(
|
||||
viewport.clientWidth,
|
||||
viewport.clientHeight,
|
||||
naturalSize.width,
|
||||
naturalSize.height
|
||||
)
|
||||
}
|
||||
|
||||
const fitPage = (): void => {
|
||||
const nextFitZoom = measureFitZoom()
|
||||
if (!nextFitZoom) return
|
||||
setFitZoom(normalizeFitZoom(nextFitZoom))
|
||||
setZoom(1)
|
||||
}
|
||||
|
||||
const showActualSize = (): void => {
|
||||
if (!fitZoom) return
|
||||
setZoom(Math.min(64, Math.max(0.25, Number((1 / fitZoom).toFixed(4)))))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
if (!viewport || !naturalSize?.width || !naturalSize.height) return
|
||||
const updateFitZoom = (): void => {
|
||||
const nextFitZoom = calculateFitZoom(
|
||||
viewport.clientWidth,
|
||||
viewport.clientHeight,
|
||||
naturalSize.width,
|
||||
naturalSize.height
|
||||
)
|
||||
setFitZoom(normalizeFitZoom(nextFitZoom))
|
||||
}
|
||||
updateFitZoom()
|
||||
const observer = new ResizeObserver(updateFitZoom)
|
||||
observer.observe(viewport)
|
||||
return () => observer.disconnect()
|
||||
}, [naturalSize?.height, naturalSize?.width])
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
if (!report) return
|
||||
const result = await onCopyImage(report)
|
||||
@@ -58,6 +119,22 @@ export function ReportViewer({
|
||||
setStatus(result.success ? '已打开报告所在文件夹' : result.error || '打开文件夹失败')
|
||||
}
|
||||
|
||||
const handleSwitchTemplate = async (templateId: SelectableReportTemplateId): Promise<void> => {
|
||||
if (!report || isSwitchingTemplate) return
|
||||
if (report.templateId === templateId) {
|
||||
setStatus('当前已是所选模板')
|
||||
return
|
||||
}
|
||||
setIsSwitchingTemplate(true)
|
||||
setStatus('正在使用已有日报数据切换模板…')
|
||||
try {
|
||||
const result = await onSwitchTemplate(report, templateId)
|
||||
setStatus(result.success ? '模板已切换,无需重新生成内容' : result.error || '模板切换失败')
|
||||
} finally {
|
||||
setIsSwitchingTemplate(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<main className="report-viewer">
|
||||
@@ -88,6 +165,14 @@ export function ReportViewer({
|
||||
<ReportToolbar
|
||||
canCopyImage={Boolean(report.generatedImage)}
|
||||
canReveal={Boolean(report.pngPath || report.htmlPath)}
|
||||
canSwitchTemplate={Boolean(
|
||||
(report.reportSnapshot && report.reportMetadata) ||
|
||||
report.reportRenderSnapshot ||
|
||||
(report.htmlStatus === 'ready' && report.htmlPath)
|
||||
)}
|
||||
currentTemplateId={report.templateId}
|
||||
isSwitchingTemplate={isSwitchingTemplate}
|
||||
onSwitchTemplate={(templateId) => void handleSwitchTemplate(templateId)}
|
||||
onRegenerate={onRegenerate}
|
||||
onCopyImage={() => void handleCopy()}
|
||||
onReveal={() => void handleReveal()}
|
||||
@@ -101,7 +186,9 @@ export function ReportViewer({
|
||||
src={report.generatedImage}
|
||||
alt={title}
|
||||
style={{
|
||||
width: naturalSize ? `${Math.round(naturalSize.width * zoom)}px` : undefined
|
||||
width: naturalSize
|
||||
? `${Math.max(1, Math.round(naturalSize.width * fitZoom * zoom))}px`
|
||||
: undefined
|
||||
}}
|
||||
onLoad={(event) => {
|
||||
const image = event.currentTarget
|
||||
@@ -111,9 +198,15 @@ export function ReportViewer({
|
||||
}
|
||||
setNaturalSize(nextSize)
|
||||
const viewport = viewportRef.current
|
||||
if (viewport && nextSize.width > viewport.clientWidth - 48) {
|
||||
const fittedZoom = Math.max(0.25, (viewport.clientWidth - 48) / nextSize.width)
|
||||
setZoom(Number(fittedZoom.toFixed(2)))
|
||||
if (viewport) {
|
||||
const fittedZoom = calculateFitZoom(
|
||||
viewport.clientWidth,
|
||||
viewport.clientHeight,
|
||||
nextSize.width,
|
||||
nextSize.height
|
||||
)
|
||||
setFitZoom(normalizeFitZoom(fittedZoom))
|
||||
setZoom(1)
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
@@ -134,7 +227,12 @@ export function ReportViewer({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ReportZoomBar zoom={zoom} onZoomChange={setZoom} onFitWidth={fitWidth} />
|
||||
<ReportZoomBar
|
||||
zoom={zoom}
|
||||
onZoomChange={setZoom}
|
||||
onFitPage={fitPage}
|
||||
onActualSize={showActualSize}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,27 +3,32 @@ import React from 'react'
|
||||
interface ReportZoomBarProps {
|
||||
zoom: number
|
||||
onZoomChange: (zoom: number) => void
|
||||
onFitWidth: () => void
|
||||
onFitPage: () => void
|
||||
onActualSize: () => void
|
||||
}
|
||||
|
||||
const clampZoom = (value: number): number => Math.min(2, Math.max(0.25, value))
|
||||
const clampZoom = (value: number): number => Math.min(64, Math.max(0.25, value))
|
||||
|
||||
export function ReportZoomBar({
|
||||
zoom,
|
||||
onZoomChange,
|
||||
onFitWidth
|
||||
onFitPage,
|
||||
onActualSize
|
||||
}: ReportZoomBarProps): React.ReactElement {
|
||||
return (
|
||||
<div className="report-zoom-bar">
|
||||
<button type="button" onClick={() => onZoomChange(clampZoom(zoom - 0.1))}>
|
||||
<button type="button" onClick={() => onZoomChange(clampZoom(zoom / 1.25))}>
|
||||
缩小
|
||||
</button>
|
||||
<span>{Math.round(zoom * 100)}%</span>
|
||||
<button type="button" onClick={() => onZoomChange(clampZoom(zoom + 0.1))}>
|
||||
<span title="100% 为完整显示在当前预览框内">{Math.round(zoom * 100)}%</span>
|
||||
<button type="button" onClick={() => onZoomChange(clampZoom(zoom * 1.25))}>
|
||||
放大
|
||||
</button>
|
||||
<button type="button" onClick={onFitWidth}>
|
||||
适应宽度
|
||||
<button type="button" onClick={onFitPage}>
|
||||
完整显示
|
||||
</button>
|
||||
<button type="button" onClick={onActualSize}>
|
||||
原始大小
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,9 @@ export type {
|
||||
ReportAssetStatus,
|
||||
ReportHistoryResult,
|
||||
SaveGeneratedReportRequest,
|
||||
SaveGeneratedReportResult
|
||||
SaveGeneratedReportResult,
|
||||
UpdateGeneratedReportTemplateRequest,
|
||||
UpdateGeneratedReportTemplateResult
|
||||
} from '../../../../shared/report-history'
|
||||
|
||||
export type ReportWorkspaceView = 'configure' | 'result'
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
SummaryDateRange,
|
||||
SummaryMessageType
|
||||
} from '../utils/group-report'
|
||||
import { ReportTemplateId } from '../components/reports/ReportTemplateSelector'
|
||||
import type { GroupDailyReport, GroupReportMetadata } from '../../../shared/group-report'
|
||||
import type {
|
||||
ReportImageInsightSummary,
|
||||
ReportPreparationProgress
|
||||
} from '../utils/group-report-facts'
|
||||
import { SelectableReportTemplateId } from '../components/reports/ReportTemplateSelector'
|
||||
import {
|
||||
transcribeVoiceMessages as transcribeReportVoiceMessages,
|
||||
type VoiceTranscriptionProgress
|
||||
@@ -29,6 +34,7 @@ export type ReportGenerationPhase =
|
||||
| 'loadingMessages'
|
||||
| 'transcribingVoice'
|
||||
| 'preparingInput'
|
||||
| 'awaitingImageDecision'
|
||||
| 'requestingModel'
|
||||
| 'exportingReport'
|
||||
| 'success'
|
||||
@@ -82,6 +88,12 @@ interface UseGroupReportGenerationArgs {
|
||||
modelConfig: AiModelConfig
|
||||
}
|
||||
|
||||
interface PreparedReportContext {
|
||||
input: Awaited<ReturnType<typeof buildGroupReportInput>>
|
||||
startedAt: number
|
||||
logs: ReportGenerationLog[]
|
||||
}
|
||||
|
||||
export interface ReportTaskStep {
|
||||
id: Exclude<ReportGenerationPhase, 'idle' | 'success' | 'error'>
|
||||
label: string
|
||||
@@ -100,6 +112,21 @@ export interface RangeMessageState {
|
||||
error: string
|
||||
}
|
||||
|
||||
const EMPTY_IMAGE_INSIGHT_SUMMARY: ReportImageInsightSummary = {
|
||||
total: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
items: [],
|
||||
failures: []
|
||||
}
|
||||
|
||||
const createStepLog = (label: string, startedAt: Date, endedAt: Date): ReportGenerationLog => ({
|
||||
label,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
duration: endedAt.getTime() - startedAt.getTime()
|
||||
})
|
||||
|
||||
const withTimeout = async <T>(
|
||||
promise: Promise<T>,
|
||||
label: string,
|
||||
@@ -242,19 +269,27 @@ export function useGroupReportGeneration({
|
||||
messageTypeCounts: Record<SummaryMessageType, number>
|
||||
rangeState: RangeMessageState
|
||||
voiceTranscriptionProgress: VoiceTranscriptionProgress | null
|
||||
preparationProgress: ReportPreparationProgress | null
|
||||
imageInsightSummary: ReportImageInsightSummary
|
||||
generatedImage: string | null
|
||||
reportPaths: ReportPaths | null
|
||||
reportSnapshot: GroupDailyReport | null
|
||||
reportMetadata: GroupReportMetadata | null
|
||||
generationMetadata: ReportGenerationMetadata
|
||||
isGenerating: boolean
|
||||
generate: () => Promise<void>
|
||||
retry: () => Promise<void>
|
||||
retry: (modelOverride?: AiModelConfig) => Promise<void>
|
||||
continueAfterImageFailures: () => Promise<void>
|
||||
cancelAfterImageFailures: () => void
|
||||
canRetryModelStep: boolean
|
||||
failedAt: string
|
||||
resetGenerationStatus: () => void
|
||||
clearError: () => void
|
||||
closeResult: () => void
|
||||
copyImage: () => Promise<{ success: boolean; error?: string }>
|
||||
revealReport: () => Promise<{ success: boolean; error?: string }>
|
||||
templateId: ReportTemplateId
|
||||
setTemplateId: (value: ReportTemplateId) => void
|
||||
templateId: SelectableReportTemplateId
|
||||
setTemplateId: (value: SelectableReportTemplateId) => void
|
||||
memberNamePreference: ReportMemberNamePreference
|
||||
setMemberNamePreference: (value: ReportMemberNamePreference) => void
|
||||
reportTimeoutSeconds: number
|
||||
@@ -266,9 +301,18 @@ export function useGroupReportGeneration({
|
||||
const [rangeState, setRangeState] = useState<RangeMessageState>({ status: 'idle', error: '' })
|
||||
const [voiceTranscriptionProgress, setVoiceTranscriptionProgress] =
|
||||
useState<VoiceTranscriptionProgress | null>(null)
|
||||
const [preparationProgress, setPreparationProgress] = useState<ReportPreparationProgress | null>(
|
||||
null
|
||||
)
|
||||
const [imageInsightSummary, setImageInsightSummary] = useState<ReportImageInsightSummary>(
|
||||
EMPTY_IMAGE_INSIGHT_SUMMARY
|
||||
)
|
||||
const [failedAt, setFailedAt] = useState('')
|
||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
||||
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
|
||||
const [reportSnapshot, setReportSnapshot] = useState<GroupDailyReport | null>(null)
|
||||
const [reportMetadata, setReportMetadata] = useState<GroupReportMetadata | null>(null)
|
||||
const [templateId, setTemplateIdState] = useState<SelectableReportTemplateId>('v1')
|
||||
const [memberNamePreference, setMemberNamePreferenceState] = useState<ReportMemberNamePreference>(
|
||||
() => {
|
||||
const saved = localStorage.getItem('group_report_member_name_preference')
|
||||
@@ -283,11 +327,15 @@ export function useGroupReportGeneration({
|
||||
generationLogs: []
|
||||
})
|
||||
const rangeRequestIdRef = useRef(0)
|
||||
const preparedContextRef = useRef<PreparedReportContext | null>(null)
|
||||
|
||||
const setMemberNamePreference = useCallback((value: ReportMemberNamePreference): void => {
|
||||
localStorage.setItem('group_report_member_name_preference', value)
|
||||
setMemberNamePreferenceState(value)
|
||||
}, [])
|
||||
const setTemplateId = useCallback((value: SelectableReportTemplateId): void => {
|
||||
setTemplateIdState(value)
|
||||
}, [])
|
||||
const setReportTimeoutSeconds = useCallback((value: number): void => {
|
||||
const normalized = Math.max(30, Math.min(1800, Math.round(Number(value) || 300)))
|
||||
localStorage.setItem('group_report_timeout_seconds', String(normalized))
|
||||
@@ -298,6 +346,7 @@ export function useGroupReportGeneration({
|
||||
phase === 'loadingMessages' ||
|
||||
phase === 'transcribingVoice' ||
|
||||
phase === 'preparingInput' ||
|
||||
phase === 'awaitingImageDecision' ||
|
||||
phase === 'requestingModel' ||
|
||||
phase === 'exportingReport'
|
||||
|
||||
@@ -374,7 +423,13 @@ export function useGroupReportGeneration({
|
||||
setError('')
|
||||
setGeneratedImage(null)
|
||||
setReportPaths(null)
|
||||
setReportSnapshot(null)
|
||||
setReportMetadata(null)
|
||||
setVoiceTranscriptionProgress(null)
|
||||
setPreparationProgress(null)
|
||||
setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY)
|
||||
setFailedAt('')
|
||||
preparedContextRef.current = null
|
||||
setGenerationMetadata({ generationLogs: [] })
|
||||
}, [])
|
||||
|
||||
@@ -396,45 +451,238 @@ export function useGroupReportGeneration({
|
||||
[]
|
||||
)
|
||||
|
||||
const runPreparedReport = useCallback(
|
||||
async (context: PreparedReportContext, selectedModel: AiModelConfig): Promise<void> => {
|
||||
if (!selectedModel.configured || !selectedModel.model) {
|
||||
setFailedAt('调用模型生成内容')
|
||||
setError('请选择一个已配置的 AI 模型')
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
let currentFailedAt = '调用模型生成内容'
|
||||
const pushLog = (log: ReportGenerationLog): void => {
|
||||
context.logs.push(log)
|
||||
setGenerationMetadata({
|
||||
modelName: selectedModel.model,
|
||||
generationLogs: [...context.logs]
|
||||
})
|
||||
}
|
||||
const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => {
|
||||
const startedAt = new Date()
|
||||
try {
|
||||
return await task()
|
||||
} finally {
|
||||
pushLog(createStepLog(label, startedAt, new Date()))
|
||||
}
|
||||
}
|
||||
|
||||
setError('')
|
||||
setFailedAt('')
|
||||
setPhase('requestingModel')
|
||||
setPreparationProgress({ stage: 'summarizingInput', label: '整理总结中' })
|
||||
setGenerationMetadata({
|
||||
modelName: selectedModel.model,
|
||||
generationLogs: [...context.logs]
|
||||
})
|
||||
writeReportLog('info', '调用模型生成日报内容', {
|
||||
providerName: selectedModel.providerName,
|
||||
model: selectedModel.model,
|
||||
reusedPreparedInput: true,
|
||||
imageInsights: context.input.imageInsightSummary.succeeded
|
||||
})
|
||||
|
||||
try {
|
||||
const aiMessages = [
|
||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: context.input.prompt }
|
||||
]
|
||||
const result = await trackStep(`AI 生成(${selectedModel.model})`, () =>
|
||||
withTimeout(
|
||||
window.api.aiChat(aiMessages, {
|
||||
providerId: selectedModel.providerId,
|
||||
modelId: selectedModel.model,
|
||||
timeoutMs: reportTimeoutSeconds * 1000
|
||||
}),
|
||||
'AI 生成日报',
|
||||
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
|
||||
)
|
||||
)
|
||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||
writeReportLog('info', '模型响应完成', {
|
||||
outputLength: result.data.length,
|
||||
usage: result.usage
|
||||
})
|
||||
|
||||
let tokenUsage =
|
||||
result.usage && result.usage.total
|
||||
? result.usage
|
||||
: estimateTokenUsage(aiMessages, result.data)
|
||||
|
||||
let report: ReturnType<typeof parseGroupDailyReport>
|
||||
try {
|
||||
report = parseGroupDailyReport(
|
||||
result.data,
|
||||
context.input.topSpeakers,
|
||||
context.input.activeTimeline,
|
||||
context.input.voiceLeaderboard || [],
|
||||
context.input.metadata,
|
||||
context.input.media
|
||||
)
|
||||
} catch (parseError) {
|
||||
writeReportLog('warn', '本地修复日报 JSON 失败,尝试由模型纠正', {
|
||||
...jsonErrorContext(result.data, parseError),
|
||||
retry: 1
|
||||
})
|
||||
const repairMessages = [
|
||||
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: result.data }
|
||||
]
|
||||
const repairResult = await trackStep(`AI 修复 JSON(${selectedModel.model})`, () =>
|
||||
withTimeout(
|
||||
window.api.aiChat(repairMessages, {
|
||||
providerId: selectedModel.providerId,
|
||||
modelId: selectedModel.model,
|
||||
timeoutMs: reportTimeoutSeconds * 1000
|
||||
}),
|
||||
'AI 修复日报 JSON',
|
||||
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
|
||||
)
|
||||
)
|
||||
if (!repairResult.success || !repairResult.data) {
|
||||
throw new Error(repairResult.error || 'AI 修复日报 JSON 失败', {
|
||||
cause: parseError
|
||||
})
|
||||
}
|
||||
const repairUsage =
|
||||
repairResult.usage && repairResult.usage.total
|
||||
? repairResult.usage
|
||||
: estimateTokenUsage(repairMessages, repairResult.data)
|
||||
tokenUsage = mergeTokenUsage(tokenUsage, repairUsage)
|
||||
try {
|
||||
report = parseGroupDailyReport(
|
||||
repairResult.data,
|
||||
context.input.topSpeakers,
|
||||
context.input.activeTimeline,
|
||||
context.input.voiceLeaderboard || [],
|
||||
context.input.metadata,
|
||||
context.input.media
|
||||
)
|
||||
writeReportLog('info', '模型已纠正日报 JSON', {
|
||||
retry: 1,
|
||||
outputLength: repairResult.data.length
|
||||
})
|
||||
} catch (retryParseError) {
|
||||
writeReportLog(
|
||||
'error',
|
||||
'日报 JSON 重试后仍解析失败',
|
||||
jsonErrorContext(repairResult.data, retryParseError)
|
||||
)
|
||||
throw retryParseError
|
||||
}
|
||||
}
|
||||
|
||||
setPhase('exportingReport')
|
||||
currentFailedAt = '导出 HTML 与 PNG'
|
||||
const exported = await withTimeout(
|
||||
window.api.exportGroupReport({
|
||||
report,
|
||||
metadata: context.input.metadata,
|
||||
templateId
|
||||
}),
|
||||
'日报图片导出'
|
||||
)
|
||||
if (
|
||||
!exported.success ||
|
||||
!exported.imageDataUrl ||
|
||||
!exported.htmlPath ||
|
||||
!exported.pngPath
|
||||
) {
|
||||
throw new Error(exported.error || '日报文件生成失败')
|
||||
}
|
||||
if (exported.exportTimings?.html) {
|
||||
pushLog({ label: 'HTML 导出', ...exported.exportTimings.html })
|
||||
}
|
||||
if (exported.exportTimings?.png) {
|
||||
pushLog({ label: 'PNG 导出', ...exported.exportTimings.png })
|
||||
}
|
||||
const exportFinishedAt =
|
||||
exported.exportTimings?.png?.endedAt || exported.exportTimings?.html?.endedAt
|
||||
const exportFinishTime = exportFinishedAt ? Date.parse(exportFinishedAt) : Date.now()
|
||||
|
||||
setGeneratedImage(exported.imageDataUrl)
|
||||
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
|
||||
setReportSnapshot(report)
|
||||
setReportMetadata(context.input.metadata)
|
||||
setGenerationMetadata({
|
||||
durationMs:
|
||||
Number.isFinite(exportFinishTime) && exportFinishTime > context.startedAt
|
||||
? exportFinishTime - context.startedAt
|
||||
: Date.now() - context.startedAt,
|
||||
modelName: selectedModel.model,
|
||||
tokenUsage,
|
||||
generationLogs: [...context.logs]
|
||||
})
|
||||
preparedContextRef.current = null
|
||||
setPreparationProgress(null)
|
||||
setPhase('success')
|
||||
writeReportLog('info', '群聊日报生成成功', {
|
||||
durationMs: Date.now() - context.startedAt,
|
||||
htmlPath: exported.htmlPath,
|
||||
pngPath: exported.pngPath,
|
||||
providerName: selectedModel.providerName,
|
||||
model: selectedModel.model
|
||||
})
|
||||
} catch (generateError) {
|
||||
const message = errorMessage(generateError)
|
||||
writeReportLog('error', '群聊日报生成失败', {
|
||||
error: message,
|
||||
failedAt: currentFailedAt,
|
||||
durationMs: Date.now() - context.startedAt,
|
||||
reusablePreparedInput: currentFailedAt === '调用模型生成内容'
|
||||
})
|
||||
setFailedAt(currentFailedAt)
|
||||
setError(message)
|
||||
setPhase('error')
|
||||
}
|
||||
},
|
||||
[reportTimeoutSeconds, templateId]
|
||||
)
|
||||
|
||||
const generate = useCallback(async (): Promise<void> => {
|
||||
if (isGenerating) return
|
||||
if (!sourceContact) {
|
||||
setFailedAt('初始化')
|
||||
setPhase('error')
|
||||
setError('请先选择一个群聊')
|
||||
return
|
||||
}
|
||||
if (!isGroupContact(sourceContact)) {
|
||||
setFailedAt('初始化')
|
||||
setPhase('error')
|
||||
setError('AI 群聊日报仅支持群聊')
|
||||
return
|
||||
}
|
||||
if (!modelConfig.configured) {
|
||||
setFailedAt('调用模型生成内容')
|
||||
setPhase('error')
|
||||
setError('尚未配置可用的默认 AI 模型')
|
||||
return
|
||||
}
|
||||
if (!summaryMessageTypes.length) {
|
||||
setFailedAt('初始化')
|
||||
setPhase('error')
|
||||
setError('请至少选择一种消息类型')
|
||||
return
|
||||
}
|
||||
|
||||
const startGenerateTime = Date.now()
|
||||
let failedAt = '初始化'
|
||||
let currentFailedAt = '初始化'
|
||||
const logs: ReportGenerationLog[] = []
|
||||
const pushLog = (log: ReportGenerationLog): void => {
|
||||
logs.push(log)
|
||||
setGenerationMetadata({
|
||||
modelName: modelConfig.model,
|
||||
generationLogs: [...logs]
|
||||
})
|
||||
setGenerationMetadata({ modelName: modelConfig.model, generationLogs: [...logs] })
|
||||
}
|
||||
const createStepLog = (label: string, startedAt: Date, endedAt: Date): ReportGenerationLog => ({
|
||||
label,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
duration: endedAt.getTime() - startedAt.getTime()
|
||||
})
|
||||
const trackStep = async <T>(label: string, task: () => Promise<T>): Promise<T> => {
|
||||
const startedAt = new Date()
|
||||
try {
|
||||
@@ -444,13 +692,17 @@ export function useGroupReportGeneration({
|
||||
}
|
||||
}
|
||||
|
||||
preparedContextRef.current = null
|
||||
setError('')
|
||||
setFailedAt('')
|
||||
setGeneratedImage(null)
|
||||
setReportPaths(null)
|
||||
setGenerationMetadata({
|
||||
modelName: modelConfig.model,
|
||||
generationLogs: []
|
||||
})
|
||||
setReportSnapshot(null)
|
||||
setReportMetadata(null)
|
||||
setVoiceTranscriptionProgress(null)
|
||||
setPreparationProgress(null)
|
||||
setImageInsightSummary(EMPTY_IMAGE_INSIGHT_SUMMARY)
|
||||
setGenerationMetadata({ modelName: modelConfig.model, generationLogs: [] })
|
||||
writeReportLog('info', '开始生成群聊日报', {
|
||||
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
|
||||
dateRange: summaryDateRange,
|
||||
@@ -461,9 +713,8 @@ export function useGroupReportGeneration({
|
||||
})
|
||||
|
||||
try {
|
||||
failedAt = '读取聊天记录'
|
||||
currentFailedAt = '读取聊天记录'
|
||||
const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
|
||||
|
||||
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
|
||||
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
|
||||
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
|
||||
@@ -473,7 +724,7 @@ export function useGroupReportGeneration({
|
||||
})
|
||||
|
||||
setPhase(selectedTypes.has('语音') ? 'transcribingVoice' : 'preparingInput')
|
||||
failedAt = '整理日报输入'
|
||||
currentFailedAt = '整理日报输入'
|
||||
const input = await trackStep('整理输入', async () => {
|
||||
const messagesWithTranscripts = selectedTypes.has('语音')
|
||||
? await transcribeSelectedVoiceMessages(filteredMessages)
|
||||
@@ -484,150 +735,40 @@ export function useGroupReportGeneration({
|
||||
messagesWithTranscripts,
|
||||
memberNamePreference
|
||||
)
|
||||
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full')
|
||||
})
|
||||
|
||||
setPhase('requestingModel')
|
||||
failedAt = '调用模型生成内容'
|
||||
const aiMessages = [
|
||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: input.prompt }
|
||||
]
|
||||
const result = await trackStep('AI 生成', () =>
|
||||
withTimeout(
|
||||
window.api.aiChat(aiMessages, {
|
||||
providerId: modelConfig.providerId,
|
||||
modelId: modelConfig.model,
|
||||
timeoutMs: reportTimeoutSeconds * 1000
|
||||
}),
|
||||
'AI 生成日报',
|
||||
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
|
||||
)
|
||||
)
|
||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||
writeReportLog('info', '模型响应完成', {
|
||||
outputLength: result.data.length,
|
||||
usage: result.usage
|
||||
})
|
||||
|
||||
let tokenUsage =
|
||||
result.usage && result.usage.total
|
||||
? result.usage
|
||||
: estimateTokenUsage(aiMessages, result.data)
|
||||
|
||||
let report: ReturnType<typeof parseGroupDailyReport>
|
||||
try {
|
||||
report = parseGroupDailyReport(
|
||||
result.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard || [],
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
} catch (parseError) {
|
||||
writeReportLog('warn', '本地修复日报 JSON 失败,尝试由模型纠正', {
|
||||
...jsonErrorContext(result.data, parseError),
|
||||
retry: 1
|
||||
return buildGroupReportInput(namedReportMessages, sourceContact, true, 'full', {
|
||||
onProgress: setPreparationProgress
|
||||
})
|
||||
const repairMessages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT
|
||||
},
|
||||
{ role: 'user', content: result.data }
|
||||
]
|
||||
const repairResult = await trackStep('AI 修复 JSON', () =>
|
||||
withTimeout(
|
||||
window.api.aiChat(repairMessages, {
|
||||
providerId: modelConfig.providerId,
|
||||
modelId: modelConfig.model,
|
||||
timeoutMs: reportTimeoutSeconds * 1000
|
||||
}),
|
||||
'AI 修复日报 JSON',
|
||||
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
|
||||
)
|
||||
)
|
||||
if (!repairResult.success || !repairResult.data) {
|
||||
throw new Error(repairResult.error || 'AI 修复日报 JSON 失败', { cause: parseError })
|
||||
}
|
||||
const repairUsage =
|
||||
repairResult.usage && repairResult.usage.total
|
||||
? repairResult.usage
|
||||
: estimateTokenUsage(repairMessages, repairResult.data)
|
||||
tokenUsage = mergeTokenUsage(tokenUsage, repairUsage)
|
||||
try {
|
||||
report = parseGroupDailyReport(
|
||||
repairResult.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard || [],
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
writeReportLog('info', '模型已纠正日报 JSON', {
|
||||
retry: 1,
|
||||
outputLength: repairResult.data.length
|
||||
})
|
||||
} catch (retryParseError) {
|
||||
writeReportLog(
|
||||
'error',
|
||||
'日报 JSON 重试后仍解析失败',
|
||||
jsonErrorContext(repairResult.data, retryParseError)
|
||||
)
|
||||
throw retryParseError
|
||||
}
|
||||
}
|
||||
|
||||
setPhase('exportingReport')
|
||||
failedAt = '导出 HTML 与 PNG'
|
||||
const exported = await withTimeout(
|
||||
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
|
||||
'日报图片导出'
|
||||
)
|
||||
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||
throw new Error(exported.error || '日报文件生成失败')
|
||||
}
|
||||
if (exported.exportTimings?.html) {
|
||||
pushLog({
|
||||
label: 'HTML 导出',
|
||||
...exported.exportTimings.html
|
||||
})
|
||||
}
|
||||
if (exported.exportTimings?.png) {
|
||||
pushLog({
|
||||
label: 'PNG 导出',
|
||||
...exported.exportTimings.png
|
||||
})
|
||||
}
|
||||
const exportFinishedAt =
|
||||
exported.exportTimings?.png?.endedAt || exported.exportTimings?.html?.endedAt
|
||||
const exportFinishTime = exportFinishedAt ? Date.parse(exportFinishedAt) : Date.now()
|
||||
|
||||
setGeneratedImage(exported.imageDataUrl)
|
||||
setReportPaths({ htmlPath: exported.htmlPath, pngPath: exported.pngPath })
|
||||
setGenerationMetadata({
|
||||
durationMs:
|
||||
Number.isFinite(exportFinishTime) && exportFinishTime > startGenerateTime
|
||||
? exportFinishTime - startGenerateTime
|
||||
: Date.now() - startGenerateTime,
|
||||
modelName: modelConfig.model,
|
||||
tokenUsage,
|
||||
generationLogs: [...logs]
|
||||
})
|
||||
setPhase('success')
|
||||
writeReportLog('info', '群聊日报生成成功', {
|
||||
durationMs: Date.now() - startGenerateTime,
|
||||
htmlPath: exported.htmlPath,
|
||||
pngPath: exported.pngPath
|
||||
|
||||
setImageInsightSummary(input.imageInsightSummary)
|
||||
writeReportLog('info', '日报输入整理完成', {
|
||||
imageCandidates: input.imageInsightSummary.total,
|
||||
imageInsightSucceeded: input.imageInsightSummary.succeeded,
|
||||
imageInsightFailed: input.imageInsightSummary.failed,
|
||||
imageInsightsInjectedIntoPrompt:
|
||||
input.imageInsightSummary.succeeded > 0 && input.prompt.includes('AI 图片识别摘要:')
|
||||
})
|
||||
const context: PreparedReportContext = { input, startedAt: startGenerateTime, logs }
|
||||
preparedContextRef.current = context
|
||||
if (input.imageInsightSummary.failed > 0) {
|
||||
setPreparationProgress({
|
||||
stage: 'summarizingInput',
|
||||
label: '等待确认是否继续文字总结',
|
||||
completed: input.imageInsightSummary.succeeded,
|
||||
total: input.imageInsightSummary.total
|
||||
})
|
||||
setPhase('awaitingImageDecision')
|
||||
return
|
||||
}
|
||||
await runPreparedReport(context, modelConfig)
|
||||
} catch (generateError) {
|
||||
const message = errorMessage(generateError)
|
||||
writeReportLog('error', '群聊日报生成失败', {
|
||||
error: message,
|
||||
failedAt,
|
||||
failedAt: currentFailedAt,
|
||||
durationMs: Date.now() - startGenerateTime
|
||||
})
|
||||
setFailedAt(currentFailedAt)
|
||||
setError(message)
|
||||
setPhase('error')
|
||||
}
|
||||
@@ -636,7 +777,7 @@ export function useGroupReportGeneration({
|
||||
loadRangeMessages,
|
||||
memberNamePreference,
|
||||
modelConfig,
|
||||
reportTimeoutSeconds,
|
||||
runPreparedReport,
|
||||
sourceContact,
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
@@ -644,8 +785,48 @@ export function useGroupReportGeneration({
|
||||
transcribeSelectedVoiceMessages
|
||||
])
|
||||
|
||||
const retry = useCallback(
|
||||
async (modelOverride?: AiModelConfig): Promise<void> => {
|
||||
const context = preparedContextRef.current
|
||||
if (failedAt === '调用模型生成内容' && context) {
|
||||
await runPreparedReport(context, modelOverride || modelConfig)
|
||||
return
|
||||
}
|
||||
await generate()
|
||||
},
|
||||
[failedAt, generate, modelConfig, runPreparedReport]
|
||||
)
|
||||
|
||||
const continueAfterImageFailures = useCallback(async (): Promise<void> => {
|
||||
const context = preparedContextRef.current
|
||||
if (!context || phase !== 'awaitingImageDecision') return
|
||||
writeReportLog('warn', '用户选择忽略图片识别失败并继续文字总结', {
|
||||
imageInsightSucceeded: context.input.imageInsightSummary.succeeded,
|
||||
imageInsightFailed: context.input.imageInsightSummary.failed
|
||||
})
|
||||
await runPreparedReport(context, modelConfig)
|
||||
}, [modelConfig, phase, runPreparedReport])
|
||||
|
||||
const cancelAfterImageFailures = useCallback((): void => {
|
||||
const summary = preparedContextRef.current?.input.imageInsightSummary
|
||||
preparedContextRef.current = null
|
||||
setError('')
|
||||
setFailedAt('')
|
||||
setPreparationProgress(null)
|
||||
setPhase('idle')
|
||||
writeReportLog('warn', '用户因图片识别失败停止本次日报生成', {
|
||||
imageInsightSucceeded: summary?.succeeded || 0,
|
||||
imageInsightFailed: summary?.failed || 0
|
||||
})
|
||||
}, [])
|
||||
|
||||
const canRetryModelStep =
|
||||
phase === 'error' && failedAt === '调用模型生成内容' && Boolean(preparedContextRef.current)
|
||||
|
||||
const clearError = useCallback((): void => {
|
||||
setError('')
|
||||
setFailedAt('')
|
||||
preparedContextRef.current = null
|
||||
setPhase('idle')
|
||||
}, [])
|
||||
|
||||
@@ -671,12 +852,20 @@ export function useGroupReportGeneration({
|
||||
messageTypeCounts,
|
||||
rangeState,
|
||||
voiceTranscriptionProgress,
|
||||
preparationProgress,
|
||||
imageInsightSummary,
|
||||
generatedImage,
|
||||
reportPaths,
|
||||
reportSnapshot,
|
||||
reportMetadata,
|
||||
generationMetadata,
|
||||
isGenerating,
|
||||
generate,
|
||||
retry: generate,
|
||||
retry,
|
||||
continueAfterImageFailures,
|
||||
cancelAfterImageFailures,
|
||||
canRetryModelStep,
|
||||
failedAt,
|
||||
resetGenerationStatus,
|
||||
clearError,
|
||||
closeResult,
|
||||
|
||||
@@ -649,6 +649,166 @@
|
||||
font: 12px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-preparation-progress,
|
||||
.report-image-insights,
|
||||
.report-image-decision {
|
||||
margin: 0 18px 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-main);
|
||||
color: var(--wxex-text-secondary);
|
||||
font: 12px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-preparation-progress > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.report-preparation-progress strong,
|
||||
.report-image-insights summary,
|
||||
.report-image-decision b {
|
||||
color: var(--wxex-text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.report-preparation-progress progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.report-image-insights {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.report-image-insights summary {
|
||||
padding: 11px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.report-image-insight-list {
|
||||
display: grid;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
}
|
||||
|
||||
.report-image-insight-list article {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
}
|
||||
|
||||
.report-image-insight-list article:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.report-image-insight-list article.failed {
|
||||
background: #fff7f3;
|
||||
}
|
||||
|
||||
.report-image-insight-list article > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.report-image-insight-list b {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--wxex-text-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-image-insight-list time,
|
||||
.report-image-insight-list small {
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
|
||||
.report-image-insight-list p,
|
||||
.report-image-decision p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.report-image-decision {
|
||||
border-color: rgba(197, 137, 48, 0.4);
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.report-image-decision p {
|
||||
margin: 5px 0 10px;
|
||||
}
|
||||
|
||||
.report-image-decision > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.report-image-decision button,
|
||||
.report-model-retry button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-primary);
|
||||
cursor: pointer;
|
||||
font: 600 12px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-image-decision button:first-child,
|
||||
.report-model-retry button {
|
||||
border-color: var(--wxex-ai);
|
||||
background: var(--wxex-ai);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.report-model-retry {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.report-model-retry label {
|
||||
color: var(--wxex-text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.report-model-retry select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: #fff;
|
||||
color: var(--wxex-text-primary);
|
||||
font: 12px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-model-retry small {
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
|
||||
.report-model-retry small.error {
|
||||
color: var(--wxex-danger);
|
||||
}
|
||||
|
||||
.report-model-retry button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.report-voice-progress {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
|
||||
@@ -259,6 +259,8 @@
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -292,6 +294,63 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.report-template-switch-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.report-template-switch-popover {
|
||||
position: absolute;
|
||||
top: 38px;
|
||||
right: 0;
|
||||
z-index: 9;
|
||||
width: 260px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-elevated);
|
||||
box-shadow: var(--wxex-shadow-popover);
|
||||
}
|
||||
|
||||
.report-template-switch-popover p {
|
||||
margin: 0 4px 6px;
|
||||
color: var(--wxex-text-muted);
|
||||
font: 12px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-template-switch-popover button {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
grid-template-columns: 62px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.report-template-switch-popover button:hover,
|
||||
.report-template-switch-popover button.active {
|
||||
background: var(--wxex-ai-soft);
|
||||
}
|
||||
|
||||
.report-template-switch-popover button span,
|
||||
.report-template-switch-popover button i {
|
||||
color: var(--wxex-text-muted);
|
||||
font: 11px/16px var(--wxex-font);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.report-template-switch-popover button b {
|
||||
overflow: hidden;
|
||||
color: var(--wxex-text-primary);
|
||||
font: 600 13px/18px var(--wxex-font);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-more-popover {
|
||||
position: absolute;
|
||||
top: 38px;
|
||||
@@ -550,6 +609,23 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.report-template-catalog,
|
||||
.report-template-group {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-template-catalog {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.report-template-group-title {
|
||||
color: var(--wxex-text-muted);
|
||||
font: 700 11px/16px var(--wxex-font);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.report-template-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -582,6 +658,14 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.report-template-eyebrow {
|
||||
margin-bottom: 2px;
|
||||
color: var(--wxex-brand);
|
||||
font: 800 10px/14px var(--wxex-font);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.report-template-item input[type='radio'] {
|
||||
accent-color: var(--wxex-primary, #07c160);
|
||||
}
|
||||
@@ -597,6 +681,190 @@
|
||||
color: var(--wxex-text-primary, #1f2933);
|
||||
}
|
||||
|
||||
.report-template-diagram {
|
||||
display: grid;
|
||||
width: 72px;
|
||||
height: 54px;
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
padding: 6px;
|
||||
border: 1px solid #dce5df;
|
||||
border-radius: 8px;
|
||||
background: #f8faf8;
|
||||
}
|
||||
|
||||
.diagram-masthead,
|
||||
.diagram-kpis,
|
||||
.diagram-content,
|
||||
.diagram-column {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.diagram-masthead {
|
||||
grid-template-columns: 1fr 18px;
|
||||
}
|
||||
|
||||
.diagram-masthead i,
|
||||
.diagram-masthead b,
|
||||
.diagram-masthead span,
|
||||
.diagram-kpis i,
|
||||
.diagram-column b,
|
||||
.diagram-column span {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
min-height: 2px;
|
||||
border-radius: 2px;
|
||||
background: #b9c9c0;
|
||||
}
|
||||
|
||||
.diagram-masthead i {
|
||||
height: 4px;
|
||||
background: #278861;
|
||||
}
|
||||
|
||||
.diagram-masthead b {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.diagram-masthead span {
|
||||
grid-column: 1 / -1;
|
||||
width: 72%;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.diagram-kpis {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.diagram-kpis i {
|
||||
height: 7px;
|
||||
background: #dceae2;
|
||||
}
|
||||
|
||||
.diagram-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.diagram-column b {
|
||||
height: 8px;
|
||||
background: #8ab6a0;
|
||||
}
|
||||
|
||||
.diagram-column span {
|
||||
height: 4px;
|
||||
background: #d5dfd9;
|
||||
}
|
||||
|
||||
.diagram-mobile-feed .diagram-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.diagram-v1 {
|
||||
background: #f5f7f8;
|
||||
}
|
||||
|
||||
.diagram-v1 .diagram-masthead i {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.diagram-v1 .diagram-kpis i {
|
||||
background: #e5edf6;
|
||||
}
|
||||
|
||||
.diagram-v1 .diagram-column-b,
|
||||
.diagram-v1 .diagram-column-c {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diagram-mobile-feed .diagram-column-b,
|
||||
.diagram-mobile-feed .diagram-column-c {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diagram-mobile-magazine {
|
||||
border-radius: 2px;
|
||||
background: #fbf7ef;
|
||||
}
|
||||
|
||||
.diagram-mobile-magazine .diagram-masthead i {
|
||||
height: 7px;
|
||||
background: #2c2925;
|
||||
}
|
||||
|
||||
.diagram-mobile-magazine .diagram-kpis i {
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border-top: 1px solid #574f45;
|
||||
border-bottom: 1px solid #574f45;
|
||||
}
|
||||
|
||||
.diagram-mobile-magazine .diagram-column-b,
|
||||
.diagram-mobile-magazine .diagram-column-c {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diagram-mobile-dashboard {
|
||||
border-color: #30453b;
|
||||
border-radius: 3px;
|
||||
background: #13201b;
|
||||
}
|
||||
|
||||
.diagram-mobile-dashboard .diagram-masthead i,
|
||||
.diagram-mobile-dashboard .diagram-column b {
|
||||
background: #55e69a;
|
||||
}
|
||||
|
||||
.diagram-mobile-dashboard .diagram-kpis i {
|
||||
border: 1px solid #30453b;
|
||||
border-radius: 2px;
|
||||
background: #1c2d26;
|
||||
}
|
||||
|
||||
.diagram-mobile-dashboard .diagram-column span {
|
||||
background: #385347;
|
||||
}
|
||||
|
||||
.diagram-mobile-dashboard .diagram-column-b,
|
||||
.diagram-mobile-dashboard .diagram-column-c {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diagram-desktop-workspace {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.diagram-desktop-workspace .diagram-content {
|
||||
grid-template-columns: 0.72fr 1.45fr 0.8fr;
|
||||
}
|
||||
|
||||
.diagram-desktop-editorial {
|
||||
width: 90px;
|
||||
border-radius: 2px;
|
||||
background: #fbf6eb;
|
||||
}
|
||||
|
||||
.diagram-desktop-editorial .diagram-masthead i {
|
||||
height: 7px;
|
||||
border-radius: 0;
|
||||
background: #332b23;
|
||||
}
|
||||
|
||||
.diagram-desktop-editorial .diagram-content {
|
||||
grid-template-columns: 1.7fr 0.8fr;
|
||||
}
|
||||
|
||||
.diagram-desktop-editorial .diagram-column-c {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diagram-desktop-editorial .diagram-column b {
|
||||
border-radius: 0;
|
||||
background: #9a4b38;
|
||||
}
|
||||
|
||||
.report-template-tagline {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
@@ -641,12 +909,80 @@
|
||||
background: #f3f5f7;
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
width: min(380px, 100%);
|
||||
width: min(520px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 16px 60px rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
.report-template-preview-card > .report-template-diagram {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
padding: 18px;
|
||||
gap: 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.report-template-preview-card > .diagram-v1,
|
||||
.report-template-preview-card > .diagram-mobile-feed,
|
||||
.report-template-preview-card > .diagram-mobile-magazine,
|
||||
.report-template-preview-card > .diagram-mobile-dashboard {
|
||||
width: 180px;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.report-template-preview-card > .report-template-diagram .diagram-masthead,
|
||||
.report-template-preview-card > .report-template-diagram .diagram-kpis,
|
||||
.report-template-preview-card > .report-template-diagram .diagram-content,
|
||||
.report-template-preview-card > .report-template-diagram .diagram-column {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.report-template-preview-card > .report-template-diagram .diagram-masthead i,
|
||||
.report-template-preview-card > .report-template-diagram .diagram-masthead b {
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.report-template-preview-card > .report-template-diagram .diagram-masthead span {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.report-template-preview-card > .report-template-diagram .diagram-kpis i {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.report-template-preview-card > .report-template-diagram .diagram-column b {
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.report-template-preview-card > .report-template-diagram .diagram-column span {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.report-template-preview-heading,
|
||||
.report-template-preview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-template-preview-heading span,
|
||||
.report-template-preview-heading em {
|
||||
color: var(--wxex-text-muted);
|
||||
font: 700 10px/14px var(--wxex-font);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.report-template-preview-heading em {
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.report-template-preview-card h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 17px;
|
||||
@@ -710,10 +1046,19 @@
|
||||
color: #1f2933;
|
||||
}
|
||||
|
||||
.report-template-preview-close {
|
||||
.report-template-preview-note {
|
||||
margin: 14px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font: 12px/18px var(--wxex-font);
|
||||
}
|
||||
|
||||
.report-template-preview-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.report-template-preview-actions button {
|
||||
padding: 9px 14px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--wxex-primary, #07c160);
|
||||
@@ -722,6 +1067,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-template-preview-actions button.secondary {
|
||||
border: 1px solid var(--wxex-border);
|
||||
background: #fff;
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.settings-auto-login-card {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
@@ -818,4 +1169,3 @@
|
||||
color: var(--wxex-text-muted);
|
||||
font: 11px/17px var(--wxex-font);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
GroupDailyReport,
|
||||
GroupReportMetadata,
|
||||
ReportFunBadge,
|
||||
ReportMediaGalleryItem,
|
||||
ReportMode,
|
||||
ReportSpeakerRank,
|
||||
ReportVisionGalleryItem,
|
||||
@@ -16,6 +15,7 @@ import type {
|
||||
ImageCandidate,
|
||||
ImageCandidateQuery
|
||||
} from '../../../shared/image-insight'
|
||||
import { calculateImageHeatScore, isHotImageCandidate } from '../../../shared/image-insight'
|
||||
|
||||
interface ReportImageReadResult {
|
||||
success: boolean
|
||||
@@ -57,6 +57,42 @@ export interface GroupReportFactsSnapshot {
|
||||
media: GroupDailyReport['media']
|
||||
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||
factsPrompt: string
|
||||
imageInsightSummary: ReportImageInsightSummary
|
||||
}
|
||||
|
||||
export interface ReportImageInsightItem {
|
||||
messageId: string
|
||||
sender: string
|
||||
time: string
|
||||
description: string
|
||||
ocrText?: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface ReportImageInsightFailure {
|
||||
messageId?: string
|
||||
sender: string
|
||||
time?: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface ReportImageInsightSummary {
|
||||
total: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
items: ReportImageInsightItem[]
|
||||
failures: ReportImageInsightFailure[]
|
||||
}
|
||||
|
||||
export interface ReportPreparationProgress {
|
||||
stage: 'selectingImages' | 'recognizingImages' | 'summarizingInput'
|
||||
label: string
|
||||
completed?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface BuildGroupReportFactsOptions {
|
||||
onProgress?: (progress: ReportPreparationProgress) => void
|
||||
}
|
||||
|
||||
function friendlyImageNotice(warnings: string[]): string {
|
||||
@@ -180,12 +216,14 @@ const buildImageContext = (
|
||||
stats: string
|
||||
responseCount: number
|
||||
participantCount: number
|
||||
interactionCount: number
|
||||
snippets: string[]
|
||||
} => {
|
||||
const baseTime = parseTimestamp(messages[index])
|
||||
const participants = new Set<string>()
|
||||
const snippets: string[] = []
|
||||
let responseCount = 0
|
||||
let interactionCount = 0
|
||||
|
||||
for (let offset = index + 1; offset < messages.length && offset <= index + 8; offset++) {
|
||||
const candidate = messages[offset]
|
||||
@@ -200,6 +238,9 @@ const buildImageContext = (
|
||||
responseCount += 1
|
||||
participants.add(sender)
|
||||
}
|
||||
if (candidate.contentData?.type === 'sticker' || candidate.contentData?.type === 'voice') {
|
||||
interactionCount += 1
|
||||
}
|
||||
if (
|
||||
snippets.length < 3 &&
|
||||
!content.startsWith('[图片]') &&
|
||||
@@ -226,6 +267,7 @@ const buildImageContext = (
|
||||
stats: statsParts.join(' · '),
|
||||
responseCount,
|
||||
participantCount: participants.size,
|
||||
interactionCount,
|
||||
snippets
|
||||
}
|
||||
}
|
||||
@@ -234,13 +276,17 @@ const buildMediaSection = async (
|
||||
messages: Message[],
|
||||
contact: Contact | null,
|
||||
isGroup: boolean,
|
||||
topSpeakersMap: Map<string, number>
|
||||
topSpeakersMap: Map<string, number>,
|
||||
options: BuildGroupReportFactsOptions = {}
|
||||
): Promise<{
|
||||
media: GroupDailyReport['media']
|
||||
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||
warnings: string[]
|
||||
imageInsightSummary: ReportImageInsightSummary
|
||||
}> => {
|
||||
const warnings: string[] = []
|
||||
const imageFailures: ReportImageInsightFailure[] = []
|
||||
let imageCandidateTotal = 0
|
||||
const rendererApi = typeof window === 'undefined' ? null : window.api
|
||||
const rawImageCandidates = messages
|
||||
.map((message, index) => {
|
||||
@@ -258,21 +304,31 @@ const buildMediaSection = async (
|
||||
stats: context.stats,
|
||||
replyCount: context.responseCount,
|
||||
participantCount: context.participantCount,
|
||||
score: context.responseCount * 3 + context.participantCount * 2 + 1
|
||||
interactionCount: context.interactionCount,
|
||||
score: calculateImageHeatScore({
|
||||
responseCount: context.responseCount,
|
||||
interactionCount: context.interactionCount
|
||||
}),
|
||||
isHot: isHotImageCandidate({
|
||||
responseCount: context.responseCount,
|
||||
interactionCount: context.participantCount
|
||||
})
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
.filter((item) => item.isHot)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 6)
|
||||
|
||||
// ============================================================
|
||||
// AI 图片理解(ImageInsightService 接入)
|
||||
// 通过 main 进程拿 Top 3 热点图 + 已缓存的 Insight;未缓存的并发调 AI
|
||||
// 失败不阻塞:任何错误只记日志,降级到原 gallery
|
||||
// 通过 main 进程拿最多 3 张真正的热点图 + 已缓存的 Insight;
|
||||
// 未缓存的并发调 AI。失败不阻塞文字日报。
|
||||
// ============================================================
|
||||
let visionGallery: ReportVisionGalleryItem[] = []
|
||||
try {
|
||||
if (!rendererApi) throw new Error('后台模式不读取 Renderer 图片')
|
||||
options.onProgress?.({ stage: 'selectingImages', label: '筛选热点图片' })
|
||||
const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '')
|
||||
const startTime = messages.length ? parseTimestamp(messages[0]) : 0
|
||||
const endTime = messages.length ? parseTimestamp(messages[messages.length - 1]) : 0
|
||||
@@ -288,7 +344,7 @@ const buildMediaSection = async (
|
||||
sender: c.sender,
|
||||
sentAt: parseTimestamp(srcMsg),
|
||||
responseCount: c.replyCount || 0,
|
||||
interactionCount: c.participantCount || 0
|
||||
interactionCount: c.interactionCount || 0
|
||||
}
|
||||
})
|
||||
|
||||
@@ -300,12 +356,45 @@ const buildMediaSection = async (
|
||||
inputs: imageInputs
|
||||
})
|
||||
const candidates = candidatesResp.success ? candidatesResp.candidates : []
|
||||
imageCandidateTotal = candidates.length
|
||||
console.log('[buildMediaSection] imageListCandidates returned', candidates.length, 'candidates')
|
||||
|
||||
if (!candidatesResp.success && rawImageCandidates.length) {
|
||||
imageCandidateTotal = Math.min(3, rawImageCandidates.length)
|
||||
for (const candidate of rawImageCandidates.slice(0, imageCandidateTotal)) {
|
||||
imageFailures.push({
|
||||
messageId: candidate.sourceMessageIds[0],
|
||||
sender: candidate.sender,
|
||||
time: candidate.time,
|
||||
error: candidatesResp.error || '热点图片筛选失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
options.onProgress?.({
|
||||
stage: 'recognizingImages',
|
||||
label: candidates.length ? '识别图片中' : '未找到可识别的热点图片',
|
||||
completed: 0,
|
||||
total: candidates.length
|
||||
})
|
||||
|
||||
// 对每个候选:缓存命中直接用,未命中并发调 imageAnalyze
|
||||
let analyzedCount = 0
|
||||
const analyzed = await Promise.all(
|
||||
candidates.map(async (candidate) => {
|
||||
if (candidate.insight) return candidate.insight
|
||||
const finish = (): void => {
|
||||
analyzedCount += 1
|
||||
options.onProgress?.({
|
||||
stage: 'recognizingImages',
|
||||
label: '识别图片中',
|
||||
completed: analyzedCount,
|
||||
total: candidates.length
|
||||
})
|
||||
}
|
||||
if (candidate.insight) {
|
||||
finish()
|
||||
return candidate.insight
|
||||
}
|
||||
// 未命中:解密图片拿 base64 → 调 AI
|
||||
try {
|
||||
const img = await rendererApi.getImage(
|
||||
@@ -318,6 +407,12 @@ const buildMediaSection = async (
|
||||
warnings.push(
|
||||
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片读取失败:${img.error || '未知错误'}`
|
||||
)
|
||||
imageFailures.push({
|
||||
messageId: candidate.messageId,
|
||||
sender: candidate.sender,
|
||||
time: localTime(candidate.sentAt),
|
||||
error: img.error || '图片读取失败'
|
||||
})
|
||||
return null
|
||||
}
|
||||
const analyzeResp = await rendererApi.imageAnalyze({
|
||||
@@ -333,6 +428,12 @@ const buildMediaSection = async (
|
||||
warnings.push(
|
||||
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别失败:${analyzeResp.error || '模型未返回识别结果'}`
|
||||
)
|
||||
imageFailures.push({
|
||||
messageId: candidate.messageId,
|
||||
sender: candidate.sender,
|
||||
time: localTime(candidate.sentAt),
|
||||
error: analyzeResp.error || '模型未返回识别结果'
|
||||
})
|
||||
return null
|
||||
}
|
||||
return analyzeResp.insight
|
||||
@@ -341,7 +442,15 @@ const buildMediaSection = async (
|
||||
warnings.push(
|
||||
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别异常:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
imageFailures.push({
|
||||
messageId: candidate.messageId,
|
||||
sender: candidate.sender,
|
||||
time: localTime(candidate.sentAt),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return null
|
||||
} finally {
|
||||
finish()
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -382,45 +491,26 @@ const buildMediaSection = async (
|
||||
} catch (error) {
|
||||
console.warn('[buildMediaSection] vision flow failed, fallback to empty:', error)
|
||||
warnings.push(`图片识别流程失败:${error instanceof Error ? error.message : String(error)}`)
|
||||
if (!imageFailures.length && rawImageCandidates.length) {
|
||||
imageCandidateTotal = Math.min(3, rawImageCandidates.length)
|
||||
for (const candidate of rawImageCandidates.slice(0, imageCandidateTotal)) {
|
||||
imageFailures.push({
|
||||
messageId: candidate.sourceMessageIds[0],
|
||||
sender: candidate.sender,
|
||||
time: candidate.time,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
visionGallery = []
|
||||
}
|
||||
|
||||
const imageCandidates = rendererApi
|
||||
? await Promise.all(
|
||||
rawImageCandidates.map(async (item) => {
|
||||
const result = await rendererApi.getImage(item.md5, item.datName, item.sessionId, {
|
||||
includeData: true
|
||||
})
|
||||
if (!result.success || !result.data?.startsWith('data:image/')) return null
|
||||
return {
|
||||
sender: item.sender,
|
||||
time: item.time,
|
||||
imageUrl: result.data,
|
||||
note: item.note,
|
||||
stats: item.stats,
|
||||
inferenceLabel: '基于图片后的聊天上下文推断',
|
||||
sourceMessageIds: item.sourceMessageIds,
|
||||
replyCount: item.replyCount,
|
||||
score: item.score
|
||||
}
|
||||
})
|
||||
)
|
||||
: []
|
||||
|
||||
const gallery: ReportMediaGalleryItem[] = imageCandidates
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.slice(0, 4)
|
||||
.map((item) => ({
|
||||
sender: item.sender,
|
||||
time: item.time,
|
||||
imageUrl: item.imageUrl,
|
||||
note: item.note,
|
||||
stats: item.stats,
|
||||
inferenceLabel: item.inferenceLabel,
|
||||
sourceMessageIds: item.sourceMessageIds,
|
||||
replyCount: item.replyCount
|
||||
}))
|
||||
options.onProgress?.({
|
||||
stage: 'summarizingInput',
|
||||
label: '汇总图片识别结果',
|
||||
completed: visionGallery.length,
|
||||
total: imageCandidateTotal
|
||||
})
|
||||
|
||||
const voiceMessages = messages
|
||||
.filter((message) => message.contentData?.type === 'voice')
|
||||
@@ -495,11 +585,11 @@ const buildMediaSection = async (
|
||||
note: `今天一共发了 ${topSpeaker[1]} 条消息。`
|
||||
})
|
||||
}
|
||||
if (gallery[0]) {
|
||||
if (visionGallery[0]) {
|
||||
funBadges.push({
|
||||
title: '图片话题王',
|
||||
owner: gallery[0].sender,
|
||||
note: `${gallery[0].time} 的图片带动了最明显的一轮讨论。`
|
||||
owner: visionGallery[0].sender,
|
||||
note: `${visionGallery[0].time} 的热点图片进入了 AI 图片精选。`
|
||||
})
|
||||
}
|
||||
if (voiceLeaderboard[0]) {
|
||||
@@ -512,13 +602,28 @@ const buildMediaSection = async (
|
||||
|
||||
return {
|
||||
media: {
|
||||
gallery,
|
||||
// 保留旧字段以兼容历史报告,但新日报不再生成或读取“群聊相册”。
|
||||
gallery: [],
|
||||
visionGallery,
|
||||
voiceHighlights: voiceHighlights.slice(0, 2),
|
||||
funBadges: funBadges.slice(0, 3)
|
||||
},
|
||||
voiceLeaderboard,
|
||||
warnings
|
||||
warnings,
|
||||
imageInsightSummary: {
|
||||
total: imageCandidateTotal,
|
||||
succeeded: visionGallery.length,
|
||||
failed: imageFailures.length,
|
||||
items: visionGallery.map((item) => ({
|
||||
messageId: item.messageId,
|
||||
sender: item.sender,
|
||||
time: item.time,
|
||||
description: item.description,
|
||||
ocrText: item.ocrText,
|
||||
tags: item.tags
|
||||
})),
|
||||
failures: imageFailures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,7 +666,8 @@ export const buildGroupReportFacts = async (
|
||||
messages: Message[],
|
||||
contact: Contact | null,
|
||||
isGroup: boolean,
|
||||
reportMode: ReportMode
|
||||
reportMode: ReportMode,
|
||||
options: BuildGroupReportFactsOptions = {}
|
||||
): Promise<GroupReportFactsSnapshot> => {
|
||||
const transcriptRows = messages.map((message) => ({
|
||||
id: message.id,
|
||||
@@ -665,11 +771,12 @@ export const buildGroupReportFacts = async (
|
||||
reportMode
|
||||
}
|
||||
|
||||
const { media, voiceLeaderboard, warnings } = await buildMediaSection(
|
||||
const { media, voiceLeaderboard, warnings, imageInsightSummary } = await buildMediaSection(
|
||||
messages,
|
||||
contact,
|
||||
isGroup,
|
||||
speakerCounts
|
||||
speakerCounts,
|
||||
options
|
||||
)
|
||||
if (warnings.length) metadata.warnings = [...(metadata.warnings || []), ...warnings]
|
||||
if (imageCount > 0 && !media.visionGallery?.length) {
|
||||
@@ -681,6 +788,7 @@ export const buildGroupReportFacts = async (
|
||||
if (
|
||||
transcriptRows.length > 0 &&
|
||||
transcriptRows.every((row) => row.content === '[图片]') &&
|
||||
imageInsightSummary.total > 0 &&
|
||||
!media.visionGallery?.length
|
||||
) {
|
||||
throw new Error(
|
||||
@@ -692,9 +800,6 @@ export const buildGroupReportFacts = async (
|
||||
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
|
||||
`消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`,
|
||||
activeTimeline ? `活跃时段:${activeTimeline}` : '',
|
||||
media.gallery.length
|
||||
? `图片观察:${media.gallery.map((item) => `${item.time} ${item.sender} 发图(${item.stats})`).join(';')}`
|
||||
: '',
|
||||
// AI 图片理解结果(由 ImageInsightService 提供,缓存命中或已调用 Vision)
|
||||
(media.visionGallery?.length ?? 0) > 0
|
||||
? `AI 图片识别摘要:${(media.visionGallery || [])
|
||||
@@ -727,6 +832,7 @@ export const buildGroupReportFacts = async (
|
||||
activeTimeline,
|
||||
media,
|
||||
voiceLeaderboard,
|
||||
factsPrompt
|
||||
factsPrompt,
|
||||
imageInsightSummary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { Contact, Message } from '../../../shared/types'
|
||||
import { jsonrepair } from 'jsonrepair'
|
||||
import { buildGroupReportFacts } from './group-report-facts'
|
||||
import type { BuildGroupReportFactsOptions, ReportImageInsightSummary } from './group-report-facts'
|
||||
|
||||
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
|
||||
|
||||
@@ -85,6 +86,7 @@ export interface GroupReportInput {
|
||||
activeTimeline: string
|
||||
voiceLeaderboard: ReportVoiceLeaderboardItem[]
|
||||
media: GroupDailyReport['media']
|
||||
imageInsightSummary: ReportImageInsightSummary
|
||||
}
|
||||
|
||||
const REPORT_MODE_LABEL: Record<ReportMode, string> = {
|
||||
@@ -103,7 +105,6 @@ interface ReportModeConfig {
|
||||
maxStorylines: number
|
||||
maxReversals: number
|
||||
maxChains: number
|
||||
maxGallery: number
|
||||
maxVoiceHighlights: number
|
||||
maxBadges: number
|
||||
maxResources: number
|
||||
@@ -125,7 +126,6 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
|
||||
maxStorylines: 0,
|
||||
maxReversals: 0,
|
||||
maxChains: 0,
|
||||
maxGallery: 0,
|
||||
maxVoiceHighlights: 0,
|
||||
maxBadges: 0,
|
||||
maxResources: 0,
|
||||
@@ -153,7 +153,6 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
|
||||
maxStorylines: 2,
|
||||
maxReversals: 2,
|
||||
maxChains: 3,
|
||||
maxGallery: 4,
|
||||
maxVoiceHighlights: 2,
|
||||
maxBadges: 3,
|
||||
maxResources: 4,
|
||||
@@ -173,7 +172,6 @@ const REPORT_MODE_CONFIG: Record<ReportMode, ReportModeConfig> = {
|
||||
'storylines',
|
||||
'reversals',
|
||||
'vision',
|
||||
'gallery',
|
||||
'voices',
|
||||
'badges',
|
||||
'chains'
|
||||
@@ -185,9 +183,10 @@ export const buildGroupReportInput = async (
|
||||
messages: Message[],
|
||||
contact: Contact | null,
|
||||
isGroup: boolean,
|
||||
reportMode: ReportMode
|
||||
reportMode: ReportMode,
|
||||
options: BuildGroupReportFactsOptions = {}
|
||||
): Promise<GroupReportInput> => {
|
||||
const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode)
|
||||
const facts = await buildGroupReportFacts(messages, contact, isGroup, reportMode, options)
|
||||
const desiredTopicCount =
|
||||
facts.metadata.messageCount >= 1000
|
||||
? '建议提炼 6-8 个互不重复的主要话题'
|
||||
@@ -222,7 +221,8 @@ ${transcript}`
|
||||
topSpeakers: facts.topSpeakers,
|
||||
activeTimeline: facts.activeTimeline,
|
||||
voiceLeaderboard: facts.voiceLeaderboard,
|
||||
media: facts.media
|
||||
media: facts.media,
|
||||
imageInsightSummary: facts.imageInsightSummary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,53 +531,6 @@ const topicLimitForMessageVolume = (
|
||||
return config
|
||||
}
|
||||
|
||||
const attachHighImpactImage = (
|
||||
topics: ReportTopic[],
|
||||
gallery: GroupDailyReport['media']['gallery'],
|
||||
visionGallery?: GroupDailyReport['media']['visionGallery']
|
||||
): ReportTopic[] => {
|
||||
if (!gallery.length && !visionGallery?.length) return topics
|
||||
// 优先用 visionGallery(AI 真实识别的 description)
|
||||
const firstVision = visionGallery?.find((it) => it.importance !== 'low')
|
||||
const [firstImage, ...rest] = gallery
|
||||
const nextTopics = topics.map((topic, index) => {
|
||||
if (index !== 0) return topic
|
||||
if (firstVision) {
|
||||
// AI 真实识别路径:note 直接用 description,不带"根据推断"前缀
|
||||
const noteParts = [firstVision.description]
|
||||
if (firstVision.ocrText) noteParts.push(`文字:${firstVision.ocrText}`)
|
||||
if (firstVision.tags.length) noteParts.push(`标签:${firstVision.tags.join('/')}`)
|
||||
return {
|
||||
...topic,
|
||||
image: {
|
||||
note: noteParts.join(' · '),
|
||||
sourceMessageIds: firstVision.sourceMessageIds
|
||||
// 注意:不填 imageUrl,因为 unknown imageHash 等问题可能导致 main 取不到原图,
|
||||
// 让 renderer 在 buildGroupReportFacts 阶段就把 dataUrl 预先加载好塞到 gallery 里,
|
||||
// 这里走 gallery 路径自然带 imageUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
if (firstImage?.replyCount && firstImage.replyCount >= 3) {
|
||||
return {
|
||||
...topic,
|
||||
image: {
|
||||
imageUrl: firstImage.imageUrl,
|
||||
note: `该图片引发 ${firstImage.replyCount} 条回复。${firstImage.note.startsWith('根据') ? firstImage.note : `根据图片前后对话推断,${firstImage.note}`}`,
|
||||
sourceMessageIds: firstImage.sourceMessageIds
|
||||
}
|
||||
}
|
||||
}
|
||||
return topic
|
||||
})
|
||||
if (firstVision) {
|
||||
// visionGallery 用过的不再展示
|
||||
return nextTopics
|
||||
}
|
||||
gallery.splice(0, rest.length >= 0 ? 1 : 0)
|
||||
return nextTopics
|
||||
}
|
||||
|
||||
const postProcessReport = (
|
||||
report: GroupDailyReport,
|
||||
mode: ReportMode,
|
||||
@@ -591,12 +544,7 @@ const postProcessReport = (
|
||||
(item) => item.sourceMessageIds || [],
|
||||
(item) => createSignature(item.title, item.summary)
|
||||
)
|
||||
const gallery = [...report.media.gallery]
|
||||
const topics = attachHighImpactImage(
|
||||
clampTopics(topicsDeduped, config),
|
||||
gallery,
|
||||
report.media.visionGallery
|
||||
)
|
||||
const topics = clampTopics(topicsDeduped, config)
|
||||
|
||||
const importantMessagesRaw = sortByScore(report.importantMessages, (item) =>
|
||||
Math.max(item.importance || 0, item.confidence || 0.6)
|
||||
@@ -784,13 +732,9 @@ const postProcessReport = (
|
||||
0.72,
|
||||
0.9
|
||||
),
|
||||
gallery: buildSectionMeta(
|
||||
config.enabledSections.includes('gallery'),
|
||||
gallery.length,
|
||||
report.media.gallery.length,
|
||||
0.6,
|
||||
0.8
|
||||
),
|
||||
// gallery remains in the type for historical reports, but is disabled for
|
||||
// every newly generated report because AI vision is the single image source.
|
||||
gallery: buildSectionMeta(false, 0, 0, 0, 0),
|
||||
voices: buildSectionMeta(
|
||||
config.enabledSections.includes('voices'),
|
||||
voiceHighlights.length,
|
||||
@@ -830,7 +774,7 @@ const postProcessReport = (
|
||||
participantChains,
|
||||
keywords,
|
||||
media: {
|
||||
gallery,
|
||||
gallery: [],
|
||||
visionGallery: report.media.visionGallery,
|
||||
voiceHighlights,
|
||||
funBadges
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
GroupReportRenderSnapshotExportRequest
|
||||
} from '../../../shared/group-report'
|
||||
import type {
|
||||
GeneratedReportRecord,
|
||||
PrepareGeneratedReportTemplateSwitchResult,
|
||||
UpdateGeneratedReportTemplateRequest,
|
||||
UpdateGeneratedReportTemplateResult
|
||||
} from '../../../shared/report-history'
|
||||
import type { SelectableReportTemplateId } from '../../../shared/report-templates'
|
||||
|
||||
interface ReportTemplateSwitchApi {
|
||||
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||
exportGroupReportSnapshot: (
|
||||
request: GroupReportRenderSnapshotExportRequest
|
||||
) => Promise<GroupReportExportResult>
|
||||
prepareGeneratedReportTemplateSwitch: (
|
||||
reportId: string
|
||||
) => Promise<PrepareGeneratedReportTemplateSwitchResult>
|
||||
updateGeneratedReportTemplate: (
|
||||
request: UpdateGeneratedReportTemplateRequest
|
||||
) => Promise<UpdateGeneratedReportTemplateResult>
|
||||
}
|
||||
|
||||
export async function switchGeneratedReportTemplate(
|
||||
report: GeneratedReportRecord,
|
||||
templateId: SelectableReportTemplateId,
|
||||
api: ReportTemplateSwitchApi
|
||||
): Promise<UpdateGeneratedReportTemplateResult> {
|
||||
let exported: GroupReportExportResult
|
||||
if (report.reportSnapshot && report.reportMetadata) {
|
||||
exported = await api.exportGroupReport({
|
||||
report: report.reportSnapshot,
|
||||
metadata: report.reportMetadata,
|
||||
templateId
|
||||
})
|
||||
} else {
|
||||
const prepared = await api.prepareGeneratedReportTemplateSwitch(report.id)
|
||||
if (!prepared.success || !prepared.snapshot) {
|
||||
return {
|
||||
success: false,
|
||||
error: prepared.error || '旧报告缺少可迁移的本地内容'
|
||||
}
|
||||
}
|
||||
exported = await api.exportGroupReportSnapshot({
|
||||
snapshot: prepared.snapshot,
|
||||
templateId
|
||||
})
|
||||
}
|
||||
if (!exported.success || !exported.imageDataUrl || !exported.htmlPath || !exported.pngPath) {
|
||||
return { success: false, error: exported.error || '新模板导出失败' }
|
||||
}
|
||||
|
||||
return api.updateGeneratedReportTemplate({
|
||||
reportId: report.id,
|
||||
templateId,
|
||||
generatedImage: exported.imageDataUrl,
|
||||
htmlPath: exported.htmlPath,
|
||||
pngPath: exported.pngPath
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export type ReportHeat = '高' | '中' | '低'
|
||||
export type ReportMode = 'compact' | 'full'
|
||||
|
||||
import type { ReportTemplateRequestId } from './report-templates'
|
||||
|
||||
export const selectHeroParticipantNames = (names: string[]): string[] =>
|
||||
Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))).slice(0, 4)
|
||||
|
||||
@@ -283,8 +285,8 @@ export interface GroupReportMetadata {
|
||||
export interface GroupReportExportRequest {
|
||||
report: GroupDailyReport
|
||||
metadata: GroupReportMetadata
|
||||
/** 模板 ID:'v1' 经典 / 'v2' 当前。缺省或未知值用默认(v2) */
|
||||
templateId?: 'v1' | 'v2'
|
||||
/** v1 是默认经典模板,v2 仅保留旧调用兼容;另有五套新版产品模板。 */
|
||||
templateId?: ReportTemplateRequestId
|
||||
}
|
||||
|
||||
export interface GroupReportExportResult {
|
||||
@@ -307,3 +309,18 @@ export interface GroupReportExportResult {
|
||||
warnings?: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版历史日报没有保存 GroupDailyReport 时,从已生成 HTML 提取的模板占位值。
|
||||
* values 中的卡片字段是本地日报已经转义/渲染好的 HTML,只用于本地模板重排版。
|
||||
*/
|
||||
export interface GroupReportRenderSnapshot {
|
||||
groupName: string
|
||||
reportDate: string
|
||||
values: Record<string, string>
|
||||
}
|
||||
|
||||
export interface GroupReportRenderSnapshotExportRequest {
|
||||
snapshot: GroupReportRenderSnapshot
|
||||
templateId: ReportTemplateRequestId
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export interface ImageCandidateQuery {
|
||||
sessionId: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
/** 取 Top N,默认 3 */
|
||||
/** 最多取 N 张,默认 3;服务端会将其限制在 0–3 */
|
||||
limit?: number
|
||||
/** 由 renderer 从已加载消息中提取的图片候选(包含热度信息) */
|
||||
inputs?: Array<{
|
||||
@@ -107,4 +107,21 @@ export interface ImageCandidateQuery {
|
||||
responseCount: number
|
||||
interactionCount: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A picture is considered hot only when it has observable follow-up activity.
|
||||
* A bare image message (or a single passive reply) is not enough to enter the
|
||||
* daily report's AI image selection. This keeps the "top 3" value an upper
|
||||
* bound rather than a quota that fills every available slot.
|
||||
*/
|
||||
export const isHotImageCandidate = (input: {
|
||||
responseCount: number
|
||||
interactionCount: number
|
||||
}): boolean =>
|
||||
input.responseCount >= 2 || (input.responseCount >= 1 && input.interactionCount >= 1)
|
||||
|
||||
export const calculateImageHeatScore = (input: {
|
||||
responseCount: number
|
||||
interactionCount: number
|
||||
}): number => input.responseCount * 3 + input.interactionCount * 2 + 1
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import type {
|
||||
GroupDailyReport,
|
||||
GroupReportMetadata,
|
||||
GroupReportRenderSnapshot
|
||||
} from './group-report'
|
||||
import type { SelectableReportTemplateId } from './report-templates'
|
||||
|
||||
export type ReportAssetStatus = 'ready' | 'missing'
|
||||
|
||||
export interface GeneratedReportRecord {
|
||||
@@ -37,6 +44,11 @@ export interface GeneratedReportRecord {
|
||||
endedAt: string
|
||||
duration: number
|
||||
}[]
|
||||
/** 新版报告保存结构化快照,模板切换时只重新渲染,不再调用 AI。 */
|
||||
reportSnapshot?: GroupDailyReport
|
||||
reportMetadata?: GroupReportMetadata
|
||||
reportRenderSnapshot?: GroupReportRenderSnapshot
|
||||
templateId?: SelectableReportTemplateId
|
||||
}
|
||||
|
||||
export interface SaveGeneratedReportRequest {
|
||||
@@ -63,6 +75,27 @@ export interface SaveGeneratedReportRequest {
|
||||
endedAt: string
|
||||
duration: number
|
||||
}[]
|
||||
reportSnapshot?: GroupDailyReport
|
||||
reportMetadata?: GroupReportMetadata
|
||||
templateId?: SelectableReportTemplateId
|
||||
}
|
||||
|
||||
export interface UpdateGeneratedReportTemplateRequest {
|
||||
reportId: string
|
||||
templateId: SelectableReportTemplateId
|
||||
generatedImage?: string
|
||||
htmlPath?: string
|
||||
pngPath?: string
|
||||
}
|
||||
|
||||
export interface PrepareGeneratedReportTemplateSwitchRequest {
|
||||
reportId: string
|
||||
}
|
||||
|
||||
export interface PrepareGeneratedReportTemplateSwitchResult {
|
||||
success: boolean
|
||||
snapshot?: GroupReportRenderSnapshot
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ReportHistoryResult {
|
||||
@@ -77,6 +110,8 @@ export interface SaveGeneratedReportResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type UpdateGeneratedReportTemplateResult = SaveGeneratedReportResult
|
||||
|
||||
export interface DeleteGeneratedReportResult {
|
||||
success: boolean
|
||||
deletedId?: string
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
export type ReportTemplateId =
|
||||
| 'mobile-feed'
|
||||
| 'mobile-magazine'
|
||||
| 'mobile-dashboard'
|
||||
| 'desktop-workspace'
|
||||
| 'desktop-editorial'
|
||||
|
||||
export type LegacyReportTemplateId = 'v1' | 'v2'
|
||||
export type SelectableReportTemplateId = 'v1' | ReportTemplateId
|
||||
export type ReportTemplateRequestId = ReportTemplateId | LegacyReportTemplateId
|
||||
export type ReportTemplatePlatform = 'default' | 'mobile' | 'desktop'
|
||||
|
||||
export interface ReportTemplateDefinition {
|
||||
id: SelectableReportTemplateId
|
||||
order: number
|
||||
platform: ReportTemplatePlatform
|
||||
label: string
|
||||
name: string
|
||||
tagline: string
|
||||
fileLabel: string
|
||||
cssClass: string
|
||||
resourceFile: string
|
||||
captureWidth: number
|
||||
maxCaptureWidth: number
|
||||
}
|
||||
|
||||
const SHARED_RESOURCE = 'daily_report_templates.html'
|
||||
|
||||
export const DEFAULT_REPORT_TEMPLATE: Readonly<ReportTemplateDefinition> = {
|
||||
id: 'v1',
|
||||
order: 0,
|
||||
platform: 'default',
|
||||
label: '默认模板',
|
||||
name: '经典日报',
|
||||
tagline: '熟悉的单栏日报版式,信息完整,适合直接生成与分享',
|
||||
fileLabel: '经典版',
|
||||
cssClass: 'template-classic',
|
||||
resourceFile: 'mobile_daily_report_v1.html',
|
||||
captureWidth: 430,
|
||||
maxCaptureWidth: 1200
|
||||
}
|
||||
|
||||
export const REPORT_TEMPLATES: readonly ReportTemplateDefinition[] = [
|
||||
{
|
||||
id: 'mobile-feed',
|
||||
order: 1,
|
||||
platform: 'mobile',
|
||||
label: 'Mobile 01',
|
||||
name: '微信信息流',
|
||||
tagline: '聊天头像、真实消息与 AI 重点整理并列呈现',
|
||||
fileLabel: '手机微信信息流',
|
||||
cssClass: 'template-mobile-feed',
|
||||
resourceFile: SHARED_RESOURCE,
|
||||
captureWidth: 430,
|
||||
maxCaptureWidth: 430
|
||||
},
|
||||
{
|
||||
id: 'mobile-magazine',
|
||||
order: 2,
|
||||
platform: 'mobile',
|
||||
label: 'Mobile 02',
|
||||
name: 'AI Magazine',
|
||||
tagline: '大标题、大数字与编辑式留白的科技日报',
|
||||
fileLabel: '手机AI杂志',
|
||||
cssClass: 'template-mobile-magazine',
|
||||
resourceFile: SHARED_RESOURCE,
|
||||
captureWidth: 430,
|
||||
maxCaptureWidth: 430
|
||||
},
|
||||
{
|
||||
id: 'mobile-dashboard',
|
||||
order: 3,
|
||||
platform: 'mobile',
|
||||
label: 'Mobile 03',
|
||||
name: 'AI Command Center',
|
||||
tagline: 'KPI、热度、排行与问答构成的群聊数据驾驶舱',
|
||||
fileLabel: '手机数据驾驶舱',
|
||||
cssClass: 'template-mobile-dashboard',
|
||||
resourceFile: SHARED_RESOURCE,
|
||||
captureWidth: 430,
|
||||
maxCaptureWidth: 430
|
||||
},
|
||||
{
|
||||
id: 'desktop-workspace',
|
||||
order: 4,
|
||||
platform: 'desktop',
|
||||
label: 'Desktop 01',
|
||||
name: '三栏 AI 工作台',
|
||||
tagline: '概览、核心讨论、成员与问答同时可见',
|
||||
fileLabel: '桌面三栏工作台',
|
||||
cssClass: 'template-desktop-workspace',
|
||||
resourceFile: SHARED_RESOURCE,
|
||||
captureWidth: 1440,
|
||||
maxCaptureWidth: 1920
|
||||
},
|
||||
{
|
||||
id: 'desktop-editorial',
|
||||
order: 5,
|
||||
platform: 'desktop',
|
||||
label: 'Desktop 02',
|
||||
name: 'Editorial 科技日报',
|
||||
tagline: '头条、专栏、引语与新闻式分隔构成的编辑版面',
|
||||
fileLabel: '桌面Editorial',
|
||||
cssClass: 'template-desktop-editorial',
|
||||
resourceFile: SHARED_RESOURCE,
|
||||
captureWidth: 1440,
|
||||
maxCaptureWidth: 1920
|
||||
}
|
||||
]
|
||||
|
||||
export const SELECTABLE_REPORT_TEMPLATES: readonly ReportTemplateDefinition[] = [
|
||||
DEFAULT_REPORT_TEMPLATE,
|
||||
...REPORT_TEMPLATES
|
||||
]
|
||||
|
||||
export const isReportTemplateId = (value: unknown): value is ReportTemplateId =>
|
||||
REPORT_TEMPLATES.some((template) => template.id === value)
|
||||
|
||||
export const isSelectableReportTemplateId = (
|
||||
value: unknown
|
||||
): value is SelectableReportTemplateId =>
|
||||
SELECTABLE_REPORT_TEMPLATES.some((template) => template.id === value)
|
||||
|
||||
export const getReportTemplate = (value?: string): ReportTemplateDefinition =>
|
||||
SELECTABLE_REPORT_TEMPLATES.find((template) => template.id === value) || DEFAULT_REPORT_TEMPLATE
|
||||
@@ -1,8 +1,28 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector'
|
||||
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
|
||||
import { ReportTemplateSelector } from '../../src/renderer/src/components/reports/ReportTemplateSelector'
|
||||
import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||
|
||||
const noImageInsights = {
|
||||
total: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
items: [],
|
||||
failures: []
|
||||
}
|
||||
|
||||
const currentModel = {
|
||||
providerId: 'provider-1',
|
||||
providerName: '默认服务',
|
||||
model: 'model-1',
|
||||
modelName: '默认模型',
|
||||
configured: true,
|
||||
status: 'connected' as const
|
||||
}
|
||||
|
||||
const groupContact: Contact = {
|
||||
md5: 'group-md5',
|
||||
@@ -18,6 +38,7 @@ describe('daily report controls', () => {
|
||||
value: {
|
||||
getAppLogPath: vi.fn(async () => ''),
|
||||
revealAppLog: vi.fn(async () => undefined),
|
||||
listAIProviders: vi.fn(async () => ({ success: true, providers: [] })),
|
||||
getGroupSnapshot: vi.fn(async () => ({
|
||||
members: [
|
||||
{
|
||||
@@ -50,7 +71,13 @@ describe('daily report controls', () => {
|
||||
error=""
|
||||
voiceTranscriptionProgress={progress}
|
||||
voiceTranscriptionEnabled
|
||||
preparationProgress={null}
|
||||
imageInsightSummary={noImageInsights}
|
||||
canRetryModelStep={false}
|
||||
currentModel={currentModel}
|
||||
onRetry={vi.fn()}
|
||||
onContinueAfterImageFailures={vi.fn()}
|
||||
onCancelAfterImageFailures={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -63,13 +90,270 @@ describe('daily report controls', () => {
|
||||
error=""
|
||||
voiceTranscriptionProgress={null}
|
||||
voiceTranscriptionEnabled={false}
|
||||
preparationProgress={null}
|
||||
imageInsightSummary={noImageInsights}
|
||||
canRetryModelStep={false}
|
||||
currentModel={currentModel}
|
||||
onRetry={vi.fn()}
|
||||
onContinueAfterImageFailures={vi.fn()}
|
||||
onCancelAfterImageFailures={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('转写语音消息')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('2/4')).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows image insight results and pauses for confirmation when some images fail', () => {
|
||||
const onContinue = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<ReportTaskStatusPanel
|
||||
phase="awaitingImageDecision"
|
||||
error=""
|
||||
voiceTranscriptionProgress={null}
|
||||
voiceTranscriptionEnabled={false}
|
||||
preparationProgress={{
|
||||
stage: 'summarizingInput',
|
||||
label: '等待确认是否继续文字总结',
|
||||
completed: 2,
|
||||
total: 3
|
||||
}}
|
||||
imageInsightSummary={{
|
||||
total: 3,
|
||||
succeeded: 2,
|
||||
failed: 1,
|
||||
items: [
|
||||
{
|
||||
messageId: 'image-1',
|
||||
sender: '成员一',
|
||||
time: '10:20',
|
||||
description: '一张表格型网页截图。',
|
||||
ocrText: '列 A 列 B',
|
||||
tags: ['表格', '网页']
|
||||
}
|
||||
],
|
||||
failures: [
|
||||
{
|
||||
messageId: 'image-2',
|
||||
sender: '成员二',
|
||||
time: '10:21',
|
||||
error: 'fetch failed'
|
||||
}
|
||||
]
|
||||
}}
|
||||
canRetryModelStep={false}
|
||||
currentModel={currentModel}
|
||||
onRetry={vi.fn()}
|
||||
onContinueAfterImageFailures={onContinue}
|
||||
onCancelAfterImageFailures={onCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('等待确认')).toBeVisible()
|
||||
expect(screen.getByText('有 1 张图片识别失败')).toBeVisible()
|
||||
expect(screen.getByText('一张表格型网页截图。')).toBeVisible()
|
||||
expect(screen.getByText('OCR:列 A 列 B')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续文字总结' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止生成' }))
|
||||
expect(onContinue).toHaveBeenCalledTimes(1)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('allows switching models and retrying only the model step', async () => {
|
||||
const onRetry = vi.fn()
|
||||
window.api.listAIProviders = vi.fn(async () => ({
|
||||
success: true,
|
||||
providers: [
|
||||
{
|
||||
id: 'provider-2',
|
||||
name: '备用服务',
|
||||
type: 'openai-compatible',
|
||||
baseUrl: 'https://example.test',
|
||||
auth: { type: 'bearer' },
|
||||
models: [
|
||||
{
|
||||
id: 'model-2',
|
||||
name: '备用模型',
|
||||
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
|
||||
}
|
||||
],
|
||||
defaultModel: 'model-2',
|
||||
advanced: { timeoutMs: 120000, extraHeaders: {} },
|
||||
hasApiKey: true,
|
||||
isDefault: false,
|
||||
status: 'connected'
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
render(
|
||||
<ReportTaskStatusPanel
|
||||
phase="error"
|
||||
error="fetch failed"
|
||||
voiceTranscriptionProgress={null}
|
||||
voiceTranscriptionEnabled={false}
|
||||
preparationProgress={null}
|
||||
imageInsightSummary={noImageInsights}
|
||||
canRetryModelStep
|
||||
currentModel={currentModel}
|
||||
onRetry={onRetry}
|
||||
onContinueAfterImageFailures={vi.fn()}
|
||||
onCancelAfterImageFailures={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const retryButton = await screen.findByRole('button', { name: '使用所选模型重新生成' })
|
||||
expect(screen.getByRole('option', { name: '备用服务 · 备用模型' })).toBeVisible()
|
||||
fireEvent.click(retryButton)
|
||||
expect(onRetry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providerId: 'provider-2', model: 'model-2' })
|
||||
)
|
||||
expect(screen.getByText(/从第三步继续/)).toBeVisible()
|
||||
})
|
||||
|
||||
it('zooms relative to a full-image fit constrained by viewport width and height', () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver
|
||||
globalThis.ResizeObserver = class {
|
||||
observe(): void {
|
||||
return undefined
|
||||
}
|
||||
disconnect(): void {
|
||||
return undefined
|
||||
}
|
||||
unobserve(): void {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'report-1',
|
||||
contactId: 'group-md5',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-12T10:00:00.000Z',
|
||||
reportDate: '2026-08-12',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
generatedImage: 'data:image/png;base64,fixture'
|
||||
}
|
||||
render(
|
||||
<ReportViewer
|
||||
report={report}
|
||||
hasReports
|
||||
onBackToConfigure={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||
onReveal={vi.fn(async () => ({ success: true }))}
|
||||
onSwitchTemplate={vi.fn(async () => ({ success: true }))}
|
||||
/>
|
||||
)
|
||||
const image = screen.getByAltText('测试群 群聊日报') as HTMLImageElement
|
||||
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 1440 })
|
||||
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: 4000 })
|
||||
Object.defineProperty(image.parentElement?.parentElement, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 760
|
||||
})
|
||||
Object.defineProperty(image.parentElement?.parentElement, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 600
|
||||
})
|
||||
fireEvent.load(image)
|
||||
expect(image.style.width).toBe('200px')
|
||||
fireEvent.click(screen.getByRole('button', { name: '缩小' }))
|
||||
expect(image.style.width).toBe('160px')
|
||||
fireEvent.click(screen.getByRole('button', { name: '放大' }))
|
||||
expect(image.style.width).toBe('200px')
|
||||
expect(screen.getByRole('button', { name: '完整显示' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '原始大小' }))
|
||||
expect(image.style.width).toBe('1440px')
|
||||
fireEvent.click(screen.getByRole('button', { name: '完整显示' }))
|
||||
expect(image.style.width).toBe('200px')
|
||||
globalThis.ResizeObserver = originalResizeObserver
|
||||
})
|
||||
|
||||
it('switches templates from the top toolbar using the saved report snapshot', async () => {
|
||||
const onSwitchTemplate = vi.fn(async () => ({ success: true }))
|
||||
const report: GeneratedReportRecord = {
|
||||
id: 'report-switch',
|
||||
contactId: 'group-md5',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-12T10:00:00.000Z',
|
||||
reportDate: '2026-08-12',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
generatedImage: 'data:image/png;base64,fixture',
|
||||
templateId: 'mobile-feed',
|
||||
reportSnapshot: {} as GeneratedReportRecord['reportSnapshot'],
|
||||
reportMetadata: {} as GeneratedReportRecord['reportMetadata']
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<ReportViewer
|
||||
report={report}
|
||||
hasReports
|
||||
onBackToConfigure={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||
onReveal={vi.fn(async () => ({ success: true }))}
|
||||
onSwitchTemplate={onSwitchTemplate}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换模板' }))
|
||||
expect(screen.getByText('仅重新排版,不调用 AI')).toBeVisible()
|
||||
expect(screen.getByRole('menuitem', { name: /默认模板经典日报/ })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Mobile 03AI Command Center/ }))
|
||||
await waitFor(() => expect(onSwitchTemplate).toHaveBeenCalledWith(report, 'mobile-dashboard'))
|
||||
|
||||
rerender(
|
||||
<ReportViewer
|
||||
report={{
|
||||
...report,
|
||||
reportSnapshot: undefined,
|
||||
reportMetadata: undefined,
|
||||
htmlPath: '/tmp/legacy-report.html'
|
||||
}}
|
||||
hasReports
|
||||
onBackToConfigure={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||
onReveal={vi.fn(async () => ({ success: true }))}
|
||||
onSwitchTemplate={onSwitchTemplate}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '切换模板' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '切换模板' })).toHaveAttribute(
|
||||
'title',
|
||||
'使用已生成的数据或本地 HTML 更换展示模板,不会重新调用 AI'
|
||||
)
|
||||
|
||||
rerender(
|
||||
<ReportViewer
|
||||
report={{
|
||||
...report,
|
||||
reportSnapshot: undefined,
|
||||
reportMetadata: undefined,
|
||||
reportRenderSnapshot: undefined,
|
||||
htmlPath: undefined
|
||||
}}
|
||||
hasReports
|
||||
onBackToConfigure={vi.fn()}
|
||||
onRegenerate={vi.fn()}
|
||||
onCopyImage={vi.fn(async () => ({ success: true }))}
|
||||
onReveal={vi.fn(async () => ({ success: true }))}
|
||||
onSwitchTemplate={onSwitchTemplate}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '切换模板' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '切换模板' })).toHaveAttribute(
|
||||
'title',
|
||||
'当前报告缺少可复用数据和 HTML,无法切换模板'
|
||||
)
|
||||
})
|
||||
|
||||
it('loads and displays group nickname, WeChat nickname, and remark separately', async () => {
|
||||
render(<ReportGroupMemberSelector sourceContact={groupContact} />)
|
||||
|
||||
@@ -78,4 +362,28 @@ describe('daily report controls', () => {
|
||||
expect(screen.getByText('通讯录备注一')).toBeVisible()
|
||||
expect(screen.getByText('wxid-one')).toBeVisible()
|
||||
})
|
||||
|
||||
it('offers the classic default plus three mobile and two desktop report templates', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ReportTemplateSelector value="v1" onChange={onChange} />)
|
||||
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(6)
|
||||
expect(screen.getByText('默认模板', { selector: '.report-template-group-title' })).toBeVisible()
|
||||
expect(screen.getByText('手机端 · 375–414 px')).toBeVisible()
|
||||
expect(screen.getByText('电脑端 · 1280–1920 px')).toBeVisible()
|
||||
expect(screen.getByText('经典日报')).toBeVisible()
|
||||
expect(screen.getByRole('radio', { name: /经典日报/ })).toBeChecked()
|
||||
expect(screen.getByText('微信信息流')).toBeVisible()
|
||||
expect(screen.getByText('AI Magazine')).toBeVisible()
|
||||
expect(screen.getByText('AI Command Center')).toBeVisible()
|
||||
expect(screen.getByText('三栏 AI 工作台')).toBeVisible()
|
||||
expect(screen.getByText('Editorial 科技日报')).toBeVisible()
|
||||
|
||||
const previewButtons = screen.getAllByRole('button', { name: '查看版式' })
|
||||
expect(previewButtons).toHaveLength(6)
|
||||
fireEvent.click(previewButtons[2])
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择此模板' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('mobile-magazine')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -479,11 +479,30 @@ handle('report:export', () => {
|
||||
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
|
||||
return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
|
||||
})
|
||||
handle('report:exportSnapshot', () => {
|
||||
const htmlPath = path.join(userData, 'fixture-report-snapshot.html')
|
||||
const pngPath = path.join(userData, 'fixture-report-snapshot.png')
|
||||
fs.writeFileSync(htmlPath, '<!doctype html><h1>固定脱敏模板快照日报</h1>', 'utf8')
|
||||
fs.writeFileSync(pngPath, Buffer.from(imageData.split(',')[1], 'base64'))
|
||||
return { success: true, imageDataUrl: imageData, htmlPath, pngPath }
|
||||
})
|
||||
handle('report:prepareTemplateSwitch', () => ({
|
||||
success: true,
|
||||
snapshot: {
|
||||
groupName: '固定脱敏群',
|
||||
reportDate: '2026-08-12',
|
||||
values: { REPORT_TITLE: '固定脱敏群日报' }
|
||||
}
|
||||
}))
|
||||
handle('report:listGenerated', () => ({ success: true, reports: [] }))
|
||||
handle('report:saveGenerated', (request) => ({
|
||||
success: true,
|
||||
record: { id: 'fixture-report-record', ...request }
|
||||
}))
|
||||
handle('report:updateGeneratedTemplate', (request) => ({
|
||||
success: true,
|
||||
record: { id: request.reportId, templateId: request.templateId }
|
||||
}))
|
||||
handle('report:deleteGenerated', () => ({ success: true }))
|
||||
handle('report:reveal', () => ({ success: true }))
|
||||
handle('copy-image', () => ({ success: true }))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildGroupReportInput,
|
||||
parseGroupDailyReport
|
||||
@@ -29,6 +29,16 @@ const media = {
|
||||
funBadges: []
|
||||
}
|
||||
|
||||
const previousWindow = (globalThis as { window?: unknown }).window
|
||||
|
||||
afterEach(() => {
|
||||
if (previousWindow === undefined) {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
} else {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow })
|
||||
}
|
||||
})
|
||||
|
||||
describe('group report parsing', () => {
|
||||
it('keeps only distinct real participants in the hero avatar list', () => {
|
||||
expect(
|
||||
@@ -113,6 +123,109 @@ describe('group report parsing', () => {
|
||||
expect(input.prompt).toContain('微信系统消息:由于账号安全原因,无法加入当前群聊。')
|
||||
})
|
||||
|
||||
it('injects successful image insights into the model prompt and reports partial failures', async () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: 'image-1',
|
||||
from: 'member',
|
||||
type: '图片',
|
||||
datetime: '2026-08-12 10:00:00',
|
||||
content: '[图片]',
|
||||
name: '成员一',
|
||||
isSender: false,
|
||||
sessionId: 'group@chatroom',
|
||||
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'one.dat' }
|
||||
},
|
||||
{
|
||||
id: 'image-2',
|
||||
from: 'member',
|
||||
type: '图片',
|
||||
datetime: '2026-08-12 10:01:00',
|
||||
content: '[图片]',
|
||||
name: '成员二',
|
||||
isSender: false,
|
||||
sessionId: 'group@chatroom',
|
||||
contentData: { type: 'image', md5: 'b'.repeat(32), datName: 'two.dat' }
|
||||
}
|
||||
]
|
||||
const progress = vi.fn()
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
api: {
|
||||
imageListCandidates: vi.fn(async () => ({
|
||||
success: true,
|
||||
candidates: [
|
||||
{
|
||||
messageId: 'image-1',
|
||||
imageHash: 'a'.repeat(32),
|
||||
md5: 'a'.repeat(32),
|
||||
datName: 'one.dat',
|
||||
sessionId: 'group@chatroom',
|
||||
sender: '成员一',
|
||||
sentAt: new Date('2026-08-12 10:00:00').getTime(),
|
||||
heatScore: 10
|
||||
},
|
||||
{
|
||||
messageId: 'image-2',
|
||||
imageHash: 'b'.repeat(32),
|
||||
md5: 'b'.repeat(32),
|
||||
datName: 'two.dat',
|
||||
sessionId: 'group@chatroom',
|
||||
sender: '成员二',
|
||||
sentAt: new Date('2026-08-12 10:01:00').getTime(),
|
||||
heatScore: 9
|
||||
}
|
||||
]
|
||||
})),
|
||||
getImage: vi.fn(async () => ({
|
||||
success: true,
|
||||
data: 'data:image/png;base64,fixture'
|
||||
})),
|
||||
imageAnalyze: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
insight: {
|
||||
id: 'insight-1',
|
||||
messageId: 'image-1',
|
||||
imageHash: 'a'.repeat(32),
|
||||
description: '一张表格型网页截图,包含多列数据。',
|
||||
ocrText: '项目 状态 负责人',
|
||||
tags: ['表格', '管理界面'],
|
||||
category: 'screenshot',
|
||||
importance: 'medium',
|
||||
provider: 'vision-provider',
|
||||
model: 'vision-model',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
sender: '成员一',
|
||||
sentAt: new Date('2026-08-12 10:00:00').getTime(),
|
||||
sessionId: 'group@chatroom'
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({ success: false, error: 'fetch failed' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const input = await buildGroupReportInput(messages, null, true, 'full', {
|
||||
onProgress: progress
|
||||
})
|
||||
|
||||
expect(input.prompt).toContain('AI 图片识别摘要:')
|
||||
expect(input.prompt).toContain('一张表格型网页截图,包含多列数据。')
|
||||
expect(input.prompt).toContain('OCR: 项目 状态 负责人')
|
||||
expect(input.imageInsightSummary).toMatchObject({ total: 2, succeeded: 1, failed: 1 })
|
||||
expect(input.imageInsightSummary.failures[0]).toMatchObject({
|
||||
messageId: 'image-2',
|
||||
error: 'fetch failed'
|
||||
})
|
||||
expect(progress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stage: 'recognizingImages', completed: 2, total: 2 })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to topic keywords when the model omits top-level keywords', () => {
|
||||
const report = parseGroupDailyReport(
|
||||
JSON.stringify({
|
||||
@@ -133,4 +246,66 @@ describe('group report parsing', () => {
|
||||
|
||||
expect(report.keywords).toEqual(['肌酸', '训练', '健身安排'])
|
||||
})
|
||||
|
||||
it('does not render the legacy gallery even when old report data contains it', () => {
|
||||
const report = parseGroupDailyReport(
|
||||
JSON.stringify({
|
||||
topics: [{ title: '图片话题', summary: '围绕图片展开讨论。', keywords: ['图片'] }]
|
||||
}),
|
||||
[],
|
||||
'',
|
||||
[],
|
||||
metadata,
|
||||
{
|
||||
gallery: [
|
||||
{
|
||||
sender: '成员一',
|
||||
time: '10:00',
|
||||
imageUrl: 'data:image/png;base64,fixture',
|
||||
note: '旧相册数据'
|
||||
}
|
||||
],
|
||||
voiceHighlights: [],
|
||||
funBadges: []
|
||||
}
|
||||
)
|
||||
|
||||
expect(report.media.gallery).toEqual([])
|
||||
expect(report.sectionMeta?.gallery).toMatchObject({ enabled: false, displayedCount: 0 })
|
||||
})
|
||||
|
||||
it('allows a text report with zero AI images when no image reaches the hot threshold', async () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
api: {
|
||||
imageListCandidates: vi.fn(async () => ({ success: true, candidates: [] })),
|
||||
getImage: vi.fn(),
|
||||
imageAnalyze: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const input = await buildGroupReportInput(
|
||||
[
|
||||
{
|
||||
id: 'cold-image',
|
||||
from: 'member',
|
||||
type: '图片',
|
||||
datetime: '2026-08-12 10:00:00',
|
||||
content: '[图片]',
|
||||
name: '成员一',
|
||||
isSender: false,
|
||||
sessionId: 'group@chatroom',
|
||||
contentData: { type: 'image', md5: 'c'.repeat(32), datName: 'cold.dat' }
|
||||
}
|
||||
],
|
||||
null,
|
||||
true,
|
||||
'full'
|
||||
)
|
||||
|
||||
expect(input.imageInsightSummary).toMatchObject({ total: 0, succeeded: 0, failed: 0 })
|
||||
expect(input.media.gallery).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getByHash } = vi.hoisted(() => ({ getByHash: vi.fn() }))
|
||||
|
||||
vi.mock('../../src/main/db/image-insights-store', () => ({
|
||||
imageInsightsStore: {
|
||||
getByHash,
|
||||
upsert: vi.fn(),
|
||||
listBySession: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
import { imageInsightService } from '../../src/main/services/image-insight-service'
|
||||
|
||||
const query = {
|
||||
sessionId: 'group@chatroom',
|
||||
startTime: 0,
|
||||
endTime: Date.now(),
|
||||
limit: 3
|
||||
}
|
||||
|
||||
const input = (id: string, responseCount: number, interactionCount: number): {
|
||||
messageId: string
|
||||
md5: string
|
||||
sessionId: string
|
||||
sender: string
|
||||
sentAt: number
|
||||
responseCount: number
|
||||
interactionCount: number
|
||||
} => ({
|
||||
messageId: id,
|
||||
md5: id.repeat(32).slice(0, 32),
|
||||
sessionId: 'group@chatroom',
|
||||
sender: id,
|
||||
sentAt: Date.now(),
|
||||
responseCount,
|
||||
interactionCount
|
||||
})
|
||||
|
||||
describe('ImageInsightService hot image selection', () => {
|
||||
beforeEach(() => {
|
||||
getByHash.mockReset()
|
||||
getByHash.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('returns fewer than three images when only two pass the hot threshold', async () => {
|
||||
const result = await imageInsightService.listTopHotImages(query, [
|
||||
input('a', 3, 0),
|
||||
input('b', 1, 1),
|
||||
input('c', 1, 0),
|
||||
input('d', 0, 5),
|
||||
input('e', 0, 0)
|
||||
])
|
||||
|
||||
expect(result.map((item) => item.messageId)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps the three highest-scoring hot images when more are eligible', async () => {
|
||||
const result = await imageInsightService.listTopHotImages(query, [
|
||||
input('a', 2, 0),
|
||||
input('b', 5, 0),
|
||||
input('c', 1, 1),
|
||||
input('d', 3, 1)
|
||||
])
|
||||
|
||||
expect(result.map((item) => item.messageId)).toEqual(['b', 'd', 'a'])
|
||||
expect(result).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('returns no candidates when no image has meaningful follow-up activity', async () => {
|
||||
const result = await imageInsightService.listTopHotImages(query, [
|
||||
input('a', 1, 0),
|
||||
input('b', 0, 4),
|
||||
input('c', 0, 0)
|
||||
])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a cached insight attached to an eligible candidate', async () => {
|
||||
const cached = { imageHash: 'a'.repeat(32), description: '缓存识别结果' }
|
||||
getByHash.mockImplementation((hash: string) => (hash === 'a'.repeat(32) ? cached : null))
|
||||
|
||||
const result = await imageInsightService.listTopHotImages(query, [input('a', 2, 0)])
|
||||
|
||||
expect(result[0]).toMatchObject({ messageId: 'a', insight: cached })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { GroupDailyReport, GroupReportMetadata } from '../../src/shared/group-report'
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'tracememo-report-history-'))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => root },
|
||||
nativeImage: {
|
||||
createFromPath: () => ({
|
||||
isEmpty: () => false,
|
||||
getSize: () => ({ width: 430, height: 1200 })
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
const reportSnapshot = {
|
||||
overview: '已生成的结构化日报',
|
||||
topics: [],
|
||||
resources: [],
|
||||
importantMessages: [],
|
||||
quotes: [],
|
||||
qa: [],
|
||||
todos: [],
|
||||
unresolved: [],
|
||||
storylines: [],
|
||||
reversals: [],
|
||||
participantChains: [],
|
||||
analytics: {
|
||||
topicHeat: [],
|
||||
activeTimeline: '',
|
||||
topSpeakers: [],
|
||||
voiceLeaderboard: []
|
||||
},
|
||||
keywords: [],
|
||||
media: { gallery: [], visionGallery: [], voiceHighlights: [], funBadges: [] }
|
||||
} satisfies GroupDailyReport
|
||||
|
||||
const reportMetadata = {
|
||||
groupName: '测试群',
|
||||
reportDate: '2026-08-12',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
activeUsers: 3,
|
||||
timeSpan: '09:00–18:00',
|
||||
generatedAt: '2026-08-12T10:00:00.000Z',
|
||||
recordNote: '',
|
||||
footerNote: '',
|
||||
heroParticipants: [],
|
||||
avatars: {}
|
||||
} satisfies GroupReportMetadata
|
||||
|
||||
describe('generated report template history', () => {
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('replaces the current report assets while preserving its structured snapshot and id', async () => {
|
||||
const originalHtml = join(root, 'original.html')
|
||||
const switchedHtml = join(root, 'switched.html')
|
||||
writeFileSync(originalHtml, '<h1>Mobile 01</h1>')
|
||||
writeFileSync(switchedHtml, '<h1>Mobile 03</h1>')
|
||||
const { saveGeneratedReport, updateGeneratedReportTemplate } =
|
||||
await import('../../src/main/report-history-service')
|
||||
|
||||
const saved = await saveGeneratedReport({
|
||||
contactId: 'group-md5',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-12T10:00:00.000Z',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('mobile-01').toString('base64')}`,
|
||||
htmlPath: originalHtml,
|
||||
reportSnapshot,
|
||||
reportMetadata,
|
||||
templateId: 'mobile-feed'
|
||||
})
|
||||
expect(saved.success).toBe(true)
|
||||
expect(saved.record).toBeDefined()
|
||||
|
||||
const updated = await updateGeneratedReportTemplate({
|
||||
reportId: saved.record!.id,
|
||||
templateId: 'mobile-dashboard',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('mobile-03').toString('base64')}`,
|
||||
htmlPath: switchedHtml
|
||||
})
|
||||
|
||||
expect(updated.success).toBe(true)
|
||||
expect(updated.record).toMatchObject({
|
||||
id: saved.record!.id,
|
||||
templateId: 'mobile-dashboard',
|
||||
reportSnapshot,
|
||||
reportMetadata
|
||||
})
|
||||
expect(readFileSync(updated.record!.htmlPath!, 'utf8')).toContain('Mobile 03')
|
||||
expect(readFileSync(updated.record!.pngPath!).toString()).toBe('mobile-03')
|
||||
expect(JSON.parse(readFileSync(updated.record!.jsonPath!, 'utf8'))).toMatchObject({
|
||||
id: saved.record!.id,
|
||||
templateId: 'mobile-dashboard',
|
||||
reportSnapshot,
|
||||
reportMetadata
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps legacy records viewable but rejects a lossless template switch', async () => {
|
||||
const legacyHtml = join(root, 'legacy.html')
|
||||
writeFileSync(legacyHtml, '<h1>Legacy</h1>')
|
||||
const { saveGeneratedReport, updateGeneratedReportTemplate } =
|
||||
await import('../../src/main/report-history-service')
|
||||
const saved = await saveGeneratedReport({
|
||||
contactId: 'legacy-group',
|
||||
contactName: '旧报告',
|
||||
dateRange: '今天',
|
||||
messageCount: 5,
|
||||
generatedAt: '2026-08-12T11:00:00.000Z',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('legacy').toString('base64')}`,
|
||||
htmlPath: legacyHtml
|
||||
})
|
||||
|
||||
const updated = await updateGeneratedReportTemplate({
|
||||
reportId: saved.record!.id,
|
||||
templateId: 'desktop-editorial',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('new').toString('base64')}`,
|
||||
htmlPath: legacyHtml
|
||||
})
|
||||
|
||||
expect(updated).toEqual({
|
||||
success: false,
|
||||
error: '旧报告未保存结构化数据,无法无损切换模板'
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts and persists a render snapshot once for legacy template switching', async () => {
|
||||
const legacyHtml = join(root, 'legacy-with-html.html')
|
||||
writeFileSync(legacyHtml, '<h1>Legacy source</h1>')
|
||||
const { saveGeneratedReport, prepareGeneratedReportTemplateSwitch } =
|
||||
await import('../../src/main/report-history-service')
|
||||
const saved = await saveGeneratedReport({
|
||||
contactId: 'legacy-snapshot-group',
|
||||
contactName: '旧报告快照',
|
||||
dateRange: '今天',
|
||||
messageCount: 8,
|
||||
generatedAt: '2026-08-12T12:00:00.000Z',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('legacy-snapshot').toString('base64')}`,
|
||||
htmlPath: legacyHtml
|
||||
})
|
||||
const extractSnapshot = vi.fn(async () => ({
|
||||
groupName: '旧报告快照',
|
||||
reportDate: '2026-08-12',
|
||||
values: { REPORT_TITLE: '旧报告快照日报', TOPIC_CARDS: '<div>已有主题</div>' }
|
||||
}))
|
||||
|
||||
const first = await prepareGeneratedReportTemplateSwitch(saved.record!.id, extractSnapshot)
|
||||
const second = await prepareGeneratedReportTemplateSwitch(saved.record!.id, extractSnapshot)
|
||||
|
||||
expect(first).toEqual(second)
|
||||
expect(extractSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(readFileSync(saved.record!.jsonPath!, 'utf8'))).toMatchObject({
|
||||
id: saved.record!.id,
|
||||
reportRenderSnapshot: first.snapshot
|
||||
})
|
||||
})
|
||||
|
||||
it('persists the classic v1 template as a selectable history template', async () => {
|
||||
const classicHtml = join(root, 'classic.html')
|
||||
writeFileSync(classicHtml, '<h1>经典日报</h1>')
|
||||
const { saveGeneratedReport } = await import('../../src/main/report-history-service')
|
||||
const saved = await saveGeneratedReport({
|
||||
contactId: 'classic-group',
|
||||
contactName: '经典日报群',
|
||||
dateRange: '今天',
|
||||
messageCount: 6,
|
||||
generatedAt: '2026-08-12T13:00:00.000Z',
|
||||
generatedImage: `data:image/png;base64,${Buffer.from('classic').toString('base64')}`,
|
||||
htmlPath: classicHtml,
|
||||
reportSnapshot,
|
||||
reportMetadata,
|
||||
templateId: 'v1'
|
||||
})
|
||||
|
||||
expect(saved.success).toBe(true)
|
||||
expect(saved.record?.templateId).toBe('v1')
|
||||
expect(JSON.parse(readFileSync(saved.record!.jsonPath!, 'utf8')).templateId).toBe('v1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||
import { switchGeneratedReportTemplate } from '../../src/renderer/src/utils/report-template-switch'
|
||||
|
||||
const structuredReport = {
|
||||
id: 'report-1',
|
||||
contactId: 'group-1',
|
||||
contactName: '测试群',
|
||||
dateRange: '今天',
|
||||
messageCount: 10,
|
||||
generatedAt: '2026-08-12T10:00:00.000Z',
|
||||
reportDate: '2026-08-12',
|
||||
htmlStatus: 'ready',
|
||||
pngStatus: 'ready',
|
||||
templateId: 'mobile-feed',
|
||||
reportSnapshot: { overview: '已有内容' },
|
||||
reportMetadata: { groupName: '测试群' }
|
||||
} as GeneratedReportRecord
|
||||
|
||||
describe('report template switching pipeline', () => {
|
||||
it('only exports the saved snapshot and updates the same history record', async () => {
|
||||
const api = {
|
||||
exportGroupReport: vi.fn(async () => ({
|
||||
success: true,
|
||||
imageDataUrl: 'data:image/png;base64,new',
|
||||
htmlPath: '/tmp/new.html',
|
||||
pngPath: '/tmp/new.png'
|
||||
})),
|
||||
exportGroupReportSnapshot: vi.fn(),
|
||||
prepareGeneratedReportTemplateSwitch: vi.fn(),
|
||||
updateGeneratedReportTemplate: vi.fn(async () => ({
|
||||
success: true,
|
||||
record: { ...structuredReport, templateId: 'mobile-dashboard' as const }
|
||||
}))
|
||||
}
|
||||
|
||||
const result = await switchGeneratedReportTemplate(structuredReport, 'mobile-dashboard', api)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(api.exportGroupReport).toHaveBeenCalledTimes(1)
|
||||
expect(api.exportGroupReport).toHaveBeenCalledWith({
|
||||
report: structuredReport.reportSnapshot,
|
||||
metadata: structuredReport.reportMetadata,
|
||||
templateId: 'mobile-dashboard'
|
||||
})
|
||||
expect(api.updateGeneratedReportTemplate).toHaveBeenCalledWith({
|
||||
reportId: structuredReport.id,
|
||||
templateId: 'mobile-dashboard',
|
||||
generatedImage: 'data:image/png;base64,new',
|
||||
htmlPath: '/tmp/new.html',
|
||||
pngPath: '/tmp/new.png'
|
||||
})
|
||||
})
|
||||
|
||||
it('switches an existing report back to the classic default without rerunning AI', async () => {
|
||||
const api = {
|
||||
exportGroupReport: vi.fn(async () => ({
|
||||
success: true,
|
||||
imageDataUrl: 'data:image/png;base64,classic',
|
||||
htmlPath: '/tmp/classic.html',
|
||||
pngPath: '/tmp/classic.png'
|
||||
})),
|
||||
exportGroupReportSnapshot: vi.fn(),
|
||||
prepareGeneratedReportTemplateSwitch: vi.fn(),
|
||||
updateGeneratedReportTemplate: vi.fn(async () => ({
|
||||
success: true,
|
||||
record: { ...structuredReport, templateId: 'v1' as const }
|
||||
}))
|
||||
}
|
||||
|
||||
const result = await switchGeneratedReportTemplate(structuredReport, 'v1', api)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(api.exportGroupReport).toHaveBeenCalledWith({
|
||||
report: structuredReport.reportSnapshot,
|
||||
metadata: structuredReport.reportMetadata,
|
||||
templateId: 'v1'
|
||||
})
|
||||
expect(api.prepareGeneratedReportTemplateSwitch).not.toHaveBeenCalled()
|
||||
expect(api.updateGeneratedReportTemplate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reportId: structuredReport.id, templateId: 'v1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('migrates a legacy record from its saved HTML before switching', async () => {
|
||||
const api = {
|
||||
exportGroupReport: vi.fn(),
|
||||
exportGroupReportSnapshot: vi.fn(async () => ({
|
||||
success: true,
|
||||
imageDataUrl: 'data:image/png;base64,legacy-new',
|
||||
htmlPath: '/tmp/legacy-new.html',
|
||||
pngPath: '/tmp/legacy-new.png'
|
||||
})),
|
||||
prepareGeneratedReportTemplateSwitch: vi.fn(async () => ({
|
||||
success: true,
|
||||
snapshot: {
|
||||
groupName: '测试群',
|
||||
reportDate: '2026-08-12',
|
||||
values: { REPORT_TITLE: '测试群日报' }
|
||||
}
|
||||
})),
|
||||
updateGeneratedReportTemplate: vi.fn(async () => ({ success: true }))
|
||||
}
|
||||
const result = await switchGeneratedReportTemplate(
|
||||
{ ...structuredReport, reportSnapshot: undefined, reportMetadata: undefined },
|
||||
'desktop-editorial',
|
||||
api
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(api.exportGroupReport).not.toHaveBeenCalled()
|
||||
expect(api.prepareGeneratedReportTemplateSwitch).toHaveBeenCalledWith('report-1')
|
||||
expect(api.exportGroupReportSnapshot).toHaveBeenCalledWith({
|
||||
snapshot: expect.objectContaining({ groupName: '测试群' }),
|
||||
templateId: 'desktop-editorial'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call any pipeline step when a legacy record cannot be migrated', async () => {
|
||||
const api = {
|
||||
exportGroupReport: vi.fn(),
|
||||
exportGroupReportSnapshot: vi.fn(),
|
||||
prepareGeneratedReportTemplateSwitch: vi.fn(async () => ({
|
||||
success: false,
|
||||
error: '当前日报缺少 HTML 文件,无法迁移旧模板数据'
|
||||
})),
|
||||
updateGeneratedReportTemplate: vi.fn()
|
||||
}
|
||||
const result = await switchGeneratedReportTemplate(
|
||||
{ ...structuredReport, reportSnapshot: undefined, reportMetadata: undefined },
|
||||
'desktop-editorial',
|
||||
api
|
||||
)
|
||||
|
||||
expect(result).toEqual({ success: false, error: '当前日报缺少 HTML 文件,无法迁移旧模板数据' })
|
||||
expect(api.exportGroupReport).not.toHaveBeenCalled()
|
||||
expect(api.updateGeneratedReportTemplate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_REPORT_TEMPLATE,
|
||||
getReportTemplate,
|
||||
isReportTemplateId,
|
||||
isSelectableReportTemplateId,
|
||||
REPORT_TEMPLATES,
|
||||
SELECTABLE_REPORT_TEMPLATES
|
||||
} from '../../src/shared/report-templates'
|
||||
|
||||
describe('daily report templates', () => {
|
||||
it('exposes the classic default plus exactly three mobile and two desktop product templates', () => {
|
||||
expect(REPORT_TEMPLATES).toHaveLength(5)
|
||||
expect(SELECTABLE_REPORT_TEMPLATES).toHaveLength(6)
|
||||
expect(SELECTABLE_REPORT_TEMPLATES[0]).toEqual(DEFAULT_REPORT_TEMPLATE)
|
||||
expect(DEFAULT_REPORT_TEMPLATE).toMatchObject({
|
||||
id: 'v1',
|
||||
label: '默认模板',
|
||||
name: '经典日报',
|
||||
resourceFile: 'mobile_daily_report_v1.html'
|
||||
})
|
||||
expect(REPORT_TEMPLATES.filter((template) => template.platform === 'mobile')).toHaveLength(3)
|
||||
expect(REPORT_TEMPLATES.filter((template) => template.platform === 'desktop')).toHaveLength(2)
|
||||
expect(new Set(REPORT_TEMPLATES.map((template) => template.cssClass)).size).toBe(5)
|
||||
})
|
||||
|
||||
it('uses mobile and desktop capture widths appropriate to their layouts', () => {
|
||||
expect(DEFAULT_REPORT_TEMPLATE.captureWidth).toBe(430)
|
||||
expect(DEFAULT_REPORT_TEMPLATE.maxCaptureWidth).toBeGreaterThanOrEqual(
|
||||
DEFAULT_REPORT_TEMPLATE.captureWidth
|
||||
)
|
||||
for (const template of REPORT_TEMPLATES) {
|
||||
expect(
|
||||
template.platform === 'mobile' ? template.captureWidth : template.captureWidth >= 1280
|
||||
).toBeTruthy()
|
||||
expect(template.maxCaptureWidth).toBeGreaterThanOrEqual(template.captureWidth)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves v1 and unknown ids to the classic default template', () => {
|
||||
expect(isReportTemplateId('desktop-editorial')).toBe(true)
|
||||
expect(isReportTemplateId('v1')).toBe(false)
|
||||
expect(isSelectableReportTemplateId('v1')).toBe(true)
|
||||
expect(isSelectableReportTemplateId('v2')).toBe(false)
|
||||
expect(getReportTemplate('unknown').id).toBe('v1')
|
||||
expect(getReportTemplate('v1')).toEqual(DEFAULT_REPORT_TEMPLATE)
|
||||
})
|
||||
|
||||
it('ships the classic resource and shared semantic resource with five distinct layout classes', () => {
|
||||
expect(existsSync(resolve('resources', DEFAULT_REPORT_TEMPLATE.resourceFile))).toBe(true)
|
||||
const resourcePath = resolve('resources', 'daily_report_templates.html')
|
||||
expect(existsSync(resourcePath)).toBe(true)
|
||||
const html = readFileSync(resourcePath, 'utf8')
|
||||
for (const template of REPORT_TEMPLATES) {
|
||||
expect(html).toContain(`.${template.cssClass}`)
|
||||
}
|
||||
expect(html).toContain('{{TOPIC_CARDS}}')
|
||||
expect(html).toContain('{{IMPORTANT_MESSAGES}}')
|
||||
expect(html).toContain('{{QA_CARDS}}')
|
||||
expect(html).toContain('{{RANK_ITEMS}}')
|
||||
expect(html).toContain('{{HERO_AVATARS}}')
|
||||
expect(html).not.toContain('群聊相册')
|
||||
expect(html).not.toContain('今日群相册')
|
||||
expect(html).toContain('grid-template-columns: minmax(78px, 104px) minmax(72px, 1fr) 34px')
|
||||
expect(html).toContain('.template-mobile-dashboard .heat-name {')
|
||||
expect(html).toContain('white-space: normal')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user