Files
weflow-new/src/pages/DualReportWindow.tsx
T

705 lines
27 KiB
TypeScript

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<string, number>
streak?: { days: number; startDate: string; endDate: string }
}
function DualReportWindow() {
const [reportData, setReportData] = useState<DualReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [loadingStage, setLoadingStage] = useState('准备中')
const [loadingProgress, setLoadingProgress] = useState(0)
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(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 (
<div className="annual-report-window loading">
<div className="loading-ring">
<svg viewBox="0 0 100 100">
<circle className="ring-bg" cx="50" cy="50" r="42" />
<circle
className="ring-progress"
cx="50" cy="50" r="42"
style={{ strokeDashoffset: 264 - (264 * loadingProgress / 100) }}
/>
</svg>
<span className="ring-text">{loadingProgress}%</span>
</div>
<p className="loading-stage">{loadingStage}</p>
<p className="loading-hint">进行中</p>
</div>
)
}
if (error) {
return (
<div className="annual-report-window error">
<p>生成报告失败: {error}</p>
</div>
)
}
if (!reportData) {
return (
<div className="annual-report-window error">
<p>暂无数据</p>
</div>
)
}
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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/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(/<!\[CDATA\[([\s\S]*?)\]\]>/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(/<title>([\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">&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