错误摘要
{error}
-
+ {canRetryModelStep ? (
+
+
+
+ 重新生成将直接复用已整理的聊天记录和图片识别结果,从第三步继续。
+ {modelLoadError ? {modelLoadError} : null}
+
+
+ ) : (
+
+ )}
diff --git a/src/renderer/src/components/reports/ReportTemplateSelector.tsx b/src/renderer/src/components/reports/ReportTemplateSelector.tsx
index aafc1b1..1bd4dc7 100644
--- a/src/renderer/src/components/reports/ReportTemplateSelector.tsx
+++ b/src/renderer/src/components/reports/ReportTemplateSelector.tsx
@@ -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 => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
export const ReportTemplateSelector: React.FC
= ({
value,
onChange,
disabled
}) => {
- const [previewing, setPreviewing] = useState(null)
- return (
-
- 日报模板
-
- 选择日报呈现风格。勾选图片后会尝试生成图片精选;识别失败不会影响文字日报。
-
+ const [previewing, setPreviewing] = useState(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 => (
+
+
{title}
- {TEMPLATES.map((tpl) => {
- const active = value === tpl.id
+ {templates.map((template) => {
+ const active = value === template.id
return (
)
})}
+
+ )
+
+ return (
+
+ 日报模板
+
+ 默认模板与五套新版模板读取同一份真实日报数据。手机模板适合长图和群内分享,桌面模板适合宽屏阅读与归档。
+
+
+ {renderGroup('默认模板', [DEFAULT_REPORT_TEMPLATE])}
+ {renderGroup('手机端 · 375–414 px', mobileTemplates)}
+ {renderGroup('电脑端 · 1280–1920 px', desktopTemplates)}
+
{previewing && (
setPreviewing(null)}>
-
e.stopPropagation()}>
-
{previewing.preview.title}
-
{previewing.tagline}
-
-
-
群聊日报 · 预览
-
2026-xx-xx · 基于已加载记录
+
event.stopPropagation()}
+ >
+
+
+ {previewing.label}
+
{previewing.name}
- {previewing.preview.sections.map((s) => (
-
- ))}
+
+ {previewing.platform === 'desktop'
+ ? '桌面宽屏'
+ : previewing.platform === 'default'
+ ? '经典长图'
+ : '手机长图'}
+
+
+
{previewing.tagline}
+
+
+ 生成时会自动代入当前群聊的真实头像、昵称、消息、讨论摘要、Q&A、统计与关键词。
+
+
+
+
-
)}
diff --git a/src/renderer/src/components/reports/ReportToolbar.tsx b/src/renderer/src/components/reports/ReportToolbar.tsx
index a7a57aa..a3c8f13 100644
--- a/src/renderer/src/components/reports/ReportToolbar.tsx
+++ b/src/renderer/src/components/reports/ReportToolbar.tsx
@@ -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
(null)
+ const templateMenuRef = useRef(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 (
+
+
+ {templateOpen && canSwitchTemplate && (
+
+
仅重新排版,不调用 AI
+ {SELECTABLE_REPORT_TEMPLATES.map((template) => (
+
+ ))}
+
+ )}
+
diff --git a/src/renderer/src/components/reports/ReportViewer.tsx b/src/renderer/src/components/reports/ReportViewer.tsx
index ab4c4b4..f3fc62e 100644
--- a/src/renderer/src/components/reports/ReportViewer.tsx
+++ b/src/renderer/src/components/reports/ReportViewer.tsx
@@ -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
(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 => {
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 => {
+ 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 (
@@ -88,6 +165,14 @@ export function ReportViewer({
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({
/>
)}
-
+
)
}
diff --git a/src/renderer/src/components/reports/ReportZoomBar.tsx b/src/renderer/src/components/reports/ReportZoomBar.tsx
index ddbe6a1..989fa92 100644
--- a/src/renderer/src/components/reports/ReportZoomBar.tsx
+++ b/src/renderer/src/components/reports/ReportZoomBar.tsx
@@ -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 (
-
)
diff --git a/src/renderer/src/components/reports/types.ts b/src/renderer/src/components/reports/types.ts
index 897e5ea..895285b 100644
--- a/src/renderer/src/components/reports/types.ts
+++ b/src/renderer/src/components/reports/types.ts
@@ -4,7 +4,9 @@ export type {
ReportAssetStatus,
ReportHistoryResult,
SaveGeneratedReportRequest,
- SaveGeneratedReportResult
+ SaveGeneratedReportResult,
+ UpdateGeneratedReportTemplateRequest,
+ UpdateGeneratedReportTemplateResult
} from '../../../../shared/report-history'
export type ReportWorkspaceView = 'configure' | 'result'
diff --git a/src/renderer/src/hooks/useGroupReportGeneration.ts b/src/renderer/src/hooks/useGroupReportGeneration.ts
index dfef6d3..ffec2ff 100644
--- a/src/renderer/src/hooks/useGroupReportGeneration.ts
+++ b/src/renderer/src/hooks/useGroupReportGeneration.ts
@@ -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>
+ startedAt: number
+ logs: ReportGenerationLog[]
+}
+
export interface ReportTaskStep {
id: Exclude
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 (
promise: Promise,
label: string,
@@ -242,19 +269,27 @@ export function useGroupReportGeneration({
messageTypeCounts: Record
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
- retry: () => Promise
+ retry: (modelOverride?: AiModelConfig) => Promise
+ continueAfterImageFailures: () => Promise
+ 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({ status: 'idle', error: '' })
const [voiceTranscriptionProgress, setVoiceTranscriptionProgress] =
useState(null)
+ const [preparationProgress, setPreparationProgress] = useState(
+ null
+ )
+ const [imageInsightSummary, setImageInsightSummary] = useState(
+ EMPTY_IMAGE_INSIGHT_SUMMARY
+ )
+ const [failedAt, setFailedAt] = useState('')
const [generatedImage, setGeneratedImage] = useState(null)
const [reportPaths, setReportPaths] = useState(null)
- const [templateId, setTemplateId] = useState('v1')
+ const [reportSnapshot, setReportSnapshot] = useState(null)
+ const [reportMetadata, setReportMetadata] = useState(null)
+ const [templateId, setTemplateIdState] = useState('v1')
const [memberNamePreference, setMemberNamePreferenceState] = useState(
() => {
const saved = localStorage.getItem('group_report_member_name_preference')
@@ -283,11 +327,15 @@ export function useGroupReportGeneration({
generationLogs: []
})
const rangeRequestIdRef = useRef(0)
+ const preparedContextRef = useRef(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 => {
+ 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 (label: string, task: () => Promise): Promise => {
+ 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
+ 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 => {
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 (label: string, task: () => Promise): Promise => {
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
- 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 => {
+ const context = preparedContextRef.current
+ if (failedAt === '调用模型生成内容' && context) {
+ await runPreparedReport(context, modelOverride || modelConfig)
+ return
+ }
+ await generate()
+ },
+ [failedAt, generate, modelConfig, runPreparedReport]
+ )
+
+ const continueAfterImageFailures = useCallback(async (): Promise => {
+ 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,
diff --git a/src/renderer/src/styles/reports-core.scss b/src/renderer/src/styles/reports-core.scss
index da2babe..63dae52 100644
--- a/src/renderer/src/styles/reports-core.scss
+++ b/src/renderer/src/styles/reports-core.scss
@@ -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;
diff --git a/src/renderer/src/styles/reports-templates.scss b/src/renderer/src/styles/reports-templates.scss
index 1aca12a..a10fa08 100644
--- a/src/renderer/src/styles/reports-templates.scss
+++ b/src/renderer/src/styles/reports-templates.scss
@@ -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);
}
-
diff --git a/src/renderer/src/utils/group-report-facts.ts b/src/renderer/src/utils/group-report-facts.ts
index 6a069af..e196e05 100644
--- a/src/renderer/src/utils/group-report-facts.ts
+++ b/src/renderer/src/utils/group-report-facts.ts
@@ -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()
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
+ topSpeakersMap: Map,
+ 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 => 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 => 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 => {
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
}
}
diff --git a/src/renderer/src/utils/group-report.ts b/src/renderer/src/utils/group-report.ts
index 2835c3f..6489ff9 100644
--- a/src/renderer/src/utils/group-report.ts
+++ b/src/renderer/src/utils/group-report.ts
@@ -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 = {
@@ -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 = {
maxStorylines: 0,
maxReversals: 0,
maxChains: 0,
- maxGallery: 0,
maxVoiceHighlights: 0,
maxBadges: 0,
maxResources: 0,
@@ -153,7 +153,6 @@ const REPORT_MODE_CONFIG: Record = {
maxStorylines: 2,
maxReversals: 2,
maxChains: 3,
- maxGallery: 4,
maxVoiceHighlights: 2,
maxBadges: 3,
maxResources: 4,
@@ -173,7 +172,6 @@ const REPORT_MODE_CONFIG: Record = {
'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 => {
- 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
diff --git a/src/renderer/src/utils/report-template-switch.ts b/src/renderer/src/utils/report-template-switch.ts
new file mode 100644
index 0000000..662ea74
--- /dev/null
+++ b/src/renderer/src/utils/report-template-switch.ts
@@ -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
+ exportGroupReportSnapshot: (
+ request: GroupReportRenderSnapshotExportRequest
+ ) => Promise
+ prepareGeneratedReportTemplateSwitch: (
+ reportId: string
+ ) => Promise
+ updateGeneratedReportTemplate: (
+ request: UpdateGeneratedReportTemplateRequest
+ ) => Promise
+}
+
+export async function switchGeneratedReportTemplate(
+ report: GeneratedReportRecord,
+ templateId: SelectableReportTemplateId,
+ api: ReportTemplateSwitchApi
+): Promise {
+ 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
+ })
+}
diff --git a/src/shared/group-report.ts b/src/shared/group-report.ts
index e80f33d..c503db0 100644
--- a/src/shared/group-report.ts
+++ b/src/shared/group-report.ts
@@ -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
+}
+
+export interface GroupReportRenderSnapshotExportRequest {
+ snapshot: GroupReportRenderSnapshot
+ templateId: ReportTemplateRequestId
+}
diff --git a/src/shared/image-insight.ts b/src/shared/image-insight.ts
index 7ef8462..f8ef426 100644
--- a/src/shared/image-insight.ts
+++ b/src/shared/image-insight.ts
@@ -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
}>
-}
\ No newline at end of file
+}
+
+/**
+ * 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
diff --git a/src/shared/report-history.ts b/src/shared/report-history.ts
index 7e743a9..e8d03a8 100644
--- a/src/shared/report-history.ts
+++ b/src/shared/report-history.ts
@@ -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
diff --git a/src/shared/report-templates.ts b/src/shared/report-templates.ts
new file mode 100644
index 0000000..fa380e2
--- /dev/null
+++ b/src/shared/report-templates.ts
@@ -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 = {
+ 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
diff --git a/tests/component/report-features.test.tsx b/tests/component/report-features.test.tsx
index 35708a3..2c83a7b 100644
--- a/tests/component/report-features.test.tsx
+++ b/tests/component/report-features.test.tsx
@@ -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(
+
+ )
+
+ 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(
+
+ )
+
+ 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(
+ ({ 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(
+ ({ 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(
+ ({ 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(
+ ({ 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()
@@ -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()
+
+ 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')
+ })
})
diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs
index af713c2..14986a9 100644
--- a/tests/e2e/support/electron-main.cjs
+++ b/tests/e2e/support/electron-main.cjs
@@ -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, '固定脱敏模板快照日报
', '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 }))
diff --git a/tests/unit/group-report.test.ts b/tests/unit/group-report.test.ts
index 67d83e7..6022b85 100644
--- a/tests/unit/group-report.test.ts
+++ b/tests/unit/group-report.test.ts
@@ -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([])
+ })
})
diff --git a/tests/unit/image-insight-service.test.ts b/tests/unit/image-insight-service.test.ts
new file mode 100644
index 0000000..f8f2661
--- /dev/null
+++ b/tests/unit/image-insight-service.test.ts
@@ -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 })
+ })
+})
diff --git a/tests/unit/report-history-service.test.ts b/tests/unit/report-history-service.test.ts
new file mode 100644
index 0000000..8ca94f6
--- /dev/null
+++ b/tests/unit/report-history-service.test.ts
@@ -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, 'Mobile 01
')
+ writeFileSync(switchedHtml, 'Mobile 03
')
+ 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, 'Legacy
')
+ 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, 'Legacy source
')
+ 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: '已有主题
' }
+ }))
+
+ 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, '经典日报
')
+ 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')
+ })
+})
diff --git a/tests/unit/report-template-switch.test.ts b/tests/unit/report-template-switch.test.ts
new file mode 100644
index 0000000..42ba686
--- /dev/null
+++ b/tests/unit/report-template-switch.test.ts
@@ -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()
+ })
+})
diff --git a/tests/unit/report-templates.test.ts b/tests/unit/report-templates.test.ts
new file mode 100644
index 0000000..71eb909
--- /dev/null
+++ b/tests/unit/report-templates.test.ts
@@ -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')
+ })
+})