+
+
+
- {loadingProgress}%
+ {Math.round(loadingProgress)}%
{loadingStage}
-
进行中
+
DUAL RECORD INIT
)
}
- if (error) {
+ if (error || !reportData) {
return (
-
-
生成报告失败: {error}
+
+
+
+
+
Report Initialization Failed
+
{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)
+ const formatFirstChat = (content: string) => {
+ if (!content) return ''
+ if (content.includes('')) {
+ const match = content.match(/
([^<]+)<\/title>/)
+ return match && match[1] ? `[${match[1]}]` : '[富文本消息]'
}
- return result
+ return content.trim()
}
- 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(/([\s\S]*?)<\/des>/i)
- if (descMatch?.[1]) return descMatch[1]
- const summaryMatch = content.match(/([\s\S]*?)<\/summary>/i)
- if (summaryMatch?.[1]) return summaryMatch[1]
- const contentMatch = content.match(/([\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(/([\s\S]*?)<\/title>/i)
- if (titleMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(titleMatch[1]).trim()))
-
- const descMatch = raw.match(/([\s\S]*?)<\/des>/i)
- if (descMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(descMatch[1]).trim()))
-
- const summaryMatch = raw.match(/([\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|]+>/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 (
-
-

{
- (e.target as HTMLImageElement).style.display = 'none';
- (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
- }} />
-
[表情]
-
- )
- }
- }
- return {formatMessageContent(msg.content, msg.localType)}
- }
- 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 (
-
-

-
- )
- }
- return {getSceneAvatarFallback(isSentByMe)}
- }
-
- 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 (
-
- {showTime && (
-
- {formatFullDate(msg.createTime).split(' ')[1]}
-
- )}
-
-
-
-
- )
- }
- return (
-
- {showTime && (
-
- {formatFullDate(msg.createTime).split(' ')[1]}
-
- )}
-
- {renderSceneAvatar(msg.isSentByMe)}
-
-
-
- )
- })
- }
+ // 计算第一句话数据
+ const displayFirstChat = reportData.yearFirstChat || reportData.firstChat
+ const firstChatArray = (reportData.yearFirstChatMessages || reportData.firstChatMessages || (displayFirstChat ? [displayFirstChat] : [])).slice(0, 3)
+
+ // 聊天火花
+ const showSpark = reportData.streak && reportData.streak.days > 0
+ // 回复速度
+ const avgResponseMins = reportData.response ? reportData.response.avg / 60 : 0
+ const fastestResponseSecs = reportData.response ? reportData.response.fastest : 0
+ // 主动性
+ const initRate = reportData.initiative ? (reportData.initiative.initiated / (reportData.initiative.initiated + reportData.initiative.received) * 100).toFixed(1) : 50
+
+ // 当前词云数据
+ const currentWordList = activeWordCloudTab === 'shared' ? reportData.topPhrases
+ : activeWordCloudTab === 'my' ? reportData.myExclusivePhrases
+ : reportData.friendExclusivePhrases
return (
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
+ {/* ============== 背景系统 ============== */}
+
- {isExporting && (
-
-
-
-
-
+
+
+ {/* S0: THE BINDING */}
+
+
+
+
+
+
+
+
+
-
正在导出
-
{exportProgress}
- )}
- {showExportModal && (
-
setShowExportModal(false)}>
-
e.stopPropagation()}>
-
-
{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}
-
-
-
- {getAvailableSections().map((section) => (
-
toggleSection(section.id)}
- >
-
- {selectedSections.has(section.id) && }
-
-
{section.name}
+ {/* S1: FIRST ENCOUNTER */}
+
+
+
+
故事的开始
+
+ {firstChatArray.map((chat: any, idx: number) => (
+
+
{chat.createTimeStr || formatMonthDayTime(chat.timestamp)}
+
{formatFirstChat(chat.content)}
))}
-
-
-
-
- )}
-
-
-
- WEFLOW · DUAL REPORT
- {yearTitle}
双人聊天报告
-
-
-
我
-
&
-
{reportData.friendName}
+ {/* S2: SYNCHRONIZATION */}
+
+
+
+
作息波纹
+
+
+ {reportData.heatmap ? (() => {
+ let maxVal = 0, maxDay = 0, maxHour = 0;
+ reportData.heatmap.forEach((dayRow: number[], dayIdx: number) => {
+ dayRow.forEach((val: number, hourIdx: number) => {
+ if (val > maxVal) { maxVal = val; maxDay = dayIdx; maxHour = hourIdx; }
+ });
+ });
+ const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
+ return <>在{dayNames[maxDay]}的{String(maxHour).padStart(2, '0')}:00,我们最为活跃>
+ })() : '我们的时空,在这里高频交叠'}
+
-
每一次对话都值得被珍藏
-
-
-
- 首次聊天
- 故事的开始
- {firstChat ? (
-
-
第一次遇见
-
{formatFullDate(firstChat.createTime).split(' ')[0]}
- {firstChatMessages.length > 0 ? (
-
- {renderMessageList(firstChatMessages)}
-
- ) : (
-
暂无消息详情
- )}
-
- 距离今天已经 {daysSince} 天
-
+ {reportData.heatmap && (
+
)}
-
+
+
- {yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? (
-
- 第一段对话
-
- {reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
-
-
-
久别重逢
-
{formatFullDate(yearFirstChat.createTime).split(' ')[0]}
-
- {renderMessageList(yearFirstChat.firstThreeMessages)}
-
-
-
- ) : null}
-
- {reportData.heatmap && (
-
- 聊天习惯
- 作息规律
- {mostActive && (
-
- 在 {mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00 最活跃({mostActive.value}条)
-
- )}
-
-
- )}
-
- {reportData.initiative && (
-
- 主动性
- 情感的天平
-
+ {/* S3: MUTUAL INITIATIVE */}
+
+
+
+
情感的天平
+
+ {reportData.initiative && (
+
+
- {reportData.selfAvatarUrl ?

: '我'}
+ {reportData.selfAvatarUrl ?

: reportData.selfName.substring(0, 1) || 'Me'}
-
{reportData.initiative.initiated}次
-
{initiatedPercent.toFixed(1)}%
+
{reportData.initiative.initiated}
+
{initRate}%
+
-
+
+
- {reportData.friendAvatarUrl ?

: reportData.friendName.substring(0, 1)}
-
-
{reportData.initiative.received}次
-
{receivedPercent.toFixed(1)}%
-
-
-
- {reportData.initiative.initiated > reportData.initiative.received ? '每一个话题都是你对TA的在意' : 'TA总是那个率先打破沉默的人'}
-
-
-
- )}
-
- {reportData.response && (
-
- 回应速度
- 你说,我在
-
-
-
-
-
-
-
-
最快回复
-
{reportData.response.fastest}秒
-
-
-
-
平均回复
-
{Math.round(reportData.response.avg / 60)}分
-
-
-
-
最慢回复
-
- {reportData.response.slowest > 3600
- ? (reportData.response.slowest / 3600).toFixed(1)
- : Math.round(reportData.response.slowest / 60)}
-
{reportData.response.slowest > 3600 ? '时' : '分'}
+ {reportData.friendAvatarUrl ?

: reportData.friendName.substring(0, 1)}
+
{reportData.initiative.received}
+
{(100 - parseFloat(initRate as any)).toFixed(1)}%
-
- {`在 ${reportData.response.count} 次互动中,平均约 ${responseAvgMinutes} 分钟,最快 ${reportData.response.fastest} 秒。`}
-
-
- )}
-
- {reportData.streak && (
-
- 聊天火花
- 最长连续聊天
-
-
-
-
-
-
-
-
-
-
-
-
-
{reportData.streak.days}
-
DAYS
-
-
-
-
-
-
-
{reportData.streak.startDate}
-
-
-
-
{reportData.streak.endDate}
-
-
-
-
-
- )}
-
-
- 常用语
- {yearTitle}常用语
-
-
-
-
-
-
-
- {activeWordCloudTab === 'shared' &&
}
- {activeWordCloudTab === 'my' && (
- reportData.myExclusivePhrases && reportData.myExclusivePhrases.length > 0 ? (
-
- ) : (
-
暂无专属词汇
- )
- )}
- {activeWordCloudTab === 'friend' && (
- reportData.friendExclusivePhrases && reportData.friendExclusivePhrases.length > 0 ? (
-
- ) : (
-
暂无专属词汇
- )
- )}
-
-
-
-
- 年度统计
- {yearTitle}数据概览
-
- {statItems.slice(0, 2).map((item) => (
-
-
{item.value.toLocaleString()}
-
{item.label}
-
- ))}
-
-
- {statItems.slice(2).map((item) => (
-
-
{item.value.toLocaleString()}
-
{item.label}
-
- ))}
-
-
-
-
-
我常用的表情
- {myEmojiUrl ? (
-

{
- (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
- (e.target as HTMLImageElement).style.display = 'none';
- }} />
- ) : null}
-
- {stats.myTopEmojiMd5 || '暂无'}
-
-
{stats.myTopEmojiCount ? `${stats.myTopEmojiCount}次` : '暂无统计'}
-
-
-
{reportData.friendName}常用的表情
- {friendEmojiUrl ? (
-

{
- (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
- (e.target as HTMLImageElement).style.display = 'none';
- }} />
- ) : null}
-
- {stats.friendTopEmojiMd5 || '暂无'}
-
-
{stats.friendTopEmojiCount ? `${stats.friendTopEmojiCount}次` : '暂无统计'}
-
-
-
-
-
- 尾声
- 谢谢你一直在
- 愿我们继续把故事写下去
-
+ )}
+
+
+ {/* S4: ECHOES */}
+
+
+
+
回应的速度
+
+
+
+
+ AVG RESPONSE
+ m
+
+
+
+
+
+
+
+
+ {/* S5: THE SPARK */}
+
+
+
+
连绵不绝的火花
+
+ {showSpark && reportData.streak ? (
+
+
+
+
DAYS STREAK
+
+ {reportData.streak.startDate}
+
+ {reportData.streak.endDate}
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {/* S6: LEXICON */}
+
+
+
+
专属词典
+
+
+
+
+
+
+
+
+ {currentWordList && currentWordList.length > 0 ? (
+
+ ) : (
+
没有足够的词汇数据
+ )}
+
+
+
+
+
+ {/* S7: VOLUME */}
+
+
+ {/* S8: EXTRACTION */}
+
+
+
+
ARCHIVED
+
+
+
+
+
+
+
+
+
+
+ {/* 底部导航点 */}
+
+ {Array.from({ length: TOTAL_SCENES }).map((_, i) => (
+
goToScene(i)}
+ />
+ ))}
)