双人年度报告后端实现

This commit is contained in:
xuncha
2026-02-01 01:13:17 +08:00
parent 53f0e299e0
commit 5413d7e2c8
14 changed files with 1572 additions and 19 deletions

View File

@@ -39,10 +39,11 @@ function AnnualReportPage() {
}
const handleGenerateReport = async () => {
if (!selectedYear || selectedYear === 'all') return
if (selectedYear === null) return
setIsGenerating(true)
try {
navigate(`/annual-report/view?year=${selectedYear}`)
const yearParam = selectedYear === 'all' ? 0 : selectedYear
navigate(`/annual-report/view?year=${yearParam}`)
} catch (e) {
console.error('生成报告失败:', e)
} finally {
@@ -50,6 +51,12 @@ function AnnualReportPage() {
}
}
const handleGenerateDualReport = () => {
if (selectedPairYear === null) return
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
navigate(`/dual-report?year=${yearParam}`)
}
if (isLoading) {
return (
<div className="annual-report-page">
@@ -111,7 +118,7 @@ function AnnualReportPage() {
<button
className="generate-btn"
onClick={handleGenerateReport}
disabled={!selectedYear || selectedYear === 'all' || isGenerating}
disabled={!selectedYear || isGenerating}
>
{isGenerating ? (
<>
@@ -125,9 +132,6 @@ function AnnualReportPage() {
</>
)}
</button>
{selectedYear === 'all' ? (
<p className="section-hint"></p>
) : null}
</section>
<section className="report-section">
@@ -155,11 +159,15 @@ function AnnualReportPage() {
))}
</div>
<button className="generate-btn secondary" disabled>
<button
className="generate-btn secondary"
onClick={handleGenerateDualReport}
disabled={!selectedPairYear}
>
<Users size={20} />
<span></span>
</button>
<p className="section-hint"></p>
<p className="section-hint"></p>
</section>
</div>
</div>

View File

@@ -282,7 +282,8 @@ function AnnualReportWindow() {
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const yearParam = params.get('year')
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
const parsedYear = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear()
const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear
generateReport(year)
}, [])
@@ -337,6 +338,11 @@ function AnnualReportWindow() {
return `${Math.round(seconds / 3600)}小时`
}
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
if (value === 0) return '全部时间'
return withSuffix ? `${value}` : `${value}`
}
// 获取可用的板块列表
const getAvailableSections = (): SectionInfo[] => {
if (!reportData) return []
@@ -595,7 +601,8 @@ function AnnualReportWindow() {
const dataUrl = outputCanvas.toDataURL('image/png')
const link = document.createElement('a')
link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png`
link.href = dataUrl
document.body.appendChild(link)
link.click()
@@ -658,11 +665,12 @@ function AnnualReportWindow() {
}
setExportProgress('正在写入文件...')
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
const exportResult = await window.electronAPI.annualReport.exportImages({
baseDir: dirResult.filePaths[0],
folderName: `${reportData?.year}年度报告_分模块`,
folderName: `${yearFilePrefix}年度报告_分模块`,
images: exportedImages.map((img) => ({
name: `${reportData?.year}年度报告_${img.name}.png`,
name: `${yearFilePrefix}年度报告_${img.name}.png`,
dataUrl: img.data
}))
})
@@ -737,6 +745,10 @@ function AnnualReportWindow() {
const topFriend = coreFriends[0]
const mostActive = getMostActiveTime(activityHeatmap.data)
const socialStoryName = topFriend?.displayName || '好友'
const yearTitle = formatYearLabel(year, true)
const yearTitleShort = formatYearLabel(year, false)
const monthlyTitle = year === 0 ? '全部时间月度好友' : `${year}年月度好友`
const phrasesTitle = year === 0 ? '你在全部时间的常用语' : `你在${year}年的年度常用语`
return (
<div className="annual-report-window">
@@ -827,7 +839,7 @@ function AnnualReportWindow() {
{/* 封面 */}
<section className="section" ref={sectionRefs.cover}>
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
<h1 className="hero-title">{year}<br /></h1>
<h1 className="hero-title">{yearTitle}<br /></h1>
<hr className="divider" />
<p className="hero-desc"><br /></p>
</section>
@@ -869,7 +881,7 @@ function AnnualReportWindow() {
{/* 月度好友 */}
<section className="section" ref={sectionRefs.monthlyFriends}>
<div className="label-text"></div>
<h2 className="hero-title">{year}</h2>
<h2 className="hero-title">{monthlyTitle}</h2>
<p className="hero-desc">12</p>
<div className="monthly-orbit">
{monthlyTopFriends.map((m, i) => (
@@ -1016,7 +1028,7 @@ function AnnualReportWindow() {
{topPhrases && topPhrases.length > 0 && (
<section className="section" ref={sectionRefs.topPhrases}>
<div className="label-text"></div>
<h2 className="hero-title">{year}</h2>
<h2 className="hero-title">{phrasesTitle}</h2>
<p className="hero-desc">
<br />
@@ -1085,7 +1097,7 @@ function AnnualReportWindow() {
<br />
<br />
</p>
<div className="ending-year">{year}</div>
<div className="ending-year">{yearTitleShort}</div>
<div className="ending-brand">WEFLOW</div>
</section>
</div>

View File

@@ -0,0 +1,171 @@
.dual-report-page {
padding: 32px 28px;
color: var(--text-primary);
}
.dual-report-page.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 12px;
color: var(--text-tertiary);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
}
p {
margin: 8px 0 0;
color: var(--text-secondary);
}
}
.year-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 20px;
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 14px;
}
}
.ranking-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.ranking-item {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 14px;
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
}
.rank-badge {
width: 28px;
height: 28px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--border-color);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
&.top {
background: color-mix(in srgb, var(--primary) 18%, transparent);
color: var(--primary);
}
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
background: var(--primary-light);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
font-weight: 700;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.info {
display: flex;
flex-direction: column;
gap: 4px;
.name {
font-weight: 600;
}
.sub {
font-size: 12px;
color: var(--text-tertiary);
}
}
.meta {
text-align: right;
font-size: 12px;
color: var(--text-tertiary);
.count {
font-weight: 600;
color: var(--text-primary);
}
}
.empty {
text-align: center;
color: var(--text-tertiary);
padding: 40px 0;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,138 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2, Search, Users } from 'lucide-react'
import './DualReportPage.scss'
interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
messageCount: number
sentCount: number
receivedCount: number
lastMessageTime?: number | null
}
function DualReportPage() {
const navigate = useNavigate()
const [year, setYear] = useState<number>(0)
const [rankings, setRankings] = useState<ContactRanking[]>([])
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [keyword, setKeyword] = useState('')
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const yearParam = params.get('year')
const parsedYear = yearParam ? parseInt(yearParam, 10) : 0
setYear(Number.isNaN(parsedYear) ? 0 : parsedYear)
}, [])
useEffect(() => {
loadRankings()
}, [])
const loadRankings = async () => {
setIsLoading(true)
setLoadError(null)
try {
const result = await window.electronAPI.analytics.getContactRankings(200)
if (result.success && result.data) {
setRankings(result.data)
} else {
setLoadError(result.error || '加载好友列表失败')
}
} catch (e) {
setLoadError(String(e))
} finally {
setIsLoading(false)
}
}
const yearLabel = year === 0 ? '全部时间' : `${year}`
const filteredRankings = useMemo(() => {
if (!keyword.trim()) return rankings
const q = keyword.trim().toLowerCase()
return rankings.filter((item) => {
return item.displayName.toLowerCase().includes(q) || item.username.toLowerCase().includes(q)
})
}, [rankings, keyword])
const handleSelect = (username: string) => {
const yearParam = year === 0 ? 0 : year
navigate(`/dual-report/view?username=${encodeURIComponent(username)}&year=${yearParam}`)
}
if (isLoading) {
return (
<div className="dual-report-page loading">
<Loader2 size={32} className="spin" />
<p>...</p>
</div>
)
}
if (loadError) {
return (
<div className="dual-report-page loading">
<p>{loadError}</p>
</div>
)
}
return (
<div className="dual-report-page">
<div className="page-header">
<div>
<h1></h1>
<p></p>
</div>
<div className="year-badge">
<Users size={14} />
<span>{yearLabel}</span>
</div>
</div>
<div className="search-bar">
<Search size={16} />
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索好友(昵称/备注/wxid"
/>
</div>
<div className="ranking-list">
{filteredRankings.map((item, index) => (
<button
key={item.username}
className="ranking-item"
onClick={() => handleSelect(item.username)}
>
<span className={`rank-badge ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
<div className="avatar">
{item.avatarUrl
? <img src={item.avatarUrl} alt={item.displayName} />
: <span>{item.displayName.slice(0, 1) || '?'}</span>
}
</div>
<div className="info">
<div className="name">{item.displayName}</div>
<div className="sub">{item.username}</div>
</div>
<div className="meta">
<div className="count">{item.messageCount.toLocaleString()} </div>
<div className="hint"></div>
</div>
</button>
))}
{filteredRankings.length === 0 ? (
<div className="empty"></div>
) : null}
</div>
</div>
)
}
export default DualReportPage

