diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts index f62f947..e395412 100644 --- a/electron/services/dualReportService.ts +++ b/electron/services/dualReportService.ts @@ -45,6 +45,11 @@ export interface DualReportData { } | null stats: DualReportStats topPhrases: Array<{ phrase: string; count: number }> + heatmap?: number[][] + initiative?: { initiated: number; received: number } + response?: { avg: number; fastest: number; count: number } + monthly?: Record + streak?: { days: number; startDate: string; endDate: string } } class DualReportService { @@ -75,7 +80,7 @@ class DualReportService { } const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const cleaned = suffixMatch ? suffixMatch[1] : trimmed - + return cleaned } @@ -327,122 +332,72 @@ class DualReportService { } this.reportProgress('统计聊天数据...', 30, onProgress) + + const statsResult = await wcdbService.getDualReportStats(friendUsername, startTime, endTime) + if (!statsResult.success || !statsResult.data) { + return { success: false, error: statsResult.error || '获取双人报告统计失败' } + } + + const cppData = statsResult.data + const counts = cppData.counts || {} + const stats: DualReportStats = { - totalMessages: 0, - totalWords: 0, - imageCount: 0, - voiceCount: 0, - emojiCount: 0 - } - const wordCountMap = new Map() - const myEmojiCounts = new Map() - const friendEmojiCounts = new Map() - const myEmojiUrlMap = new Map() - const friendEmojiUrlMap = new Map() - - const messageCountResult = await wcdbService.getMessageCount(friendUsername) - const totalForProgress = messageCountResult.success && messageCountResult.count - ? messageCountResult.count - : 0 - let processed = 0 - let lastProgressAt = 0 - - const cursorResult = await wcdbService.openMessageCursor(friendUsername, 1000, true, startTime, endTime) - if (!cursorResult.success || !cursorResult.cursor) { - return { success: false, error: cursorResult.error || '打开消息游标失败' } + totalMessages: counts.total || 0, + totalWords: counts.words || 0, + imageCount: counts.image || 0, + voiceCount: counts.voice || 0, + emojiCount: counts.emoji || 0 } - try { - let hasMore = true - while (hasMore) { - const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) - if (!batch.success || !batch.rows) break - for (const row of batch.rows) { - const localType = parseInt(row.local_type || row.type || '1', 10) - const isSent = this.resolveIsSent(row, rawWxid, cleanedWxid) - stats.totalMessages += 1 + // Process Emojis to find top for me and friend + let myTopEmojiMd5: string | undefined + let myTopEmojiUrl: string | undefined + let myTopCount = -1 - if (localType === 3) stats.imageCount += 1 - if (localType === 34) stats.voiceCount += 1 - if (localType === 47) { - stats.emojiCount += 1 - const content = this.decodeMessageContent(row.message_content, row.compress_content) + let friendTopEmojiMd5: string | undefined + let friendTopEmojiUrl: string | undefined + let friendTopCount = -1 + + if (cppData.emojis && Array.isArray(cppData.emojis)) { + for (const item of cppData.emojis) { + const rawContent = item.content || '' + const isMe = rawContent.startsWith('1:') + const content = rawContent.substring(2) // Remove "1:" or "0:" prefix + const count = item.count || 0 + + if (isMe) { + if (count > myTopCount) { const md5 = this.extractEmojiMd5(content) - const url = this.extractEmojiUrl(content) if (md5) { - const targetMap = isSent ? myEmojiCounts : friendEmojiCounts - targetMap.set(md5, (targetMap.get(md5) || 0) + 1) - if (url) { - const urlMap = isSent ? myEmojiUrlMap : friendEmojiUrlMap - if (!urlMap.has(md5)) urlMap.set(md5, url) - } + myTopCount = count + myTopEmojiMd5 = md5 + myTopEmojiUrl = this.extractEmojiUrl(content) } } - - if (localType === 1 || localType === 244813135921) { - const content = this.decodeMessageContent(row.message_content, row.compress_content) - const text = String(content || '').trim() - if (text.length > 0) { - stats.totalWords += text.replace(/\s+/g, '').length - const normalized = text.replace(/\s+/g, ' ').trim() - if (normalized.length >= 2 && - normalized.length <= 50 && - !normalized.includes('http') && - !normalized.includes('<') && - !normalized.startsWith('[') && - !normalized.startsWith(' friendTopCount) { + const md5 = this.extractEmojiMd5(content) + if (md5) { + friendTopCount = count + friendTopEmojiMd5 = md5 + friendTopEmojiUrl = this.extractEmojiUrl(content) } } - - if (totalForProgress > 0) { - processed++ - } - } - hasMore = batch.hasMore === true - - const now = Date.now() - if (now - lastProgressAt > 200) { - if (totalForProgress > 0) { - const ratio = Math.min(1, processed / totalForProgress) - const progress = 30 + Math.floor(ratio * 50) - this.reportProgress('统计聊天数据...', progress, onProgress) - } - lastProgressAt = now } } - } finally { - await wcdbService.closeMessageCursor(cursorResult.cursor) } - const pickTop = (map: Map): string | undefined => { - let topKey: string | undefined - let topCount = -1 - for (const [key, count] of map.entries()) { - if (count > topCount) { - topCount = count - topKey = key - } - } - return topKey - } - - const myTopEmojiMd5 = pickTop(myEmojiCounts) - const friendTopEmojiMd5 = pickTop(friendEmojiCounts) - stats.myTopEmojiMd5 = myTopEmojiMd5 + stats.myTopEmojiUrl = myTopEmojiUrl stats.friendTopEmojiMd5 = friendTopEmojiMd5 - stats.myTopEmojiUrl = myTopEmojiMd5 ? myEmojiUrlMap.get(myTopEmojiMd5) : undefined - stats.friendTopEmojiUrl = friendTopEmojiMd5 ? friendEmojiUrlMap.get(friendTopEmojiMd5) : undefined + stats.friendTopEmojiUrl = friendTopEmojiUrl - this.reportProgress('生成常用语词云...', 85, onProgress) - const topPhrases = Array.from(wordCountMap.entries()) - .filter(([_, count]) => count >= 2) - .sort((a, b) => b[1] - a[1]) - .slice(0, 50) - .map(([phrase, count]) => ({ phrase, count })) + const topPhrases = (cppData.phrases || []).map((p: any) => ({ + phrase: p.phrase, + count: p.count + })) + // Attach extra stats to the data object (needs interface update if strictly typed, but data is flexible) const reportData: DualReportData = { year: reportYear, selfName: myName, @@ -452,8 +407,14 @@ class DualReportService { firstChatMessages, yearFirstChat, stats, - topPhrases - } + topPhrases, + // Append new C++ stats + heatmap: cppData.heatmap, + initiative: cppData.initiative, + response: cppData.response, + monthly: cppData.monthly, + streak: cppData.streak + } as any // Use as any to bypass strict type check for new fields, or update interface this.reportProgress('双人报告生成完成', 100, onProgress) return { success: true, data: reportData } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index fe4f18a..1e3c79a 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -44,6 +44,7 @@ export class WcdbCore { private wcdbGetAvailableYears: any = null private wcdbGetAnnualReportStats: any = null private wcdbGetAnnualReportExtras: any = null + private wcdbGetDualReportStats: any = null private wcdbGetGroupStats: any = null private wcdbOpenMessageCursor: any = null private wcdbOpenMessageCursorLite: any = null @@ -456,6 +457,13 @@ export class WcdbCore { this.wcdbGetAnnualReportExtras = null } + // wcdb_status wcdb_get_dual_report_stats(wcdb_handle handle, const char* session_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) + try { + this.wcdbGetDualReportStats = this.lib.func('int32 wcdb_get_dual_report_stats(int64 handle, const char* sessionId, int32 begin, int32 end, _Out_ void** outJson)') + } catch { + this.wcdbGetDualReportStats = null + } + // wcdb_status wcdb_get_logs(char** out_json) try { this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)') @@ -1710,4 +1718,26 @@ export class WcdbCore { return { success: false, error: String(e) } } } + async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetDualReportStats) { + return { success: false, error: '未支持双人报告统计' } + } + try { + const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp) + const outPtr = [null as any] + const result = this.wcdbGetDualReportStats(this.handle, sessionId, begin, end, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取双人报告统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析双人报告统计失败' } + const data = JSON.parse(jsonStr) + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } } \ No newline at end of file diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index c8ca667..092db01 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -315,6 +315,13 @@ export class WcdbService { return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd }) } + /** + * 获取双人报告统计数据 + */ + async getDualReportStats(sessionId: string, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getDualReportStats', { sessionId, beginTimestamp, endTimestamp }) + } + /** * 获取群聊统计 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index cf3e89a..6d01ef4 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -96,6 +96,9 @@ if (parentPort) { case 'getAnnualReportExtras': result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd) break + case 'getDualReportStats': + result = await core.getDualReportStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp) + break case 'getGroupStats': result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp) break diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index cf0b4ed..41aced9 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/components/ReportComponents.scss b/src/components/ReportComponents.scss new file mode 100644 index 0000000..56c4e0e --- /dev/null +++ b/src/components/ReportComponents.scss @@ -0,0 +1,142 @@ +// Shared styles for Report components (Heatmap, WordCloud) + +// --- Heatmap --- +.heatmap-wrapper { + margin-top: 24px; + width: 100%; +} + +.heatmap-header { + display: grid; + grid-template-columns: 28px 1fr; + gap: 3px; + margin-bottom: 6px; + color: var(--ar-text-sub); // Assumes --ar-text-sub is defined in parent context or globally + font-size: 10px; +} + +.time-labels { + display: grid; + grid-template-columns: repeat(24, 1fr); + gap: 3px; + + span { + text-align: center; + } +} + +.heatmap { + display: grid; + grid-template-columns: 28px 1fr; + gap: 3px; +} + +.heatmap-week-col { + display: grid; + grid-template-rows: repeat(7, 1fr); + gap: 3px; + font-size: 10px; + color: var(--ar-text-sub); +} + +.week-label { + display: flex; + align-items: center; +} + +.heatmap-grid { + display: grid; + grid-template-columns: repeat(24, 1fr); + gap: 3px; +} + +.h-cell { + aspect-ratio: 1; + border-radius: 2px; + min-height: 10px; + transition: transform 0.15s; + + &:hover { + transform: scale(1.3); + z-index: 1; + } +} + + +// --- Word Cloud --- +.word-cloud-wrapper { + margin: 24px auto 0; + padding: 0; + max-width: 520px; + display: flex; + justify-content: center; + --cloud-scale: clamp(0.72, 80vw / 520, 1); +} + +.word-cloud-inner { + position: relative; + width: 520px; + height: 520px; + margin: 0; + border-radius: 50%; + transform: scale(var(--cloud-scale)); + transform-origin: center; + + &::before { + content: ""; + position: absolute; + inset: -6%; + background: + radial-gradient(circle at 35% 45%, color-mix(in srgb, var(--primary, #07C160) 12%, transparent), transparent 55%), + radial-gradient(circle at 65% 50%, color-mix(in srgb, var(--accent, #F2AA00) 10%, transparent), transparent 58%), + radial-gradient(circle at 50% 65%, var(--bg-tertiary, rgba(0, 0, 0, 0.04)), transparent 60%); + filter: blur(18px); + border-radius: 50%; + pointer-events: none; + z-index: 0; + } +} + +.word-tag { + display: inline-block; + padding: 0; + background: transparent; + border-radius: 0; + border: none; + line-height: 1.2; + white-space: nowrap; + transition: transform 0.2s ease, color 0.2s ease; + cursor: default; + color: var(--ar-text-main); + font-weight: 600; + opacity: 0; + animation: wordPopIn 0.55s ease forwards; + position: absolute; + z-index: 1; + transform: translate(-50%, -50%) scale(0.8); + + &:hover { + transform: translate(-50%, -50%) scale(1.08); + color: var(--ar-primary); + z-index: 2; + } +} + +@keyframes wordPopIn { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.6); + } + + 100% { + opacity: var(--final-opacity, 1); + transform: translate(-50%, -50%) scale(1); + } +} + +.word-cloud-note { + margin-top: 24px; + font-size: 14px !important; + color: var(--ar-text-sub) !important; + text-align: center; +} \ No newline at end of file diff --git a/src/components/ReportHeatmap.tsx b/src/components/ReportHeatmap.tsx new file mode 100644 index 0000000..ef87390 --- /dev/null +++ b/src/components/ReportHeatmap.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import './ReportComponents.scss' + +interface ReportHeatmapProps { + data: number[][] +} + +const ReportHeatmap: React.FC = ({ data }) => { + if (!data || data.length === 0) return null + + const maxHeat = Math.max(...data.flat()) + const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] + + return ( +
+
+
+
+ {[0, 6, 12, 18].map(h => ( + {h} + ))} +
+
+
+
+ {weekLabels.map(w =>
{w}
)} +
+
+ {data.map((row, wi) => + row.map((val, hi) => { + const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1' + return ( +
+ ) + }) + )} +
+
+
+ ) +} + +export default ReportHeatmap diff --git a/src/components/ReportWordCloud.tsx b/src/components/ReportWordCloud.tsx new file mode 100644 index 0000000..031b913 --- /dev/null +++ b/src/components/ReportWordCloud.tsx @@ -0,0 +1,113 @@ +import React from 'react' +import './ReportComponents.scss' + +interface ReportWordCloudProps { + words: { phrase: string; count: number }[] +} + +const ReportWordCloud: React.FC = ({ words }) => { + if (!words || words.length === 0) return null + + 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 ( + + {item.phrase} + + ) + }).filter(Boolean) + + return ( +
+
+ {wordItems} +
+
+ ) +} + +export default ReportWordCloud diff --git a/src/pages/AnnualReportWindow.scss b/src/pages/AnnualReportWindow.scss index 9a8efec..e6f5632 100644 --- a/src/pages/AnnualReportWindow.scss +++ b/src/pages/AnnualReportWindow.scss @@ -43,7 +43,7 @@ // 背景装饰圆点 - 毛玻璃效果 .bg-decoration { - position: absolute; // Changed from fixed + position: absolute; inset: 0; pointer-events: none; z-index: 0; @@ -53,10 +53,10 @@ .deco-circle { position: absolute; border-radius: 50%; - background: rgba(0, 0, 0, 0.03); + background: color-mix(in srgb, var(--primary) 3%, transparent); backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px); - border: 1px solid rgba(0, 0, 0, 0.05); + border: 1px solid var(--border-color); &.c1 { width: 280px; @@ -243,6 +243,7 @@ } .exporting-snapshot { + .hero-title, .label-text, .hero-desc, @@ -1279,134 +1280,135 @@ color: var(--ar-text-sub) !important; text-align: center; } + // 曾经的好朋友 视觉效果 .lost-friend-visual { + display: flex; + align-items: center; + justify-content: center; + gap: 32px; + margin: 64px auto 48px; + position: relative; + max-width: 480px; + + .avatar-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + z-index: 2; + + .avatar-label { + font-size: 13px; + color: var(--ar-text-sub); + font-weight: 500; + opacity: 0.6; + } + + &.sender { + animation: fadeInRight 1s ease-out backwards; + } + + &.receiver { + animation: fadeInLeft 1s ease-out backwards; + } + } + + .fading-line { + position: relative; + flex: 1; + height: 2px; + min-width: 120px; display: flex; align-items: center; justify-content: center; - gap: 32px; - margin: 64px auto 48px; - position: relative; - max-width: 480px; - .avatar-group { - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - z-index: 2; - - .avatar-label { - font-size: 13px; - color: var(--ar-text-sub); - font-weight: 500; - opacity: 0.6; - } - - &.sender { - animation: fadeInRight 1s ease-out backwards; - } - - &.receiver { - animation: fadeInLeft 1s ease-out backwards; - } + .line-path { + width: 100%; + height: 100%; + background: linear-gradient(to right, + var(--ar-primary) 0%, + rgba(var(--ar-primary-rgb), 0.4) 50%, + rgba(var(--ar-primary-rgb), 0.05) 100%); + border-radius: 2px; } - .fading-line { - position: relative; - flex: 1; - height: 2px; - min-width: 120px; - display: flex; - align-items: center; - justify-content: center; - - .line-path { - width: 100%; - height: 100%; - background: linear-gradient(to right, - var(--ar-primary) 0%, - rgba(var(--ar-primary-rgb), 0.4) 50%, - rgba(var(--ar-primary-rgb), 0.05) 100%); - border-radius: 2px; - } - - .line-glow { - position: absolute; - inset: -4px 0; - background: linear-gradient(to right, - rgba(var(--ar-primary-rgb), 0.2) 0%, - transparent 100%); - filter: blur(8px); - pointer-events: none; - } - - .flow-particle { - position: absolute; - width: 40px; - height: 2px; - background: linear-gradient(to right, transparent, var(--ar-primary), transparent); - border-radius: 2px; - opacity: 0; - animation: flowAcross 4s infinite linear; - } + .line-glow { + position: absolute; + inset: -4px 0; + background: linear-gradient(to right, + rgba(var(--ar-primary-rgb), 0.2) 0%, + transparent 100%); + filter: blur(8px); + pointer-events: none; } + + .flow-particle { + position: absolute; + width: 40px; + height: 2px; + background: linear-gradient(to right, transparent, var(--ar-primary), transparent); + border-radius: 2px; + opacity: 0; + animation: flowAcross 4s infinite linear; + } + } } .hero-desc.fading { - opacity: 0.7; - font-style: italic; - font-size: 16px; - margin-top: 32px; - line-height: 1.8; - letter-spacing: 0.05em; - animation: fadeIn 1.5s ease-out 0.5s backwards; + opacity: 0.7; + font-style: italic; + font-size: 16px; + margin-top: 32px; + line-height: 1.8; + letter-spacing: 0.05em; + animation: fadeIn 1.5s ease-out 0.5s backwards; } @keyframes flowAcross { - 0% { - left: -20%; - opacity: 0; - } + 0% { + left: -20%; + opacity: 0; + } - 10% { - opacity: 0.8; - } + 10% { + opacity: 0.8; + } - 50% { - opacity: 0.4; - } + 50% { + opacity: 0.4; + } - 90% { - opacity: 0.1; - } + 90% { + opacity: 0.1; + } - 100% { - left: 120%; - opacity: 0; - } + 100% { + left: 120%; + opacity: 0; + } } @keyframes fadeInRight { - from { - opacity: 0; - transform: translateX(-20px); - } + from { + opacity: 0; + transform: translateX(-20px); + } - to { - opacity: 1; - transform: translateX(0); - } + to { + opacity: 1; + transform: translateX(0); + } } @keyframes fadeInLeft { - from { - opacity: 0; - transform: translateX(20px); - } + from { + opacity: 0; + transform: translateX(20px); + } - to { - opacity: 1; - transform: translateX(0); - } -} + to { + opacity: 1; + transform: translateX(0); + } +} \ No newline at end of file diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index ed1bdcd..344393b 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -109,148 +109,8 @@ const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?: ) } -// 热力图组件 -const Heatmap = ({ data }: { data: number[][] }) => { - const maxHeat = Math.max(...data.flat()) - const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] - - return ( -
-
-
-
- {[0, 6, 12, 18].map(h => ( - {h} - ))} -
-
-
-
- {weekLabels.map(w =>
{w}
)} -
-
- {data.map((row, wi) => - row.map((val, hi) => { - const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1' - return ( -
- ) - }) - )} -
-
-
- ) -} - -// 词云组件 -const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => { - 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 ( - - {item.phrase} - - ) - }).filter(Boolean) - - return ( -
-
- {wordItems} -
-
- ) -} +import Heatmap from '../components/ReportHeatmap' +import WordCloud from '../components/ReportWordCloud' function AnnualReportWindow() { const [reportData, setReportData] = useState(null) diff --git a/src/pages/DualReportPage.scss b/src/pages/DualReportPage.scss index 293efef..802f0bb 100644 --- a/src/pages/DualReportPage.scss +++ b/src/pages/DualReportPage.scss @@ -132,26 +132,43 @@ .info { display: flex; flex-direction: column; - gap: 4px; + gap: 2px; + min-width: 0; // 允许 flex 子项缩小,配合 ellipsis .name { + font-size: 15px; font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .sub { font-size: 12px; - color: var(--text-tertiary); + color: var(--text-secondary); // 从 tertiary 改为 secondary 以增强对比度 + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.8; } } .meta { text-align: right; font-size: 12px; - color: var(--text-tertiary); + color: var(--text-secondary); // 改为 secondary + flex-shrink: 0; .count { - font-weight: 600; - color: var(--text-primary); + font-size: 14px; + font-weight: 700; + color: var(--primary); // 使用主题色更醒目 + margin-bottom: 2px; + } + + .hint { + opacity: 0.7; } } @@ -166,6 +183,11 @@ } @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 646b9ab..574068e 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -8,6 +8,7 @@ font-size: clamp(26px, 5vw, 44px); white-space: normal; } + .dual-names { font-size: clamp(24px, 4vw, 40px); font-weight: 700; @@ -81,13 +82,17 @@ } .first-chat-scene { - background: linear-gradient(180deg, #8f5b85 0%, #e38aa0 50%, #f6d0c8 100%); + background: linear-gradient(180deg, + color-mix(in srgb, var(--primary) 60%, #000) 0%, + color-mix(in srgb, var(--primary) 40%, #fff) 50%, + var(--ar-bg-color) 100%); border-radius: 20px; padding: 28px 24px 24px; - color: #fff; + color: var(--text-primary); position: relative; overflow: hidden; margin-top: 16px; + border: 1px solid var(--border-color); } .first-chat-scene::before { @@ -95,9 +100,9 @@ position: absolute; inset: 0; background-image: - radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.2), transparent 40%), - radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.15), transparent 35%), - radial-gradient(circle at 50% 80%, rgba(255, 255, 255, 0.12), transparent 45%); + radial-gradient(circle at 20% 20%, color-mix(in srgb, var(--primary) 20%, transparent), transparent 40%), + radial-gradient(circle at 80% 10%, color-mix(in srgb, var(--accent) 15%, transparent), transparent 35%), + radial-gradient(circle at 50% 80%, color-mix(in srgb, var(--primary) 12%, transparent), transparent 45%); opacity: 0.6; pointer-events: none; } @@ -107,6 +112,7 @@ font-weight: 700; text-align: center; margin-bottom: 8px; + color: var(--text-primary); } .scene-subtitle { @@ -114,7 +120,8 @@ font-weight: 500; text-align: center; margin-bottom: 20px; - opacity: 0.95; + opacity: 0.9; + color: var(--text-secondary); } .scene-messages { @@ -137,32 +144,34 @@ width: 40px; height: 40px; border-radius: 12px; - background: rgba(255, 255, 255, 0.25); + background: var(--primary-light); display: flex; align-items: center; justify-content: center; font-weight: 700; - color: #fff; + color: var(--primary); } .scene-bubble { - background: rgba(255, 255, 255, 0.85); - color: #5a4d5e; + background: var(--bg-secondary); + color: var(--text-primary); padding: 10px 14px; border-radius: 14px; max-width: 60%; - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12); + box-shadow: var(--shadow-md); + border: 1px solid var(--border-color); } .scene-message.sent .scene-bubble { - background: rgba(255, 224, 168, 0.9); - color: #4a3a2f; + background: color-mix(in srgb, var(--primary) 15%, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color)); } .scene-meta { font-size: 11px; opacity: 0.7; margin-bottom: 4px; + color: var(--text-tertiary); } .scene-content { @@ -172,8 +181,8 @@ } .scene-message.sent .scene-avatar { - background: rgba(255, 224, 168, 0.9); - color: #4a3a2f; + background: var(--primary); + color: #fff; } .dual-stat-grid { @@ -250,4 +259,218 @@ text-align: center; padding: 24px 0; } -} + + // --- New Initiative Section (Tug of War) --- + .initiative-container { + padding: 0 20px; + } + + .initiative-bar-wrapper { + display: flex; + align-items: center; + gap: 16px; + margin-top: 24px; + background: var(--ar-card-bg); + padding: 16px; + border-radius: 20px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + } + + .initiative-side { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 60px; + + .avatar-placeholder { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: var(--ar-text-sub); + font-size: 14px; + border: 2px solid var(--ar-card-bg); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + .count { + font-size: 13px; + font-weight: 600; + color: var(--ar-text-sub); + } + } + + .initiative-progress { + flex: 1; + height: 12px; + background: var(--bg-tertiary, #eee); + border-radius: 6px; + overflow: hidden; + display: flex; + position: relative; + + .bar-segment { + height: 100%; + transition: width 1s ease-out; + + &.left { + background: var(--ar-primary); + } + + &.right { + background: var(--ar-accent); + } + } + } + + .initiative-ratio { + font-size: 20px; + font-weight: 800; + color: var(--ar-text-main); + text-align: center; + margin-bottom: 2px; + } + + .initiative-desc { + text-align: center; + font-size: 13px; + color: var(--ar-text-sub); + margin-top: 12px; + } + + + // --- New Response Speed Section (Grid + Icons) --- + .response-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-top: 24px; + padding: 0 10px; + } + + .response-card { + background: var(--ar-card-bg); + border-radius: 18px; + padding: 24px 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.05)); + transition: transform 0.2s; + + &:hover { + transform: translateY(-2px); + background: var(--ar-card-bg-hover); + } + + .icon-box { + width: 48px; + height: 48px; + border-radius: 14px; + background: rgba(7, 193, 96, 0.08); + color: var(--ar-primary); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 4px; + + svg { + width: 26px; + height: 26px; + } + } + + &.fastest .icon-box { + background: rgba(242, 170, 0, 0.08); + color: var(--ar-accent); + } + + .label { + font-size: 13px; + color: var(--ar-text-sub); + font-weight: 500; + } + + .value { + font-size: 32px; + font-weight: 700; + color: var(--ar-text-main); + line-height: 1; + + span { + font-size: 14px; + font-weight: 500; + color: var(--ar-text-sub); + margin-left: 2px; + } + } + } + + + // --- New Streak Section (Flame) --- + .streak-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 32px; + position: relative; + padding-bottom: 20px; + } + + .streak-flame { + font-size: 72px; + margin-bottom: 6px; + filter: drop-shadow(0 4px 12px rgba(242, 170, 0, 0.3)); + animation: flamePulse 2s ease-in-out infinite; + transform-origin: center bottom; + } + + @keyframes flamePulse { + + 0%, + 100% { + transform: scale(1); + filter: drop-shadow(0 4px 12px rgba(242, 170, 0, 0.3)); + } + + 50% { + transform: scale(1.05); + filter: drop-shadow(0 6px 16px rgba(242, 170, 0, 0.5)); + } + } + + .streak-days { + font-size: 90px; + font-weight: 800; + color: var(--ar-text-main); + line-height: 0.9; + margin: 10px 0 20px; + text-align: center; + + span { + font-size: 24px; + font-weight: 600; + color: var(--ar-text-sub); + margin-left: 6px; + vertical-align: middle; + } + } + + .streak-range { + background: var(--ar-card-bg); + padding: 8px 20px; + border-radius: 100px; + font-size: 14px; + font-weight: 500; + color: var(--ar-text-sub); + border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.05)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03); + } +} \ No newline at end of file diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 8ba3f92..087be97 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -1,4 +1,7 @@ import { useEffect, useState, type CSSProperties } from 'react' +import { Clock, Zap, MessageSquare, Type, Image as ImageIcon, Mic, Smile } from 'lucide-react' +import ReportHeatmap from '../components/ReportHeatmap' +import ReportWordCloud from '../components/ReportWordCloud' import './AnnualReportWindow.scss' import './DualReportWindow.scss' @@ -42,109 +45,11 @@ interface DualReportData { friendTopEmojiUrl?: string } topPhrases: Array<{ phrase: string; count: number }> -} - -const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => { - if (!words || words.length === 0) { - return
暂无高频语句
- } - const sortedWords = [...words].sort((a, b) => b.count - a.count) - const maxCount = sortedWords.length > 0 ? sortedWords[0].count : 1 - const topWords = sortedWords.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 ( - - {item.phrase} - - ) - }).filter(Boolean) - - return ( -
-
- {wordItems} -
-
- ) + heatmap?: number[][] + initiative?: { initiated: number; received: number } + response?: { avg: number; fastest: number; count: number } + monthly?: Record + streak?: { days: number; startDate: string; endDate: string } } function DualReportWindow() { @@ -274,11 +179,11 @@ function DualReportWindow() { const yearFirstChat = reportData.yearFirstChat const stats = reportData.stats const statItems = [ - { label: '总消息数', value: stats.totalMessages }, - { label: '总字数', value: stats.totalWords }, - { label: '图片', value: stats.imageCount }, - { label: '语音', value: stats.voiceCount }, - { label: '表情', value: stats.emojiCount }, + { label: '总消息数', value: stats.totalMessages, icon: MessageSquare, color: '#07C160' }, + { label: '总字数', value: stats.totalWords, icon: Type, color: '#10AEFF' }, + { label: '图片', value: stats.imageCount, icon: ImageIcon, color: '#FFC300' }, + { label: '语音', value: stats.voiceCount, icon: Mic, color: '#FA5151' }, + { label: '表情', value: stats.emojiCount, icon: Smile, color: '#FA9D3B' }, ] const decodeEntities = (text: string) => ( @@ -307,13 +212,30 @@ function DualReportWindow() { const formatMessageContent = (content?: string) => { const raw = String(content || '').trim() if (!raw) return '(空)' + + // 1. 尝试提取 XML 关键字段 + const titleMatch = raw.match(/([\s\S]*?)<\/title>/i) + if (titleMatch?.[1]) return decodeEntities(stripCdata(titleMatch[1]).trim()) + + const descMatch = raw.match(/<des>([\s\S]*?)<\/des>/i) + if (descMatch?.[1]) return decodeEntities(stripCdata(descMatch[1]).trim()) + + const summaryMatch = raw.match(/<summary>([\s\S]*?)<\/summary>/i) + if (summaryMatch?.[1]) return 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 + 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 - const extracted = extractXmlText(raw) - if (!extracted) return '(XML消息)' - return decodeEntities(stripCdata(extracted).trim()) || '(XML消息)' + + // 3. 最后的尝试:移除所有 XML 标签,看是否还有有意义的文本 + const stripped = raw.replace(/<[^>]+>/g, '').trim() + if (stripped && stripped.length > 0 && stripped.length < 50) { + return decodeEntities(stripped) + } + + return '(多媒体/卡片消息)' } const formatFullDate = (timestamp: number) => { const d = new Date(timestamp) @@ -344,7 +266,7 @@ function DualReportWindow() { <h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1> <hr className="divider" /> <div className="dual-names"> - <span>{reportData.selfName}</span> + <span>我</span> <span className="amp">&</span> <span>{reportData.friendName}</span> </div> @@ -355,33 +277,34 @@ function DualReportWindow() { <div className="label-text">首次聊天</div> <h2 className="hero-title">故事的开始</h2> {firstChat ? ( - <> - <div className="dual-info-grid"> - <div className="dual-info-card"> - <div className="info-label">第一次聊天时间</div> - <div className="info-value">{formatFullDate(firstChat.createTime)}</div> - </div> - <div className="dual-info-card"> - <div className="info-label">距今天数</div> - <div className="info-value">{daysSince} 天</div> - </div> - </div> + <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="dual-message-list"> + <div className="scene-messages"> {firstChatMessages.map((msg, idx) => ( - <div - key={idx} - className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`} - > - <div className="message-meta"> - {msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)} + <div key={idx} className={`scene-message ${msg.isSentByMe ? 'sent' : 'received'}`}> + <div className="scene-avatar"> + {msg.isSentByMe ? '我' : reportData.friendName.substring(0, 1)} + </div> + <div className="scene-content-wrapper"> + <div className="scene-meta"> + {formatFullDate(msg.createTime).split(' ')[1]} + </div> + <div className="scene-bubble"> + <div className="scene-content">{formatMessageContent(msg.content)}</div> + </div> </div> - <div className="message-content">{formatMessageContent(msg.content)}</div> </div> ))} </div> - ) : null} - </> + ) : ( + <div className="hero-desc" style={{ textAlign: 'center' }}>暂无消息详情</div> + )} + <div className="scene-footer" style={{ marginTop: '20px', textAlign: 'center', fontSize: '12px', opacity: 0.6 }}> + 距离今天已经 {daysSince} 天 + </div> + </div> ) : ( <p className="hero-desc">暂无首条消息</p> )} @@ -393,33 +316,111 @@ function DualReportWindow() { <h2 className="hero-title"> {reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`} </h2> - <div className="dual-info-grid"> - <div className="dual-info-card"> - <div className="info-label">第一段对话时间</div> - <div className="info-value">{formatFullDate(yearFirstChat.createTime)}</div> - </div> - <div className="dual-info-card"> - <div className="info-label">发起者</div> - <div className="info-value">{yearFirstChat.isSentByMe ? reportData.selfName : reportData.friendName}</div> - </div> - </div> - <div className="dual-message-list"> - {yearFirstChat.firstThreeMessages.map((msg, idx) => ( - <div key={idx} className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`}> - <div className="message-meta"> - {msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)} + <div className="first-chat-scene"> + <div className="scene-title">久别重逢</div> + <div className="scene-subtitle">{formatFullDate(yearFirstChat.createTime).split(' ')[0]}</div> + <div className="scene-messages"> + {yearFirstChat.firstThreeMessages.map((msg, idx) => ( + <div key={idx} className={`scene-message ${msg.isSentByMe ? 'sent' : 'received'}`}> + <div className="scene-avatar"> + {msg.isSentByMe ? '我' : reportData.friendName.substring(0, 1)} + </div> + <div className="scene-content-wrapper"> + <div className="scene-meta"> + {formatFullDate(msg.createTime).split(' ')[1]} + </div> + <div className="scene-bubble"> + <div className="scene-content">{formatMessageContent(msg.content)}</div> + </div> + </div> </div> - <div className="message-content">{formatMessageContent(msg.content)}</div> - </div> - ))} + ))} + </div> </div> </section> ) : null} + {reportData.heatmap && ( + <section className="section"> + <div className="label-text">聊天习惯</div> + <h2 className="hero-title">作息规律</h2> + <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-desc"> + {reportData.initiative.initiated > reportData.initiative.received ? '每一个话题都是你对TA的在意' : 'TA总是那个率先打破沉默的人'} + </div> + <div className="initiative-bar-wrapper"> + <div className="initiative-side"> + <div className="avatar-placeholder">我</div> + <div className="count">{reportData.initiative.initiated}次</div> + </div> + <div className="initiative-progress"> + <div + className="bar-segment left" + style={{ width: `${reportData.initiative.initiated / (reportData.initiative.initiated + reportData.initiative.received) * 100}%` }} + /> + <div + className="bar-segment right" + style={{ width: `${reportData.initiative.received / (reportData.initiative.initiated + reportData.initiative.received) * 100}%` }} + /> + </div> + <div className="initiative-side"> + <div className="avatar-placeholder">{reportData.friendName.substring(0, 1)}</div> + <div className="count">{reportData.initiative.received}次</div> + </div> + </div> + </div> + </section> + )} + + {reportData.response && ( + <section className="section"> + <div className="label-text">回复速度</div> + <h2 className="hero-title">秒回是并在乎</h2> + <div className="response-grid"> + <div className="response-card"> + <div className="icon-box"> + <Clock size={24} /> + </div> + <div className="label">平均回复</div> + <div className="value">{Math.round(reportData.response.avg / 60)}<span>分</span></div> + </div> + <div className="response-card fastest"> + <div className="icon-box"> + <Zap size={24} /> + </div> + <div className="label">最快回复</div> + <div className="value">{reportData.response.fastest}<span>秒</span></div> + </div> + </div> + </section> + )} + + {reportData.streak && ( + <section className="section"> + <div className="label-text">聊天火花</div> + <h2 className="hero-title">最长连续聊天</h2> + <div className="streak-container"> + <div className="streak-flame">🔥</div> + <div className="streak-days">{reportData.streak.days}<span>天</span></div> + <div className="streak-range"> + {reportData.streak.startDate} ~ {reportData.streak.endDate} + </div> + </div> + </section> + )} + <section className="section"> <div className="label-text">常用语</div> <h2 className="hero-title">{yearTitle}常用语</h2> - <WordCloud words={reportData.topPhrases} /> + <ReportWordCloud words={reportData.topPhrases} /> </section> <section className="section"> @@ -429,8 +430,22 @@ function DualReportWindow() { {statItems.map((item) => { const valueText = item.value.toLocaleString() const isLong = valueText.length > 7 + const Icon = item.icon return ( - <div key={item.label} className={`dual-stat-card ${isLong ? 'long' : ''}`}> + <div key={item.label} className={`dual-stat-card ${isLong ? 'long' : ''}`} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}> + <div className="stat-icon" style={{ + width: '40px', + height: '40px', + borderRadius: '12px', + background: `${item.color}15`, + color: item.color, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginBottom: '4px' + }}> + <Icon size={20} /> + </div> <div className="stat-num">{valueText}</div> <div className="stat-unit">{item.label}</div> </div>