diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index ab5fd0d..5b2d510 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -8,44 +8,9 @@ import { registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' +import { drawPatternBackground } from '../utils/reportExport' import './AnnualReportWindow.scss' -// SVG 背景图案 (用于导出) -const PATTERN_LIGHT_SVG = `` - -const PATTERN_DARK_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((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 diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 44bee66..7eb5ff9 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -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); } } -} \ No newline at end of file +} diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 585fb1e..e9f9627 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -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 +} + function DualReportWindow() { const [reportData, setReportData] = useState(null) const [isLoading, setIsLoading] = useState(true) @@ -75,6 +85,29 @@ function DualReportWindow() { const [myEmojiUrl, setMyEmojiUrl] = useState(null) const [friendEmojiUrl, setFriendEmojiUrl] = useState(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>(new Set()) + const [fabOpen, setFabOpen] = useState(false) + const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate') + + const { themeMode } = useThemeStore() + + const sectionRefs = { + cover: useRef(null), + firstChat: useRef(null), + yearFirstChat: useRef(null), + heatmap: useRef(null), + initiative: useRef(null), + response: useRef(null), + streak: useRef(null), + wordCloud: useRef(null), + stats: useRef(null), + ending: useRef(null) + } + + const containerRef = useRef(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 | 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 + + 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) => { + if (!containerRef.current || !reportData) { + return + } + + setIsExporting(true) + setExportProgress('正在生成长图...') + + let wordCloudInner: HTMLElement | null = null + let wordTags: NodeListOf | 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 + + 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 (
@@ -305,7 +683,7 @@ function DualReportWindow() { if (emojiUrl) { return (
- 表情 { + 表情 { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); }} /> @@ -356,7 +734,7 @@ function DualReportWindow() { if (avatarUrl) { return (
- {isSentByMe + {isSentByMe
) } @@ -419,9 +797,99 @@ function DualReportWindow() {
+
+ + + + +
+ + {isExporting && ( +
+
+
+
+ +
+

正在导出

+

{exportProgress}

+
+
+ )} + + {showExportModal && ( +
setShowExportModal(false)}> +
e.stopPropagation()}> +
+

{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}

+ +
+
+ {getAvailableSections().map((section) => ( +
toggleSection(section.id)} + > +
+ {selectedSections.has(section.id) && } +
+ {section.name} +
+ ))} +
+
+ + +
+
+
+ )} +
-
-
+
+
WEFLOW · DUAL REPORT

{yearTitle}
双人聊天报告


@@ -433,7 +901,7 @@ function DualReportWindow() {

每一次对话都值得被珍藏

-
+
首次聊天

故事的开始

{firstChat ? ( @@ -457,7 +925,7 @@ function DualReportWindow() {
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? ( -
+
第一段对话

{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`} @@ -473,7 +941,7 @@ function DualReportWindow() { ) : null} {reportData.heatmap && ( -
+
聊天习惯

作息规律

{mostActive && ( @@ -486,14 +954,14 @@ function DualReportWindow() { )} {reportData.initiative && ( -
+
主动性

情感的天平

- {reportData.selfAvatarUrl ? me-avatar : '我'} + {reportData.selfAvatarUrl ? me-avatar : '我'}
{reportData.initiative.initiated}次
{initiatedPercent.toFixed(1)}%
@@ -507,7 +975,7 @@ function DualReportWindow() {
- {reportData.friendAvatarUrl ? friend-avatar : reportData.friendName.substring(0, 1)} + {reportData.friendAvatarUrl ? friend-avatar : reportData.friendName.substring(0, 1)}
{reportData.initiative.received}次
{receivedPercent.toFixed(1)}%
@@ -521,7 +989,7 @@ function DualReportWindow() { )} {reportData.response && ( -
+
回应速度

你说,我在

@@ -558,7 +1026,7 @@ function DualReportWindow() { )} {reportData.streak && ( -
+
聊天火花

最长连续聊天

@@ -596,7 +1064,7 @@ function DualReportWindow() {
)} -
+
常用语

{yearTitle}常用语

@@ -640,7 +1108,7 @@ function DualReportWindow() {
-
+
年度统计

{yearTitle}数据概览

@@ -664,7 +1132,7 @@ function DualReportWindow() {
我常用的表情
{myEmojiUrl ? ( - my-emoji { + my-emoji { (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).style.display = 'none'; }} /> @@ -677,7 +1145,7 @@ function DualReportWindow() {
{reportData.friendName}常用的表情
{friendEmojiUrl ? ( - friend-emoji { + friend-emoji { (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).style.display = 'none'; }} /> @@ -690,7 +1158,7 @@ function DualReportWindow() {
-
+
尾声

谢谢你一直在

愿我们继续把故事写下去

diff --git a/src/utils/reportExport.ts b/src/utils/reportExport.ts new file mode 100644 index 0000000..224b99e --- /dev/null +++ b/src/utils/reportExport.ts @@ -0,0 +1,36 @@ +const PATTERN_LIGHT_SVG = `` + +const PATTERN_DARK_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((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 + }) +}