import { useEffect, useState } from 'react' import ReportHeatmap from '../components/ReportHeatmap' import ReportWordCloud from '../components/ReportWordCloud' import './AnnualReportWindow.scss' import './DualReportWindow.scss' interface DualReportMessage { content: string isSentByMe: boolean createTime: number createTimeStr: string localType?: number emojiMd5?: string emojiCdnUrl?: string } interface DualReportData { year: number selfName: string selfAvatarUrl?: string friendUsername: string friendName: string friendAvatarUrl?: string firstChat: { createTime: number createTimeStr: string content: string isSentByMe: boolean senderUsername?: string localType?: number emojiMd5?: string emojiCdnUrl?: string } | null firstChatMessages?: DualReportMessage[] yearFirstChat?: { createTime: number createTimeStr: string content: string isSentByMe: boolean friendName: string firstThreeMessages: DualReportMessage[] localType?: number emojiMd5?: string emojiCdnUrl?: string } | null stats: { totalMessages: number totalWords: number imageCount: number voiceCount: number emojiCount: number myTopEmojiMd5?: string friendTopEmojiMd5?: string myTopEmojiUrl?: string friendTopEmojiUrl?: string myTopEmojiCount?: number friendTopEmojiCount?: number } topPhrases: Array<{ phrase: string; count: number }> myExclusivePhrases: Array<{ phrase: string; count: number }> friendExclusivePhrases: Array<{ phrase: string; count: number }> heatmap?: number[][] initiative?: { initiated: number; received: number } response?: { avg: number; fastest: number; slowest: number; count: number } monthly?: Record streak?: { days: number; startDate: string; endDate: string } } function DualReportWindow() { const [reportData, setReportData] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [loadingStage, setLoadingStage] = useState('准备中') const [loadingProgress, setLoadingProgress] = useState(0) const [myEmojiUrl, setMyEmojiUrl] = useState(null) const [friendEmojiUrl, setFriendEmojiUrl] = useState(null) const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared') useEffect(() => { const params = new URLSearchParams(window.location.hash.split('?')[1] || '') const username = params.get('username') const yearParam = params.get('year') const parsedYear = yearParam ? parseInt(yearParam, 10) : 0 const year = Number.isNaN(parsedYear) ? 0 : parsedYear if (!username) { setError('缺少好友信息') setIsLoading(false) return } generateReport(username, year) }, []) const generateReport = async (friendUsername: string, year: number) => { setIsLoading(true) setError(null) setLoadingProgress(0) const removeProgressListener = window.electronAPI.dualReport.onProgress?.((payload: { status: string; progress: number }) => { setLoadingProgress(payload.progress) setLoadingStage(payload.status) }) try { const result = await window.electronAPI.dualReport.generateReport({ friendUsername, year }) removeProgressListener?.() setLoadingProgress(100) setLoadingStage('完成') if (result.success && result.data) { const normalizedResponse = result.data.response ? { ...result.data.response, slowest: result.data.response.slowest ?? result.data.response.avg } : undefined setReportData({ ...result.data, response: normalizedResponse }) setIsLoading(false) } else { setError(result.error || '生成报告失败') setIsLoading(false) } } catch (e) { removeProgressListener?.() setError(String(e)) setIsLoading(false) } } useEffect(() => { const loadEmojis = async () => { if (!reportData) return setMyEmojiUrl(null) setFriendEmojiUrl(null) const stats = reportData.stats if (stats.myTopEmojiUrl) { const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5) if (res.success && res.localPath) { setMyEmojiUrl(res.localPath) } } if (stats.friendTopEmojiUrl) { const res = await window.electronAPI.chat.downloadEmoji(stats.friendTopEmojiUrl, stats.friendTopEmojiMd5) if (res.success && res.localPath) { setFriendEmojiUrl(res.localPath) } } } void loadEmojis() }, [reportData]) if (isLoading) { return (
{loadingProgress}%

{loadingStage}

进行中

) } if (error) { return (

生成报告失败: {error}

) } if (!reportData) { return (

暂无数据

) } const yearTitle = reportData.year === 0 ? '全部时间' : `${reportData.year}年` const firstChat = reportData.firstChat const firstChatMessages = (reportData.firstChatMessages && reportData.firstChatMessages.length > 0) ? reportData.firstChatMessages.slice(0, 3) : firstChat ? [{ content: firstChat.content, isSentByMe: firstChat.isSentByMe, createTime: firstChat.createTime, createTimeStr: firstChat.createTimeStr }] : [] const daysSince = firstChat ? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000)) : null const yearFirstChat = reportData.yearFirstChat const stats = reportData.stats const initiativeTotal = (reportData.initiative?.initiated || 0) + (reportData.initiative?.received || 0) const initiatedPercent = initiativeTotal > 0 ? (reportData.initiative!.initiated / initiativeTotal) * 100 : 0 const receivedPercent = initiativeTotal > 0 ? (reportData.initiative!.received / initiativeTotal) * 100 : 0 const statItems = [ { label: '总消息数', value: stats.totalMessages, color: '#07C160' }, { label: '总字数', value: stats.totalWords, color: '#10AEFF' }, { label: '图片', value: stats.imageCount, color: '#FFC300' }, { label: '语音', value: stats.voiceCount, color: '#FA5151' }, { label: '表情', value: stats.emojiCount, color: '#FA9D3B' }, ] const decodeEntities = (text: string) => ( text .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") ) const filterDisplayMessages = (messages: DualReportMessage[], maxActual: number = 3) => { let actualCount = 0 const result: DualReportMessage[] = [] for (const msg of messages) { const isSystem = msg.localType === 10000 || msg.localType === 10002 if (!isSystem) { if (actualCount >= maxActual) break actualCount++ } result.push(msg) } return result } const stripCdata = (text: string) => text.replace(//g, '$1') const compactMessageText = (text: string) => ( text .replace(/\r\n/g, '\n') .replace(/\s*\n+\s*/g, ' ') .replace(/\s{2,}/g, ' ') .trim() ) const extractXmlText = (content: string) => { const titleMatch = content.match(/([\s\S]*?)<\/title>/i) if (titleMatch?.[1]) return titleMatch[1] const descMatch = content.match(/<des>([\s\S]*?)<\/des>/i) if (descMatch?.[1]) return descMatch[1] const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i) if (summaryMatch?.[1]) return summaryMatch[1] const contentMatch = content.match(/<content>([\s\S]*?)<\/content>/i) if (contentMatch?.[1]) return contentMatch[1] return '' } const formatMessageContent = (content?: string, localType?: number) => { const isSystemMsg = localType === 10000 || localType === 10002 if (!isSystemMsg) { if (localType === 3) return '[图片]' if (localType === 34) return '[语音]' if (localType === 43) return '[视频]' if (localType === 47) return '[表情]' if (localType === 42) return '[名片]' if (localType === 48) return '[位置]' if (localType === 49) return '[链接/文件]' } const raw = compactMessageText(String(content || '').trim()) if (!raw) return '(空)' // 1. 尝试提取 XML 关键字段 const titleMatch = raw.match(/<title>([\s\S]*?)<\/title>/i) if (titleMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(titleMatch[1]).trim())) const descMatch = raw.match(/<des>([\s\S]*?)<\/des>/i) if (descMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(descMatch[1]).trim())) const summaryMatch = raw.match(/<summary>([\s\S]*?)<\/summary>/i) if (summaryMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(summaryMatch[1]).trim())) // 2. 检查是否是 XML 结构 const hasXmlTag = /<\s*[a-zA-Z]+[^>]*>/.test(raw) const looksLikeXml = /<\?xml|<msg\b|<appmsg\b|<sysmsg\b|<appattach\b|<emoji\b|<img\b|<voip\b/i.test(raw) || hasXmlTag if (!looksLikeXml) return raw // 3. 最后的尝试:移除所有 XML 标签,看是否还有有意义的文本 const stripped = raw.replace(/<[^>]+>/g, '').trim() if (stripped && stripped.length > 0 && stripped.length < 50) { return compactMessageText(decodeEntities(stripped)) } return '[多媒体消息]' } const ReportMessageItem = ({ msg }: { msg: DualReportMessage }) => { if (msg.localType === 47 && (msg.emojiMd5 || msg.emojiCdnUrl)) { const emojiUrl = msg.emojiCdnUrl || (msg.emojiMd5 ? `https://emoji.qpic.cn/wx_emoji/${msg.emojiMd5}/0` : '') if (emojiUrl) { return ( <div className="report-emoji-container"> <img src={emojiUrl} alt="表情" className="report-emoji-img" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); }} /> <span style={{ display: 'none' }}>[表情]</span> </div> ) } } return <span>{formatMessageContent(msg.content, msg.localType)}</span> } const formatFullDate = (timestamp: number) => { const d = new Date(timestamp) const year = d.getFullYear() const month = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') const hour = String(d.getHours()).padStart(2, '0') const minute = String(d.getMinutes()).padStart(2, '0') return `${year}/${month}/${day} ${hour}:${minute}` } const getMostActiveTime = (data: number[][]) => { let maxHour = 0 let maxWeekday = 0 let maxVal = -1 data.forEach((row, weekday) => { row.forEach((value, hour) => { if (value > maxVal) { maxVal = value maxHour = hour maxWeekday = weekday } }) }) const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] return { weekday: weekdayNames[maxWeekday] || '周一', hour: maxHour, value: Math.max(0, maxVal) } } const mostActive = reportData.heatmap ? getMostActiveTime(reportData.heatmap) : null const responseAvgMinutes = reportData.response ? Math.max(0, Math.round(reportData.response.avg / 60)) : 0 const getSceneAvatarUrl = (isSentByMe: boolean) => (isSentByMe ? reportData.selfAvatarUrl : reportData.friendAvatarUrl) const getSceneAvatarFallback = (isSentByMe: boolean) => (isSentByMe ? '我' : reportData.friendName.substring(0, 1)) const renderSceneAvatar = (isSentByMe: boolean) => { const avatarUrl = getSceneAvatarUrl(isSentByMe) if (avatarUrl) { return ( <div className="scene-avatar with-image"> <img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} /> </div> ) } return <div className="scene-avatar fallback">{getSceneAvatarFallback(isSentByMe)}</div> } const renderMessageList = (messages: DualReportMessage[]) => { const displayMsgs = filterDisplayMessages(messages) let lastTime = 0 const TIME_THRESHOLD = 5 * 60 * 1000 // 5 分钟 return displayMsgs.map((msg, idx) => { const isSystem = msg.localType === 10000 || msg.localType === 10002 const showTime = idx === 0 || (msg.createTime - lastTime > TIME_THRESHOLD) lastTime = msg.createTime if (isSystem) { return ( <div key={idx} className="scene-message system"> {showTime && ( <div className="scene-meta"> {formatFullDate(msg.createTime).split(' ')[1]} </div> )} <div className="system-msg-content"> <ReportMessageItem msg={msg} /> </div> </div> ) } return ( <div key={idx} className={`scene-message ${msg.isSentByMe ? 'sent' : 'received'}`}> {showTime && ( <div className="scene-meta"> {formatFullDate(msg.createTime).split(' ')[1]} </div> )} <div className="scene-body"> {renderSceneAvatar(msg.isSentByMe)} <div className="scene-content-wrapper"> <div className={`scene-bubble ${msg.localType === 47 ? 'no-bubble' : ''}`}> <div className="scene-content"><ReportMessageItem msg={msg} /></div> </div> </div> </div> </div> ) }) } return ( <div className="annual-report-window dual-report-window"> <div className="drag-region" /> <div className="bg-decoration"> <div className="deco-circle c1" /> <div className="deco-circle c2" /> <div className="deco-circle c3" /> <div className="deco-circle c4" /> <div className="deco-circle c5" /> </div> <div className="report-scroll-view"> <div className="report-container"> <section className="section"> <div className="label-text">WEFLOW · DUAL REPORT</div> <h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1> <hr className="divider" /> <div className="dual-names"> <span>我</span> <span className="amp">&</span> <span>{reportData.friendName}</span> </div> <p className="hero-desc">每一次对话都值得被珍藏</p> </section> <section className="section"> <div className="label-text">首次聊天</div> <h2 className="hero-title">故事的开始</h2> {firstChat ? ( <div className="first-chat-scene"> <div className="scene-title">第一次遇见</div> <div className="scene-subtitle">{formatFullDate(firstChat.createTime).split(' ')[0]}</div> {firstChatMessages.length > 0 ? ( <div className="scene-messages"> {renderMessageList(firstChatMessages)} </div> ) : ( <div className="hero-desc" style={{ textAlign: 'center' }}>暂无消息详情</div> )} <div className="scene-footer" style={{ marginTop: '20px', textAlign: 'center', fontSize: '14px', opacity: 0.6 }}> 距离今天已经 {daysSince} 天 </div> </div> ) : ( <p className="hero-desc">暂无首条消息</p> )} </section> {yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? ( <section className="section"> <div className="label-text">第一段对话</div> <h2 className="hero-title"> {reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`} </h2> <div className="first-chat-scene"> <div className="scene-title">久别重逢</div> <div className="scene-subtitle">{formatFullDate(yearFirstChat.createTime).split(' ')[0]}</div> <div className="scene-messages"> {renderMessageList(yearFirstChat.firstThreeMessages)} </div> </div> </section> ) : null} {reportData.heatmap && ( <section className="section"> <div className="label-text">聊天习惯</div> <h2 className="hero-title">作息规律</h2> {mostActive && ( <p className="hero-desc active-time dual-active-time"> 在 <span className="hl">{mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00</span> 最活跃({mostActive.value}条) </p> )} <ReportHeatmap data={reportData.heatmap} /> </section> )} {reportData.initiative && ( <section className="section"> <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" /> : '我'} </div> <div className="count">{reportData.initiative.initiated}次</div> <div className="percent">{initiatedPercent.toFixed(1)}%</div> </div> <div className="initiative-progress"> <div className="line-bg" /> <div className="initiative-indicator" style={{ left: `${initiatedPercent}%` }} /> </div> <div className="initiative-side"> <div className="avatar-placeholder"> {reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" /> : reportData.friendName.substring(0, 1)} </div> <div className="count">{reportData.initiative.received}次</div> <div className="percent">{receivedPercent.toFixed(1)}%</div> </div> </div> <div className="initiative-desc"> {reportData.initiative.initiated > reportData.initiative.received ? '每一个话题都是你对TA的在意' : 'TA总是那个率先打破沉默的人'} </div> </div> </section> )} {reportData.response && ( <section className="section"> <div className="label-text">回应速度</div> <h2 className="hero-title">你说,我在</h2> <div className="response-pulse-container"> <div className="pulse-visual"> <div className="pulse-ripple one" /> <div className="pulse-ripple two" /> <div className="pulse-ripple three" /> <div className="pulse-node left"> <div className="label">最快回复</div> <div className="value">{reportData.response.fastest}<span>秒</span></div> </div> <div className="pulse-hub"> <div className="label">平均回复</div> <div className="value">{Math.round(reportData.response.avg / 60)}<span>分</span></div> </div> <div className="pulse-node right"> <div className="label">最慢回复</div> <div className="value"> {reportData.response.slowest > 3600 ? (reportData.response.slowest / 3600).toFixed(1) : Math.round(reportData.response.slowest / 60)} <span>{reportData.response.slowest > 3600 ? '时' : '分'}</span> </div> </div> </div> </div> <p className="hero-desc response-note"> {`在 ${reportData.response.count} 次互动中,平均约 ${responseAvgMinutes} 分钟,最快 ${reportData.response.fastest} 秒。`} </p> </section> )} {reportData.streak && ( <section className="section"> <div className="label-text">聊天火花</div> <h2 className="hero-title">最长连续聊天</h2> <div className="streak-spark-visual premium"> <div className="spark-ambient-glow" /> <div className="spark-ember one" /> <div className="spark-ember two" /> <div className="spark-ember three" /> <div className="spark-ember four" /> <div className="spark-core-wrapper"> <div className="spark-flame-outer" /> <div className="spark-flame-inner" /> <div className="spark-core"> <div className="spark-days">{reportData.streak.days}</div> <div className="spark-label">DAYS</div> </div> </div> <div className="streak-bridge premium"> <div className="bridge-date start"> <div className="date-orb" /> <span>{reportData.streak.startDate}</span> </div> <div className="bridge-line"> <div className="line-glow" /> <div className="line-string" /> </div> <div className="bridge-date end"> <span>{reportData.streak.endDate}</span> <div className="date-orb" /> </div> </div> </div> </section> )} <section className="section word-cloud-section"> <div className="label-text">常用语</div> <h2 className="hero-title">{yearTitle}常用语</h2> <div className="word-cloud-tabs"> <button className={`tab-item ${activeWordCloudTab === 'shared' ? 'active' : ''}`} onClick={() => setActiveWordCloudTab('shared')} > 共用词汇 </button> <button className={`tab-item ${activeWordCloudTab === 'my' ? 'active' : ''}`} onClick={() => setActiveWordCloudTab('my')} > 我的专属 </button> <button className={`tab-item ${activeWordCloudTab === 'friend' ? 'active' : ''}`} onClick={() => setActiveWordCloudTab('friend')} > TA的专属 </button> </div> <div className={`word-cloud-container fade-in ${activeWordCloudTab}`}> {activeWordCloudTab === 'shared' && <ReportWordCloud words={reportData.topPhrases} />} {activeWordCloudTab === 'my' && ( reportData.myExclusivePhrases && reportData.myExclusivePhrases.length > 0 ? ( <ReportWordCloud words={reportData.myExclusivePhrases} /> ) : ( <div className="empty-state">暂无专属词汇</div> ) )} {activeWordCloudTab === 'friend' && ( reportData.friendExclusivePhrases && reportData.friendExclusivePhrases.length > 0 ? ( <ReportWordCloud words={reportData.friendExclusivePhrases} /> ) : ( <div className="empty-state">暂无专属词汇</div> ) )} </div> </section> <section className="section"> <div className="label-text">年度统计</div> <h2 className="hero-title">{yearTitle}数据概览</h2> <div className="dual-stat-grid"> {statItems.slice(0, 2).map((item) => ( <div key={item.label} className="dual-stat-card"> <div className="stat-num">{item.value.toLocaleString()}</div> <div className="stat-unit">{item.label}</div> </div> ))} </div> <div className="dual-stat-grid bottom"> {statItems.slice(2).map((item) => ( <div key={item.label} className="dual-stat-card"> <div className="stat-num small">{item.value.toLocaleString()}</div> <div className="stat-unit">{item.label}</div> </div> ))} </div> <div className="emoji-row"> <div className="emoji-card"> <div className="emoji-title">我常用的表情</div> {myEmojiUrl ? ( <img src={myEmojiUrl} alt="my-emoji" onError={(e) => { (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : null} <div className="emoji-placeholder" style={myEmojiUrl ? { display: 'none' } : undefined}> {stats.myTopEmojiMd5 || '暂无'} </div> <div className="emoji-count">{stats.myTopEmojiCount ? `${stats.myTopEmojiCount}次` : '暂无统计'}</div> </div> <div className="emoji-card"> <div className="emoji-title">{reportData.friendName}常用的表情</div> {friendEmojiUrl ? ( <img src={friendEmojiUrl} alt="friend-emoji" onError={(e) => { (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).style.display = 'none'; }} /> ) : null} <div className="emoji-placeholder" style={friendEmojiUrl ? { display: 'none' } : undefined}> {stats.friendTopEmojiMd5 || '暂无'} </div> <div className="emoji-count">{stats.friendTopEmojiCount ? `${stats.friendTopEmojiCount}次` : '暂无统计'}</div> </div> </div> </section> <section className="section"> <div className="label-text">尾声</div> <h2 className="hero-title">谢谢你一直在</h2> <p className="hero-desc">愿我们继续把故事写下去</p> </section> </div> </div> </div> ) } export default DualReportWindow