feat: 增加日报模板

This commit is contained in:
Wxw-Gu
2026-08-12 16:10:22 +08:00
parent cf3f115124
commit d439b4b749
36 changed files with 5068 additions and 559 deletions
+309 -1
View File
@@ -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('手机端 · 375414 px')).toBeVisible()
expect(screen.getByText('电脑端 · 12801920 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')
})
})
+19
View File
@@ -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 }))
+176 -1
View File
@@ -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([])
})
})
+88
View File
@@ -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 })
})
})
+187
View File
@@ -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:0018: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')
})
})
+139
View File
@@ -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()
})
})
+70
View File
@@ -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')
})
})