双人年度报告支持导出 [Enhancement]: 双人年度报告不支持导出 但总年度报告支持

Fixes #531
This commit is contained in:
xuncha
2026-04-03 21:07:44 +08:00
parent 62e23aaf23
commit 81b8960d41
4 changed files with 525 additions and 56 deletions

View File

@@ -8,44 +8,9 @@ import {
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import { drawPatternBackground } from '../utils/reportExport'
import './AnnualReportWindow.scss'
// SVG 背景图案 (用于导出)
const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
// 绘制 SVG 图案背景到 canvas
const drawPatternBackground = async (ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: string, isDark: boolean) => {
// 先填充背景色
ctx.fillStyle = bgColor
ctx.fillRect(0, 0, width, height)
// 加载 SVG 图案
const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG
const blob = new Blob([svgString], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
return new Promise<void>((resolve) => {
const img = new window.Image()
img.onload = () => {
// 平铺绘制图案
const pattern = ctx.createPattern(img, 'repeat')
if (pattern) {
ctx.fillStyle = pattern
ctx.fillRect(0, 0, width, height)
}
URL.revokeObjectURL(url)
resolve()
}
img.onerror = () => {
URL.revokeObjectURL(url)
resolve()
}
img.src = url
})
}
interface TopContact {
username: string
displayName: string

View File

@@ -238,7 +238,7 @@
}
.scene-message.sent .scene-avatar {
border-color: color-mix(in srgb, var(--primary) 30%, var(--bg-tertiary, rgba(0, 0, 0, 0.08)));
border-color: rgba(var(--ar-primary-rgb), 0.3);
}
.dual-stat-grid {
@@ -981,4 +981,4 @@
transform: translateY(0);
}
}
}
}

View File