View File

@@ -0,0 +1,220 @@
.dual-report-window {
color: var(--text-primary);
padding: 32px 24px 60px;
background: var(--bg-primary);
}
.dual-report-window.loading,
.dual-report-window.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 12px;
color: var(--text-tertiary);
}
.dual-section {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 24px;
margin: 16px auto;
max-width: 900px;
}
.dual-section.cover {
text-align: center;
background: linear-gradient(135deg, color-mix(in srgb, var(--primary) 10%, transparent) 0%, var(--card-bg) 100%);
.label {
font-size: 12px;
letter-spacing: 2px;
color: var(--text-tertiary);
margin-bottom: 12px;
}
h1 {
margin: 0 0 12px;
font-size: 36px;
}
p {
margin: 0;
color: var(--text-secondary);
}
}
.section-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 16px;
}
.info-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
gap: 16px;
font-size: 14px;
}
.info-label {
color: var(--text-tertiary);
}
.info-value {
color: var(--text-primary);
font-weight: 600;
}
.info-empty {
color: var(--text-tertiary);
}
.message-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
}
.message-item {
padding: 10px 12px;
border-radius: 12px;
background: color-mix(in srgb, var(--primary) 6%, transparent);
&.received {
background: color-mix(in srgb, var(--border-color) 35%, transparent);
}
}
.message-meta {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.message-content {
font-size: 14px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
background: color-mix(in srgb, var(--primary) 6%, transparent);
border-radius: 12px;
padding: 14px;
text-align: center;
.stat-value {
font-size: 20px;
font-weight: 700;
}
.stat-label {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
}
.emoji-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.emoji-card {
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
justify-content: center;
img {
width: 64px;
height: 64px;
object-fit: contain;
}
}
.emoji-title {
font-size: 12px;
color: var(--text-tertiary);
}
.emoji-placeholder {
font-size: 12px;
color: var(--text-secondary);
word-break: break-all;
text-align: center;
}
.word-cloud-wrapper {
position: relative;
width: 100%;
padding-top: 80%;
background: color-mix(in srgb, var(--primary) 4%, transparent);
border-radius: 18px;
overflow: hidden;
}
.word-cloud-inner {
position: absolute;
inset: 0;
}
.word-tag {
position: absolute;
font-weight: 600;
color: var(--text-primary);
transform: translate(-50%, -50%);
opacity: 0;
animation: fadeUp 0.8s ease forwards;
}
.word-cloud-empty {
color: var(--text-tertiary);
font-size: 14px;
text-align: center;
padding: 40px 0;
}
.progress {
font-size: 20px;
font-weight: 700;
}
.stage {
font-size: 12px;
color: var(--text-tertiary);
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes fadeUp {
from { opacity: 0; transform: translate(-50%, -50%) translateY(10px); }
to { opacity: var(--final-opacity, 1); transform: translate(-50%, -50%) translateY(0); }
}

View File

@@ -0,0 +1,366 @@
import { useEffect, useState, type CSSProperties } from 'react'
import { Loader2 } from 'lucide-react'
import './DualReportWindow.scss'
interface DualReportMessage {
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
}
interface DualReportData {
year: number
myName: string
friendUsername: string
friendName: string
firstChat: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
senderUsername?: string
} | null
thisYearFirstChat?: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
friendName: string
firstThreeMessages: DualReportMessage[]
} | null
yearlyStats: {
totalMessages: number
totalWords: number
imageCount: number
voiceCount: number
emojiCount: number
myTopEmojiMd5?: string
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
}
wordCloud: {
words: Array<{ phrase: string; count: number }>
totalWords: number
totalMessages: number
}
}
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
if (!words || words.length === 0) {
return <div className="word-cloud-empty"></div>
}
const maxCount = words.length > 0 ? words[0].count : 1
const topWords = words.slice(0, 32)
const baseSize = 520
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
const placedItems: { x: number; y: number; w: number; h: number }[] = []
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
const halfW = w / 2
const halfH = h / 2
const dx = x - 50
const dy = y - 50
const dist = Math.sqrt(dx * dx + dy * dy)
const maxR = 49 - Math.max(halfW, halfH)
if (dist > maxR) return false
const pad = 1.8
for (const p of placedItems) {
if ((x - halfW - pad) < (p.x + p.w / 2) &&
(x + halfW + pad) > (p.x - p.w / 2) &&
(y - halfH - pad) < (p.y + p.h / 2) &&
(y + halfH + pad) > (p.y - p.h / 2)) {
return false
}
}
return true
}
const wordItems = topWords.map((item, i) => {
const ratio = item.count / maxCount
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
const delay = (i * 0.04).toFixed(2)
const charCount = Math.max(1, item.phrase.length)
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
const widthPx = fontSize * (charCount * widthFactor)
const heightPx = fontSize * 1.1
const widthPct = (widthPx / baseSize) * 100
const heightPct = (heightPx / baseSize) * 100
let x = 50, y = 50
let placedOk = false
const tries = i === 0 ? 1 : 420
for (let t = 0; t < tries; t++) {
if (i === 0) {
x = 50
y = 50
} else {
const idx = i + t * 0.28
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
x = 50 + radius * Math.cos(angle)
y = 50 + radius * Math.sin(angle)
}
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk) return null
placedItems.push({ x, y, w: widthPct, h: heightPct })
return (
<span
key={i}
className="word-tag"
style={{
'--final-opacity': opacity,
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
fontSize: `${fontSize}px`,
animationDelay: `${delay}s`,
} as CSSProperties}
title={`${item.phrase} (出现 ${item.count} 次)`}
>
{item.phrase}
</span>
)
}).filter(Boolean)
return (
<div className="word-cloud-wrapper">
<div className="word-cloud-inner">
{wordItems}
</div>
</div>
)
}
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)
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) {
setReportData(result.data)
setIsLoading(false)
} else {
setError(result.error || '生成报告失败')
setIsLoading(false)
}
} catch (e) {
removeProgressListener?.()
setError(String(e))
setIsLoading(false)
}
}
useEffect(() => {
const loadEmojis = async () => {
if (!reportData) return
const stats = reportData.yearlyStats
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="dual-report-window loading">
<Loader2 size={36} className="spin" />
<div className="progress">{loadingProgress}%</div>
<div className="stage">{loadingStage}</div>
</div>
)
}
if (error) {
return (
<div className="dual-report-window error">
<p>{error}</p>
</div>
)
}
if (!reportData) {
return (
<div className="dual-report-window error">
<p></p>
</div>
)
}
const yearTitle = reportData.year === 0 ? '全部时间' : `${reportData.year}`
const firstChat = reportData.firstChat
const daysSince = firstChat
? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000))
: null
const thisYearFirstChat = reportData.thisYearFirstChat
const stats = reportData.yearlyStats
return (
<div className="dual-report-window">
<section className="dual-section cover">
<div className="label">DUAL REPORT</div>
<h1>{reportData.myName} &amp; {reportData.friendName}</h1>
<p></p>
</section>
<section className="dual-section">
<div className="section-title"></div>
{firstChat ? (
<div className="info-card">
<div className="info-row">
<span className="info-label"></span>
<span className="info-value">{firstChat.createTimeStr}</span>
</div>
<div className="info-row">
<span className="info-label"></span>
<span className="info-value">{daysSince} </span>
</div>
<div className="info-row">
<span className="info-label"></span>
<span className="info-value">{firstChat.content || '(空)'}</span>
</div>
</div>
) : (
<div className="info-empty"></div>
)}
</section>
{thisYearFirstChat ? (
<section className="dual-section">
<div className="section-title"></div>
<div className="info-card">
<div className="info-row">
<span className="info-label"></span>
<span className="info-value">{thisYearFirstChat.createTimeStr}</span>
</div>
<div className="info-row">
<span className="info-label"></span>
<span className="info-value">{thisYearFirstChat.isSentByMe ? reportData.myName : reportData.friendName}</span>
</div>
<div className="message-list">
{thisYearFirstChat.firstThreeMessages.map((msg, idx) => (
<div key={idx} className={`message-item ${msg.isSentByMe ? 'sent' : 'received'}`}>
<div className="message-meta">{msg.isSentByMe ? reportData.myName : reportData.friendName} · {msg.createTimeStr}</div>
<div className="message-content">{msg.content || '(空)'}</div>
</div>
))}
</div>
</div>
</section>
) : null}
<section className="dual-section">
<div className="section-title">{yearTitle}</div>
<WordCloud words={reportData.wordCloud.words} />
</section>
<section className="dual-section">
<div className="section-title">{yearTitle}</div>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-value">{stats.totalMessages.toLocaleString()}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.totalWords.toLocaleString()}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.imageCount.toLocaleString()}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.voiceCount.toLocaleString()}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.emojiCount.toLocaleString()}</div>
<div className="stat-label"></div>
</div>
</div>
<div className="emoji-row">
<div className="emoji-card">
<div className="emoji-title"></div>
{myEmojiUrl ? (
<img src={myEmojiUrl} alt="my-emoji" />
) : (
<div className="emoji-placeholder">{stats.myTopEmojiMd5 || '暂无'}</div>
)}
</div>
<div className="emoji-card">
<div className="emoji-title">{reportData.friendName}</div>
{friendEmojiUrl ? (
<img src={friendEmojiUrl} alt="friend-emoji" />
) : (
<div className="emoji-placeholder">{stats.friendTopEmojiMd5 || '暂无'}</div>
)}
</div>
</div>
</section>
</div>
)
}
export default DualReportWindow