@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Check, Download, Image, SlidersHorizontal, X } from 'lucide-react'
import html2canvas from 'html2canvas'
import ReportHeatmap from '../components/ReportHeatmap'
import ReportWordCloud from '../components/ReportWordCloud'
import { useThemeStore } from '../stores/themeStore'
import { drawPatternBackground } from '../utils/reportExport'
import './AnnualReportWindow.scss'
import './DualReportWindow.scss'
@@ -66,6 +70,12 @@ interface DualReportData {
streak?: { days: number; startDate: string; endDate: string }
}
interface SectionInfo {
id: string
name: string
ref: React.RefObject<HTMLElement | null>
}
function DualReportWindow() {
const [reportData, setReportData] = useState<DualReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -75,6 +85,29 @@ function DualReportWindow() {
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared')
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState('')
const [showExportModal, setShowExportModal] = useState(false)
const [selectedSections, setSelectedSections] = useState<Set<string>>(new Set())
const [fabOpen, setFabOpen] = useState(false)
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
const { themeMode } = useThemeStore()
const sectionRefs = {
cover: useRef<HTMLElement>(null),
firstChat: useRef<HTMLElement>(null),
yearFirstChat: useRef<HTMLElement>(null),
heatmap: useRef<HTMLElement>(null),
initiative: useRef<HTMLElement>(null),
response: useRef<HTMLElement>(null),
streak: useRef<HTMLElement>(null),
wordCloud: useRef<HTMLElement>(null),
stats: useRef<HTMLElement>(null),
ending: useRef<HTMLElement>(null)
}
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
@@ -151,6 +184,351 @@ function DualReportWindow() {
void loadEmojis()
}, [reportData])
const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year))
const sanitizeFileNameSegment = (value: string) => {
const sanitized = value.replace(/[\\/:*?"<>|]/g, '_').trim()
return sanitized || '好友'
}
const getAvailableSections = (): SectionInfo[] => {
if (!reportData) return []
const sections: SectionInfo[] = [
{ id: 'cover', name: '封面', ref: sectionRefs.cover },
{ id: 'firstChat', name: '首次聊天', ref: sectionRefs.firstChat }
]
if (reportData.yearFirstChat && (!reportData.firstChat || reportData.yearFirstChat.createTime !== reportData.firstChat.createTime)) {
sections.push({ id: 'yearFirstChat', name: '第一段对话', ref: sectionRefs.yearFirstChat })
}
if (reportData.heatmap) {
sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap })
}
if (reportData.initiative) {
sections.push({ id: 'initiative', name: '主动性', ref: sectionRefs.initiative })
}
if (reportData.response) {
sections.push({ id: 'response', name: '回应速度', ref: sectionRefs.response })
}
if (reportData.streak) {
sections.push({ id: 'streak', name: '最长连续聊天', ref: sectionRefs.streak })
}
sections.push({ id: 'wordCloud', name: '常用语', ref: sectionRefs.wordCloud })
sections.push({ id: 'stats', name: '年度统计', ref: sectionRefs.stats })
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
return sections
}
const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => {
const element = section.ref.current
if (!element) {
return null
}
const OUTPUT_WIDTH = 1920
const OUTPUT_HEIGHT = 1080
let wordCloudInner: HTMLElement | null = null
let wordTags: NodeListOf<HTMLElement> | null = null
let wordCloudOriginalStyle = ''
const wordTagOriginalStyles: string[] = []
const originalStyle = element.style.cssText
try {
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
const activeEl = document.activeElement as HTMLElement | null
activeEl?.blur?.()
document.body.classList.add('exporting-snapshot')
document.documentElement.classList.add('exporting-snapshot')
element.style.minHeight = 'auto'
element.style.padding = '40px 20px'
element.style.background = 'transparent'
element.style.backgroundColor = 'transparent'
element.style.boxShadow = 'none'
wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement | null
wordTags = element.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
if (wordCloudInner) {
wordCloudOriginalStyle = wordCloudInner.style.cssText
wordCloudInner.style.transform = 'none'
}
wordTags.forEach((tag, index) => {
wordTagOriginalStyles[index] = tag.style.cssText
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
tag.style.animation = 'none'
})
await new Promise((resolve) => setTimeout(resolve, 50))
const computedStyle = getComputedStyle(document.documentElement)
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
const canvas = await html2canvas(element, {
backgroundColor: 'transparent',
scale: 2,
useCORS: true,
allowTaint: true,
logging: false,
onclone: (clonedDoc) => {
clonedDoc.body.classList.add('exporting-snapshot')
clonedDoc.documentElement.classList.add('exporting-snapshot')
clonedDoc.getSelection?.()?.removeAllRanges()
}
})
const outputCanvas = document.createElement('canvas')
outputCanvas.width = OUTPUT_WIDTH
outputCanvas.height = OUTPUT_HEIGHT
const ctx = outputCanvas.getContext('2d')
if (!ctx) {
return null
}
const isDark = themeMode === 'dark'
await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark)
const PADDING = 80
const contentWidth = OUTPUT_WIDTH - PADDING * 2
const contentHeight = OUTPUT_HEIGHT - PADDING * 2
const srcRatio = canvas.width / canvas.height
const dstRatio = contentWidth / contentHeight
let drawWidth: number
let drawHeight: number
let drawX: number
let drawY: number
if (srcRatio > dstRatio) {
drawWidth = contentWidth
drawHeight = contentWidth / srcRatio
drawX = PADDING
drawY = PADDING + (contentHeight - drawHeight) / 2
} else {
drawHeight = contentHeight
drawWidth = contentHeight * srcRatio
drawX = PADDING + (contentWidth - drawWidth) / 2
drawY = PADDING
}
ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight)
return { name: section.name, data: outputCanvas.toDataURL('image/png') }
} catch {
return null
} finally {
element.style.cssText = originalStyle
if (wordCloudInner) {
wordCloudInner.style.cssText = wordCloudOriginalStyle
}
wordTags?.forEach((tag, index) => {
tag.style.cssText = wordTagOriginalStyles[index]
})
document.body.classList.remove('exporting-snapshot')
document.documentElement.classList.remove('exporting-snapshot')
}
}
const exportFullReport = async (filterIds?: Set<string>) => {
if (!containerRef.current || !reportData) {
return
}
setIsExporting(true)
setExportProgress('正在生成长图...')
let wordCloudInner: HTMLElement | null = null
let wordTags: NodeListOf<HTMLElement> | null = null
let wordCloudOriginalStyle = ''
const wordTagOriginalStyles: string[] = []
const container = containerRef.current
const sections = Array.from(container.querySelectorAll('.section')) as HTMLElement[]
const originalStyles = sections.map((section) => section.style.cssText)
try {
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
const activeEl = document.activeElement as HTMLElement | null
activeEl?.blur?.()
document.body.classList.add('exporting-snapshot')
document.documentElement.classList.add('exporting-snapshot')
sections.forEach((section) => {
section.style.minHeight = 'auto'
section.style.padding = '40px 0'
})
if (filterIds) {
getAvailableSections().forEach((section) => {
if (!filterIds.has(section.id) && section.ref.current) {
section.ref.current.style.display = 'none'
}
})
}
wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement | null
wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
if (wordCloudInner) {
wordCloudOriginalStyle = wordCloudInner.style.cssText
wordCloudInner.style.transform = 'none'
}
wordTags.forEach((tag, index) => {
wordTagOriginalStyles[index] = tag.style.cssText
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
tag.style.animation = 'none'
})
await new Promise((resolve) => setTimeout(resolve, 100))
const computedStyle = getComputedStyle(document.documentElement)
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
const canvas = await html2canvas(container, {
backgroundColor: 'transparent',
scale: 2,
useCORS: true,
allowTaint: true,
logging: false,
onclone: (clonedDoc) => {
clonedDoc.body.classList.add('exporting-snapshot')
clonedDoc.documentElement.classList.add('exporting-snapshot')
clonedDoc.getSelection?.()?.removeAllRanges()
}
})
const outputCanvas = document.createElement('canvas')
outputCanvas.width = canvas.width
outputCanvas.height = canvas.height
const ctx = outputCanvas.getContext('2d')
if (!ctx) {
throw new Error('无法创建导出画布')
}
const isDark = themeMode === 'dark'
await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark)
ctx.drawImage(canvas, 0, 0)
const yearFilePrefix = formatFileYearLabel(reportData.year)
const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername)
const link = document.createElement('a')
link.download = `${yearFilePrefix}双人年度报告_${friendFileSegment}${filterIds ? '_自定义' : ''}.png`
link.href = outputCanvas.toDataURL('image/png')
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (e) {
alert('导出失败: ' + String(e))
} finally {
sections.forEach((section, index) => {
section.style.cssText = originalStyles[index]
})
if (wordCloudInner) {
wordCloudInner.style.cssText = wordCloudOriginalStyle
}
wordTags?.forEach((tag, index) => {
tag.style.cssText = wordTagOriginalStyles[index]
})
document.body.classList.remove('exporting-snapshot')
document.documentElement.classList.remove('exporting-snapshot')
setIsExporting(false)
setExportProgress('')
}
}
const exportSelectedSections = async () => {
if (!reportData) return
const sections = getAvailableSections().filter((section) => selectedSections.has(section.id))
if (sections.length === 0) {
alert('请至少选择一个板块')
return
}
if (exportMode === 'long') {
setShowExportModal(false)
await exportFullReport(selectedSections)
setSelectedSections(new Set())
return
}
setIsExporting(true)
setShowExportModal(false)
const exportedImages: Array<{ name: string; data: string }> = []
for (let index = 0; index < sections.length; index++) {
const section = sections[index]
setExportProgress(`正在导出: ${section.name} (${index + 1}/${sections.length})`)
const result = await exportSection(section)
if (result) {
exportedImages.push(result)
}
}
if (exportedImages.length === 0) {
alert('导出失败')
setIsExporting(false)
setExportProgress('')
return
}
const dirResult = await window.electronAPI.dialog.openDirectory({
title: '选择导出文件夹',
properties: ['openDirectory', 'createDirectory']
})
if (dirResult.canceled || !dirResult.filePaths?.[0]) {
setIsExporting(false)
setExportProgress('')
return
}
setExportProgress('正在写入文件...')
const yearFilePrefix = formatFileYearLabel(reportData.year)
const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername)
const exportResult = await window.electronAPI.annualReport.exportImages({
baseDir: dirResult.filePaths[0],
folderName: `${yearFilePrefix}双人年度报告_${friendFileSegment}_分模块`,
images: exportedImages.map((image) => ({
name: `${yearFilePrefix}双人年度报告_${friendFileSegment}_${image.name}.png`,
dataUrl: image.data
}))
})
if (!exportResult.success) {
alert('导出失败: ' + (exportResult.error || '未知错误'))
}
setIsExporting(false)
setExportProgress('')
setSelectedSections(new Set())
}
const toggleSection = (id: string) => {
const next = new Set(selectedSections)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
setSelectedSections(next)
}
const toggleAll = () => {
const sections = getAvailableSections()
if (selectedSections.size === sections.length) {
setSelectedSections(new Set())
return
}
setSelectedSections(new Set(sections.map((section) => section.id)))
}
if (isLoading) {
return (
<div className="annual-report-window loading">
@@ -305,7 +683,7 @@ function DualReportWindow() {
if (emojiUrl) {
return (
<div className="report-emoji-container">
<img src={emojiUrl} alt="表情" className="report-emoji-img" onError={(e) => {
<img src={emojiUrl} alt="表情" className="report-emoji-img" crossOrigin="anonymous" onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
}} />
@@ -356,7 +734,7 @@ function DualReportWindow() {
if (avatarUrl) {
return (
<div className="scene-avatar with-image">
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} />
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} crossOrigin="anonymous" />
</div>
)
}
@@ -419,9 +797,99 @@ function DualReportWindow() {
<div className="deco-circle c5" />
</div>
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
<button
className="fab-item"
onClick={() => {
setFabOpen(false)
setExportMode('separate')
setShowExportModal(true)
}}
title="分模块导出"
>
<Image size={18} />
</button>
<button
className="fab-item"
onClick={() => {
setFabOpen(false)
setExportMode('long')
setShowExportModal(true)
}}
title="自定义导出长图"
>
<SlidersHorizontal size={18} />
</button>
<button
className="fab-item"
onClick={() => {
setFabOpen(false)
void exportFullReport()
}}
title="导出长图"
>
<Download size={18} />
</button>
<button className="fab-main" onClick={() => setFabOpen(!fabOpen)}>
{fabOpen ? <X size={22} /> : <Download size={22} />}
</button>
</div>
{isExporting && (
<div className="export-overlay">
<div className="export-progress-modal">
<div className="export-spinner">
<div className="spinner-ring"></div>
<Download size={24} className="spinner-icon" />
</div>
<p className="export-title"></p>
<p className="export-status">{exportProgress}</p>
</div>
</div>
)}
{showExportModal && (
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
<div className="export-modal section-selector" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
<button className="close-btn" onClick={() => setShowExportModal(false)}>
<X size={20} />
</button>
</div>
<div className="section-grid">
{getAvailableSections().map((section) => (
<div
key={section.id}
className={`section-card ${selectedSections.has(section.id) ? 'selected' : ''}`}
onClick={() => toggleSection(section.id)}
>
<div className="card-check">
{selectedSections.has(section.id) && <Check size={14} />}
</div>
<span>{section.name}</span>
</div>
))}
</div>
<div className="modal-footer">
<button className="select-all-btn" onClick={toggleAll}>
{selectedSections.size === getAvailableSections().length ? '取消全选' : '全选'}
</button>
<button
className="confirm-btn"
onClick={() => void exportSelectedSections()}
disabled={selectedSections.size === 0}
>
{exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
</button>
</div>
</div>
</div>
)}
<div className="report-scroll-view">
<div className="report-container">
<section className="section">
<div className="report-container" ref={containerRef}>
<section className="section" ref={sectionRefs.cover}>
<div className="label-text">WEFLOW · DUAL REPORT</div>
<h1 className="hero-title dual-cover-title">{yearTitle}<br /></h1>
<hr className="divider" />
@@ -433,7 +901,7 @@ function DualReportWindow() {
<p className="hero-desc"></p>
</section>
<section className="section">
<section className="section" ref={sectionRefs.firstChat}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{firstChat ? (
@@ -457,7 +925,7 @@ function DualReportWindow() {
</section>
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? (
<section className="section">
<section className="section" ref={sectionRefs.yearFirstChat}>
<div className="label-text"></div>
<h2 className="hero-title">
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
@@ -473,7 +941,7 @@ function DualReportWindow() {
) : null}
{reportData.heatmap && (
<section className="section">
<section className="section" ref={sectionRefs.heatmap}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{mostActive && (
@@ -486,14 +954,14 @@ function DualReportWindow() {
)}
{reportData.initiative && (
<section className="section">
<section className="section" ref={sectionRefs.initiative}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="initiative-container">
<div className="initiative-bar-wrapper">
<div className="initiative-side">
<div className="avatar-placeholder">
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" /> : '我'}
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" crossOrigin="anonymous" /> : '我'}
</div>
<div className="count">{reportData.initiative.initiated}</div>
<div className="percent">{initiatedPercent.toFixed(1)}%</div>
@@ -507,7 +975,7 @@ function DualReportWindow() {
</div>
<div className="initiative-side">
<div className="avatar-placeholder">
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" /> : reportData.friendName.substring(0, 1)}
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" crossOrigin="anonymous" /> : reportData.friendName.substring(0, 1)}
</div>
<div className="count">{reportData.initiative.received}</div>
<div className="percent">{receivedPercent.toFixed(1)}%</div>
@@ -521,7 +989,7 @@ function DualReportWindow() {
)}
{reportData.response && (
<section className="section">
<section className="section" ref={sectionRefs.response}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="response-pulse-container">
@@ -558,7 +1026,7 @@ function DualReportWindow() {
)}
{reportData.streak && (
<section className="section">
<section className="section" ref={sectionRefs.streak}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="streak-spark-visual premium">
@@ -596,7 +1064,7 @@ function DualReportWindow() {
</section>
)}
<section className="section word-cloud-section">
<section className="section word-cloud-section" ref={sectionRefs.wordCloud}>
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
@@ -640,7 +1108,7 @@ function DualReportWindow() {
</div>
</section>
<section className="section">
<section className="section" ref={sectionRefs.stats}>
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
<div className="dual-stat-grid">
@@ -664,7 +1132,7 @@ function DualReportWindow() {
<div className="emoji-card">
<div className="emoji-title"></div>
{myEmojiUrl ? (
<img src={myEmojiUrl} alt="my-emoji" onError={(e) => {
<img src={myEmojiUrl} alt="my-emoji" crossOrigin="anonymous" onError={(e) => {
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
(e.target as HTMLImageElement).style.display = 'none';
}} />
@@ -677,7 +1145,7 @@ function DualReportWindow() {
<div className="emoji-card">
<div className="emoji-title">{reportData.friendName}</div>
{friendEmojiUrl ? (
<img src={friendEmojiUrl} alt="friend-emoji" onError={(e) => {
<img src={friendEmojiUrl} alt="friend-emoji" crossOrigin="anonymous" onError={(e) => {
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
(e.target as HTMLImageElement).style.display = 'none';
}} />
@@ -690,7 +1158,7 @@ function DualReportWindow() {
</div>
</section>
<section className="section">
<section className="section" ref={sectionRefs.ending}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<p className="hero-desc"></p>

36
src/utils/reportExport.ts Normal file
View File

@@ -0,0 +1,36 @@
const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
export const drawPatternBackground = async (
ctx: CanvasRenderingContext2D,
width: number,
height: number,
bgColor: string,
isDark: boolean
) => {
ctx.fillStyle = bgColor
ctx.fillRect(0, 0, width, height)
const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG
const blob = new Blob([svgString], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
return new Promise<void>((resolve) => {
const img = new window.Image()
img.onload = () => {
const pattern = ctx.createPattern(img, 'repeat')
if (pattern) {
ctx.fillStyle = pattern
ctx.fillRect(0, 0, width, height)
}
URL.revokeObjectURL(url)
resolve()
}
img.onerror = () => {
URL.revokeObjectURL(url)
resolve()
}
img.src = url
})
}