diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts index 5e3b21a..75067ff 100644 --- a/electron/services/dualReportService.ts +++ b/electron/services/dualReportService.ts @@ -6,6 +6,9 @@ export interface DualReportMessage { isSentByMe: boolean createTime: number createTimeStr: string + localType?: number + emojiMd5?: string + emojiCdnUrl?: string } export interface DualReportFirstChat { @@ -14,6 +17,9 @@ export interface DualReportFirstChat { content: string isSentByMe: boolean senderUsername?: string + localType?: number + emojiMd5?: string + emojiCdnUrl?: string } export interface DualReportStats { @@ -46,6 +52,9 @@ export interface DualReportData { isSentByMe: boolean friendName: string firstThreeMessages: DualReportMessage[] + localType?: number + emojiMd5?: string + emojiCdnUrl?: string } | null stats: DualReportStats topPhrases: Array<{ phrase: string; count: number }> @@ -526,55 +535,105 @@ class DualReportService { } this.reportProgress('获取首条聊天记录...', 15, onProgress) - const firstRows = await this.getFirstMessages(friendUsername, 3, 0, 0) + const firstRows = await this.getFirstMessages(friendUsername, 10, 0, 0) let firstChat: DualReportFirstChat | null = null if (firstRows.length > 0) { const row = firstRows[0] const createTime = parseInt(row.create_time || '0', 10) * 1000 - const content = this.decodeMessageContent(row.message_content, row.compress_content) + const rawContent = this.decodeMessageContent(row.message_content, row.compress_content) + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0) + let emojiMd5: string | undefined + let emojiCdnUrl: string | undefined + if (localType === 47) { + const stripped = this.stripEmojiOwnerPrefix(rawContent) + emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped) + emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped) + } + firstChat = { createTime, createTimeStr: this.formatDateTime(createTime), - content: String(content || ''), + content: String(rawContent || ''), isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid), - senderUsername: row.sender_username || row.sender + senderUsername: row.sender_username || row.sender, + localType, + emojiMd5, + emojiCdnUrl } } const firstChatMessages: DualReportMessage[] = firstRows.map((row) => { const msgTime = parseInt(row.create_time || '0', 10) * 1000 - const msgContent = this.decodeMessageContent(row.message_content, row.compress_content) + const rawContent = this.decodeMessageContent(row.message_content, row.compress_content) + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0) + let emojiMd5: string | undefined + let emojiCdnUrl: string | undefined + if (localType === 47) { + const stripped = this.stripEmojiOwnerPrefix(rawContent) + emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped) + emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped) + } + return { - content: String(msgContent || ''), + content: String(rawContent || ''), isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid), createTime: msgTime, - createTimeStr: this.formatDateTime(msgTime) + createTimeStr: this.formatDateTime(msgTime), + localType, + emojiMd5, + emojiCdnUrl } }) let yearFirstChat: DualReportData['yearFirstChat'] = null if (!isAllTime) { this.reportProgress('获取今年首次聊天...', 20, onProgress) - const firstYearRows = await this.getFirstMessages(friendUsername, 3, startTime, endTime) + const firstYearRows = await this.getFirstMessages(friendUsername, 10, startTime, endTime) if (firstYearRows.length > 0) { const firstRow = firstYearRows[0] const createTime = parseInt(firstRow.create_time || '0', 10) * 1000 const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => { const msgTime = parseInt(row.create_time || '0', 10) * 1000 - const msgContent = this.decodeMessageContent(row.message_content, row.compress_content) + const rawContent = this.decodeMessageContent(row.message_content, row.compress_content) + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0) + let emojiMd5: string | undefined + let emojiCdnUrl: string | undefined + if (localType === 47) { + const stripped = this.stripEmojiOwnerPrefix(rawContent) + emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped) + emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped) + } + return { - content: String(msgContent || ''), + content: String(rawContent || ''), isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid), createTime: msgTime, - createTimeStr: this.formatDateTime(msgTime) + createTimeStr: this.formatDateTime(msgTime), + localType, + emojiMd5, + emojiCdnUrl } }) + const firstRowYear = firstYearRows[0] + const rawContentYear = this.decodeMessageContent(firstRowYear.message_content, firstRowYear.compress_content) + const localTypeYear = this.getRowInt(firstRowYear, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0) + let emojiMd5Year: string | undefined + let emojiCdnUrlYear: string | undefined + if (localTypeYear === 47) { + const stripped = this.stripEmojiOwnerPrefix(rawContentYear) + emojiMd5Year = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(firstRowYear, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped) + emojiCdnUrlYear = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(firstRowYear, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped) + } + yearFirstChat = { createTime, createTimeStr: this.formatDateTime(createTime), - content: String(this.decodeMessageContent(firstRow.message_content, firstRow.compress_content) || ''), - isSentByMe: this.resolveIsSent(firstRow, rawWxid, cleanedWxid), + content: String(rawContentYear || ''), + isSentByMe: this.resolveIsSent(firstRowYear, rawWxid, cleanedWxid), friendName, - firstThreeMessages + firstThreeMessages, + localType: localTypeYear, + emojiMd5: emojiMd5Year, + emojiCdnUrl: emojiCdnUrlYear } } } @@ -660,7 +719,6 @@ class DualReportService { 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, @@ -673,13 +731,12 @@ class DualReportService { yearFirstChat, stats, 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 + } as any this.reportProgress('双人报告生成完成', 100, onProgress) return { success: true, data: reportData } diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 41aced9..f24681d 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 221604e..8ccff4b 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -31,9 +31,6 @@ } .dual-info-card { - background: var(--ar-card-bg); - border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.05)); - border-radius: 14px; padding: 16px; &.full { @@ -61,14 +58,8 @@ } .dual-message { - background: var(--ar-card-bg); - border-radius: 14px; padding: 14px; - &.received { - background: var(--ar-card-bg-hover); - } - .message-meta { font-size: 12px; color: var(--ar-text-sub); @@ -82,14 +73,11 @@ } .first-chat-scene { - background: color-mix(in srgb, var(--ar-card-bg) 92%, #fff 8%); - border-radius: 16px; padding: 18px 16px 16px; color: var(--ar-text-main); position: relative; overflow: hidden; margin-top: 16px; - border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.06)); } .first-chat-scene::before { @@ -121,11 +109,50 @@ .scene-message { display: flex; - align-items: flex-end; - gap: 12px; + flex-direction: column; + align-items: center; + margin-bottom: 32px; + width: 100%; - &.sent { + &.system { + margin: 16px 0; + + .system-msg-content { + background: rgba(255, 255, 255, 0.05); + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + text-align: center; + max-width: 80%; + } + } + + .scene-meta { + font-size: 10px; + opacity: 0.65; + margin-bottom: 12px; + color: var(--text-tertiary); + text-align: center; + width: 100%; + } + + .scene-body { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + max-width: 100%; + } + + &.sent .scene-body { flex-direction: row-reverse; + justify-content: flex-start; + } + + &.received .scene-body { + flex-direction: row; + justify-content: flex-start; } } @@ -163,36 +190,42 @@ } .scene-bubble { - background: color-mix(in srgb, var(--ar-card-bg-hover) 90%, #fff 10%); color: var(--ar-text-main); padding: 10px 14px; - border-radius: 12px; width: fit-content; - min-width: 68px; + min-width: 40px; max-width: 100%; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); - border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.06)); - } + background: var(--ar-card-bg); + border-radius: 12px; + position: relative; - .scene-message.sent .scene-bubble { - background: color-mix(in srgb, var(--primary) 12%, var(--ar-card-bg-hover)); - border-color: color-mix(in srgb, var(--primary) 26%, var(--bg-tertiary, rgba(0, 0, 0, 0.06))); - } - - .scene-meta { - font-size: 11px; - opacity: 0.7; - margin-bottom: 4px; - color: var(--text-tertiary); + &.no-bubble { + background: transparent; + padding: 0; + box-shadow: none; + } } .scene-content { - font-size: 14px; - line-height: 1.65; + line-height: 1.5; + font-size: clamp(14px, 1.8vw, 16px); + word-break: break-all; white-space: pre-wrap; - word-break: break-word; overflow-wrap: break-word; line-break: auto; + + .report-emoji-container { + display: inline-block; + vertical-align: middle; + margin: 2px 0; + + .report-emoji-img { + max-width: 120px; + max-height: 120px; + border-radius: 4px; + display: block; + } + } } .scene-avatar.fallback { @@ -209,29 +242,47 @@ } .dual-stat-grid { - display: grid; - grid-template-columns: repeat(5, minmax(140px, 1fr)); - gap: 14px; - margin: 20px -28px 24px; - padding: 0 28px; - overflow: visible; + display: flex; + flex-wrap: nowrap; + gap: clamp(60px, 10vw, 120px); + margin: 48px 0 32px; + padding: 0; + justify-content: center; + align-items: flex-start; + + &.bottom { + margin-top: 0; + margin-bottom: 48px; + gap: clamp(40px, 6vw, 80px); + } } .dual-stat-card { - background: var(--ar-card-bg); - border-radius: 14px; - padding: 14px 12px; + display: flex; + flex-direction: column; + align-items: center; text-align: center; + min-width: 140px; + max-width: 280px; } .stat-num { - font-size: clamp(20px, 2.8vw, 30px); + font-size: clamp(36px, 6vw, 64px); + font-weight: 800; font-variant-numeric: tabular-nums; + color: var(--ar-primary); + line-height: 1; white-space: nowrap; + + &.small { + font-size: clamp(24px, 4vw, 40px); + } } .stat-unit { - font-size: 12px; + font-size: 14px; + margin-top: 4px; + opacity: 0.8; } .dual-stat-card.long .stat-num { @@ -247,15 +298,12 @@ } .emoji-card { - border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08)); - border-radius: 16px; padding: 18px 16px; display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center; - background: var(--ar-card-bg); img { width: 64px; @@ -283,257 +331,564 @@ padding: 24px 0; } - // --- New Initiative Section (Tug of War) --- .initiative-container { - padding: 0 20px; + padding: 32px 0; + width: 100%; + background: transparent; + border: none; } .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); + gap: 32px; + width: 100%; + padding: 24px 0; + margin-bottom: 24px; + position: relative; } .initiative-side { display: flex; flex-direction: column; align-items: center; - gap: 4px; - min-width: 60px; + gap: 12px; + min-width: 80px; + z-index: 2; .avatar-placeholder { - width: 44px; - height: 44px; - border-radius: 50%; + width: 54px; + height: 54px; + border-radius: 18px; 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); + font-size: 16px; + border: 1.5px solid rgba(255, 255, 255, 0.15); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + overflow: hidden; img { width: 100%; height: 100%; object-fit: cover; - border-radius: 50%; } } .count { - font-size: 13px; - font-weight: 600; + font-size: 11px; + font-weight: 500; + opacity: 0.4; color: var(--ar-text-sub); } .percent { - font-size: 12px; + font-size: 14px; color: var(--ar-text-main); - opacity: 0.85; - font-weight: 600; + font-weight: 800; + opacity: 0.9; } } .initiative-progress { flex: 1; - height: 12px; - background: var(--bg-tertiary, #eee); - border-radius: 6px; - overflow: hidden; - display: flex; + height: 1px; // 线条样式 position: relative; + display: flex; + align-items: center; - .bar-segment { - height: 100%; - transition: width 1s ease-out; + .line-bg { + position: absolute; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 255, 255, 0.1) 20%, + rgba(255, 255, 255, 0.1) 80%, + transparent 100%); + } - &.left { - background: var(--ar-primary); - } + .initiative-indicator { + position: absolute; + width: 8px; + height: 8px; + background: #fff; + border-radius: 50%; + transform: translateX(-50%); + transition: left 1.5s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: + 0 0 10px #fff, + 0 0 20px rgba(255, 255, 255, 0.5), + 0 0 30px var(--ar-primary); + z-index: 3; - &.right { - background: var(--ar-accent); + &::before { + content: ''; + position: absolute; + top: -4px; + left: -4px; + right: -4px; + bottom: -4px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 50%; + animation: pulse 2s infinite; } } } - .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: repeat(3, minmax(0, 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); - } - - &.sample .icon-box { - background: rgba(16, 174, 255, 0.08); - color: #10AEFF; - } - - .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; - } - } - } - - .response-note { - margin-top: 14px; - max-width: none; text-align: center; font-size: 14px; color: var(--ar-text-sub); + letter-spacing: 1px; + opacity: 0.6; + background: transparent; + padding: 0; + margin: 0 auto; + font-style: italic; + } + + @keyframes pulse { + 0% { + transform: scale(1); + opacity: 0.8; + } + + 100% { + transform: scale(2); + opacity: 0; + } } - // --- New Streak Section (Flame) --- - .streak-container { + .response-pulse-container { + width: 100%; + padding: 80px 0; + display: flex; + justify-content: center; + } + + .pulse-visual { + position: relative; + width: 420px; + height: 240px; + display: flex; + align-items: center; + justify-content: center; + } + + .pulse-hub { + position: relative; + z-index: 5; display: flex; flex-direction: column; align-items: center; justify-content: center; + width: 160px; + height: 160px; + background: radial-gradient(circle at center, rgba(255, 255, 255, 0.12) 0%, transparent 75%); + border-radius: 50%; + box-shadow: 0 0 40px rgba(255, 255, 255, 0.1); + + .label { + font-size: 13px; + color: var(--ar-text-sub); + opacity: 0.6; + margin-bottom: 6px; + letter-spacing: 2px; + } + + .value { + font-size: 54px; + font-weight: 950; + color: #fff; + line-height: 1; + text-shadow: 0 0 30px rgba(255, 255, 255, 0.5); + + span { + font-size: 18px; + font-weight: 500; + margin-left: 4px; + opacity: 0.7; + } + } + } + + .pulse-node { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + z-index: 4; + animation: floatNode 4s ease-in-out infinite; + + &.left { + left: 0; + transform: translateX(-15%); + } + + &.right { + right: 0; + transform: translateX(15%); + animation-delay: -2s; + } + + .label { + font-size: 12px; + color: var(--ar-text-sub); + opacity: 0.5; + margin-bottom: 4px; + } + + .value { + font-size: 24px; + font-weight: 800; + color: var(--ar-text-main); + opacity: 0.95; + + span { + font-size: 13px; + margin-left: 2px; + opacity: 0.6; + } + } + } + + .pulse-ripple { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border: 1.5px solid rgba(255, 255, 255, 0.08); + border-radius: 50%; + animation: ripplePulse 8s linear infinite; + pointer-events: none; + + &.one { + animation-delay: 0s; + } + + &.two { + animation-delay: 2.5s; + } + + &.three { + animation-delay: 5s; + } + } + + @keyframes ripplePulse { + 0% { + width: 140px; + height: 140px; + opacity: 0.5; + } + + 100% { + width: 700px; + height: 700px; + opacity: 0; + } + } + + @keyframes floatNode { + + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-16px); + } + } + + .response-note { + text-align: center; + font-size: 14px; + color: var(--ar-text-sub); + opacity: 0.5; margin-top: 32px; + font-style: italic; + max-width: none; + line-height: 1.6; + } + + .streak-spark-visual.premium { + width: 100%; + height: 400px; position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 20px 0; + overflow: visible; + + .spark-ambient-glow { + position: absolute; + top: 40%; + left: 50%; + transform: translate(-50%, -50%); + width: 600px; + height: 480px; + background: radial-gradient(circle at center, rgba(242, 170, 0, 0.04) 0%, transparent 70%); + filter: blur(60px); + z-index: 1; + pointer-events: none; + } + } + + .spark-core-wrapper { + position: relative; + width: 220px; + height: 280px; + display: flex; + align-items: center; + justify-content: center; + z-index: 5; + animation: flameSway 6s ease-in-out infinite; + transform-origin: bottom center; + } + + .spark-flame-outer { + position: absolute; + width: 100%; + height: 100%; + background: radial-gradient(ellipse at 50% 85%, rgba(242, 170, 0, 0.15) 0%, transparent 75%); + border-radius: 50% 50% 20% 20% / 80% 80% 30% 30%; + filter: blur(25px); + animation: flickerOuter 4s infinite alternate; + } + + .spark-flame-inner { + position: absolute; + bottom: 20%; + width: 140px; + height: 180px; + background: radial-gradient(ellipse at 50% 90%, rgba(255, 215, 0, 0.2) 0%, transparent 80%); + border-radius: 50% 50% 30% 30% / 85% 85% 25% 25%; + filter: blur(12px); + animation: flickerInner 3s infinite alternate-reverse; + } + + .spark-core { + position: relative; + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; padding-bottom: 20px; + + .spark-days { + font-size: 84px; + font-weight: 800; + color: rgba(255, 255, 255, 0.9); + line-height: 1; + letter-spacing: -1px; + text-shadow: + 0 0 15px rgba(255, 255, 255, 0.4), + 0 8px 30px rgba(0, 0, 0, 0.3); + } + + .spark-label { + font-size: 14px; + font-weight: 800; + color: rgba(255, 255, 255, 0.4); + letter-spacing: 6px; + margin-top: 12px; + text-indent: 6px; + } } - .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; + .streak-bridge.premium { + width: 100%; + max-width: 500px; + display: flex; + align-items: center; + gap: 0; + margin-top: -20px; + z-index: 20; + + .bridge-date { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + width: 100px; + + span { + font-size: 13px; + color: var(--ar-text-sub); + opacity: 0.6; + font-weight: 500; + letter-spacing: 0.2px; + position: absolute; + top: 24px; + white-space: nowrap; + } + + .date-orb { + width: 6px; + height: 6px; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 12px var(--ar-accent); + border: 1px solid rgba(252, 170, 0, 0.5); + } + } + + .bridge-line { + flex: 1; + height: 40px; + position: relative; + display: flex; + align-items: center; + + .line-string { + width: 100%; + height: 1.5px; + background: linear-gradient(90deg, + rgba(242, 170, 0, 0) 0%, + rgba(242, 170, 0, 0.6) 20%, + rgba(242, 170, 0, 0.6) 80%, + rgba(242, 170, 0, 0) 100%); + mask-image: radial-gradient(ellipse at center, black 60%, transparent 100%); + } + + .line-glow { + position: absolute; + width: 100%; + height: 8px; + background: radial-gradient(ellipse at center, rgba(242, 170, 0, 0.2) 0%, transparent 80%); + filter: blur(4px); + animation: sparkFlicker 2s infinite alternate; + } + } } - @keyframes flamePulse { + .spark-ember { + position: absolute; + background: #FFD700; + border-radius: 50%; + filter: blur(0.5px); + box-shadow: 0 0 6px #F2AA00; + opacity: 0; + z-index: 4; + + &.one { + width: 3px; + height: 3px; + left: 46%; + animation: emberRise 5s infinite 0s; + } + + &.two { + width: 2px; + height: 2px; + left: 53%; + animation: emberRise 4s infinite 1.2s; + } + + &.three { + width: 4px; + height: 4px; + left: 50%; + animation: emberRise 6s infinite 2.5s; + } + + &.four { + width: 2.5px; + height: 2.5px; + left: 48%; + animation: emberRise 5.5s infinite 3.8s; + } + } + + @keyframes flameSway { + + 0%, + 100% { + transform: rotate(-1deg) skewX(-1deg); + } + + 50% { + transform: rotate(1.5deg) skewX(1deg); + } + } + + @keyframes flickerOuter { + + 0%, + 100% { + opacity: 0.15; + filter: blur(25px); + } + + 50% { + opacity: 0.25; + filter: blur(30px); + } + } + + @keyframes flickerInner { 0%, 100% { transform: scale(1); - filter: drop-shadow(0 4px 12px rgba(242, 170, 0, 0.3)); + opacity: 0.2; } 50% { - transform: scale(1.05); - filter: drop-shadow(0 6px 16px rgba(242, 170, 0, 0.5)); + transform: scale(1.08); + opacity: 0.3; } } - .streak-days { - font-size: 90px; - font-weight: 800; - color: var(--ar-text-main); - line-height: 0.9; - margin: 10px 0 20px; - text-align: center; + @keyframes emberRise { + 0% { + transform: translateY(100px) scale(1); + opacity: 0; + } - span { - font-size: 24px; - font-weight: 600; - color: var(--ar-text-sub); - margin-left: 6px; - vertical-align: middle; + 20% { + opacity: 0.8; + } + + 80% { + opacity: 0.3; + } + + 100% { + transform: translateY(-260px) scale(0.4); + opacity: 0; } } - .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); - } + @keyframes sparkFlicker { - .emoji-count { - font-size: 12px; - color: var(--ar-text-sub); - opacity: 0.85; + 0%, + 100% { + transform: scale(1); + opacity: 0.9; + filter: brightness(1); + } + + 50% { + transform: scale(1.03); + opacity: 1; + filter: brightness(1.2); + } } @media (max-width: 960px) { - .response-grid { - grid-template-columns: 1fr; - padding: 0; + .pulse-visual { + transform: scale(0.85); } .scene-avatar { @@ -551,4 +906,4 @@ min-width: 56px; } } -} +} \ No newline at end of file diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index cf9f005..197a8ae 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react' -import { Clock, Zap, MessageCircle, MessageSquare, Type, Image as ImageIcon, Mic, Smile } from 'lucide-react' import ReportHeatmap from '../components/ReportHeatmap' import ReportWordCloud from '../components/ReportWordCloud' import './AnnualReportWindow.scss' @@ -10,6 +9,9 @@ interface DualReportMessage { isSentByMe: boolean createTime: number createTimeStr: string + localType?: number + emojiMd5?: string + emojiCdnUrl?: string } interface DualReportData { @@ -25,6 +27,9 @@ interface DualReportData { content: string isSentByMe: boolean senderUsername?: string + localType?: number + emojiMd5?: string + emojiCdnUrl?: string } | null firstChatMessages?: DualReportMessage[] yearFirstChat?: { @@ -34,6 +39,9 @@ interface DualReportData { isSentByMe: boolean friendName: string firstThreeMessages: DualReportMessage[] + localType?: number + emojiMd5?: string + emojiCdnUrl?: string } | null stats: { totalMessages: number @@ -51,7 +59,7 @@ interface DualReportData { topPhrases: Array<{ phrase: string; count: number }> heatmap?: number[][] initiative?: { initiated: number; received: number } - response?: { avg: number; fastest: number; count: number } + response?: { avg: number; fastest: number; slowest: number; count: number } monthly?: Record streak?: { days: number; startDate: string; endDate: string } } @@ -188,11 +196,11 @@ function DualReportWindow() { 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, 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' }, + { 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) => ( @@ -204,6 +212,20 @@ function DualReportWindow() { .replace(/'/g, "'") ) + const filterDisplayMessages = (messages: DualReportMessage[], maxActual: number = 3) => { + let actualCount = 0 + const result: DualReportMessage[] = [] + for (const msg of messages) { + const isSystem = msg.localType === 10000 || msg.localType === 10002 + if (!isSystem) { + if (actualCount >= maxActual) break + actualCount++ + } + result.push(msg) + } + return result + } + const stripCdata = (text: string) => text.replace(//g, '$1') const compactMessageText = (text: string) => ( text @@ -225,7 +247,18 @@ function DualReportWindow() { return '' } - const formatMessageContent = (content?: string) => { + 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 '(空)' @@ -251,7 +284,25 @@ function DualReportWindow() { return compactMessageText(decodeEntities(stripped)) } - return '(多媒体/卡片消息)' + 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) @@ -300,6 +351,50 @@ function DualReportWindow() { 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)} +
+
+
+
+
+
+
+ ) + }) + } + return (
@@ -335,24 +430,12 @@ function DualReportWindow() {
{formatFullDate(firstChat.createTime).split(' ')[0]}
{firstChatMessages.length > 0 ? (
- {firstChatMessages.map((msg, idx) => ( -
- {renderSceneAvatar(msg.isSentByMe)} -
-
- {formatFullDate(msg.createTime).split(' ')[1]} -
-
-
{formatMessageContent(msg.content)}
-
-
-
- ))} + {renderMessageList(firstChatMessages)}
) : (
暂无消息详情
)} -
+
距离今天已经 {daysSince} 天
@@ -361,7 +444,7 @@ function DualReportWindow() { )} - {yearFirstChat ? ( + {yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? (
第一段对话

@@ -371,19 +454,7 @@ function DualReportWindow() {
久别重逢
{formatFullDate(yearFirstChat.createTime).split(' ')[0]}
- {yearFirstChat.firstThreeMessages.map((msg, idx) => ( -
- {renderSceneAvatar(msg.isSentByMe)} -
-
- {formatFullDate(msg.createTime).split(' ')[1]} -
-
-
{formatMessageContent(msg.content)}
-
-
-
- ))} + {renderMessageList(yearFirstChat.firstThreeMessages)}

@@ -395,7 +466,7 @@ function DualReportWindow() {

作息规律

{mostActive && (

- {'\u5728'} {mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00 {'\u6700\u6d3b\u8dc3\uff08'}{mostActive.value}{'\u6761\uff09'} + 在 {mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00 最活跃({mostActive.value}条)

)} @@ -407,81 +478,107 @@ function DualReportWindow() {
主动性

情感的天平

-
- {reportData.initiative.initiated > reportData.initiative.received ? '每一个话题都是你对TA的在意' : 'TA总是那个率先打破沉默的人'} -
- {reportData.selfAvatarUrl ? me-avatar : '\u6211'} + {reportData.selfAvatarUrl ? me-avatar : '我'}
-
{reportData.initiative.initiated}{'\u6b21'}
+
{reportData.initiative.initiated}次
{initiatedPercent.toFixed(1)}%
+
-
{reportData.friendAvatarUrl ? friend-avatar : reportData.friendName.substring(0, 1)}
-
{reportData.initiative.received}{'\u6b21'}
+
{reportData.initiative.received}次
{receivedPercent.toFixed(1)}%
+
+ {reportData.initiative.initiated > reportData.initiative.received ? '每一个话题都是你对TA的在意' : 'TA总是那个率先打破沉默的人'} +
)} {reportData.response && (
-
回复速度
-

{'\u79d2\u56de\uff0c\u662f\u56e0\u4e3a\u5728\u4e4e'}

-
-
-
- +
回应速度
+

你说,我在

+
+
+
+
+
+ +
+
最快回复
+
{reportData.response.fastest}
-
{'\u5e73\u5747\u56de\u590d'}
-
{Math.round(reportData.response.avg / 60)}{'\u5206'}
-
-
-
- + +
+
平均回复
+
{Math.round(reportData.response.avg / 60)}
-
{'\u6700\u5feb\u56de\u590d'}
-
{reportData.response.fastest}{'\u79d2'}
-
-
-
- + +
+
最慢回复
+
+ {reportData.response.slowest > 3600 + ? (reportData.response.slowest / 3600).toFixed(1) + : Math.round(reportData.response.slowest / 60)} + {reportData.response.slowest > 3600 ? '时' : '分'} +
-
{'\u7edf\u8ba1\u6837\u672c'}
-
{reportData.response.count}{'\u6b21'}

- {`\u5171\u7edf\u8ba1 ${reportData.response.count} \u6b21\u6709\u6548\u56de\u590d\uff0c\u5e73\u5747\u7ea6 ${responseAvgMinutes} \u5206\u949f\uff0c\u6700\u5feb ${reportData.response.fastest} \u79d2\u3002`} + {`在 ${reportData.response.count} 次互动中,平均约 ${responseAvgMinutes} 分钟,最快 ${reportData.response.fastest} 秒。`}

)} {reportData.streak && (
-
{'\u804a\u5929\u706b\u82b1'}
-

{'\u6700\u957f\u8fde\u7eed\u804a\u5929'}

-
-
{'\uD83D\uDD25'}
-
{reportData.streak.days}{'\u5929'}
-
- {reportData.streak.startDate} ~ {reportData.streak.endDate} +
聊天火花
+

最长连续聊天

+
+
+ +
+
+
+
+ +
+
+
+
+
{reportData.streak.days}
+
DAYS
+
+
+ +
+
+
+ {reportData.streak.startDate} +
+
+
+
+
+
+ {reportData.streak.endDate} +
+
@@ -497,50 +594,48 @@ function DualReportWindow() {
年度统计

{yearTitle}数据概览

- {statItems.map((item) => { - const valueText = item.value.toLocaleString() - const isLong = valueText.length > 7 - const Icon = item.icon - return ( -
-
- -
-
{valueText}
-
{item.label}
-
- ) - })} + {statItems.slice(0, 2).map((item) => ( +
+
{item.value.toLocaleString()}
+
{item.label}
+
+ ))} +
+
+ {statItems.slice(2).map((item) => ( +
+
{item.value.toLocaleString()}
+
{item.label}
+
+ ))}
我常用的表情
{myEmojiUrl ? ( - my-emoji - ) : ( -
{stats.myTopEmojiMd5 || '暂无'}
- )} -
{stats.myTopEmojiCount ? `${stats.myTopEmojiCount}\u6b21` : '\u6682\u65e0\u7edf\u8ba1'}
+ my-emoji { + (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); + (e.target as HTMLImageElement).style.display = 'none'; + }} /> + ) : null} +
+ {stats.myTopEmojiMd5 || '暂无'} +
+
{stats.myTopEmojiCount ? `${stats.myTopEmojiCount}次` : '暂无统计'}
{reportData.friendName}常用的表情
{friendEmojiUrl ? ( - friend-emoji - ) : ( -
{stats.friendTopEmojiMd5 || '暂无'}
- )} -
{stats.friendTopEmojiCount ? `${stats.friendTopEmojiCount}\u6b21` : '\u6682\u65e0\u7edf\u8ba1'}
+ friend-emoji { + (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); + (e.target as HTMLImageElement).style.display = 'none'; + }} /> + ) : null} +
+ {stats.friendTopEmojiMd5 || '暂无'} +
+
{stats.friendTopEmojiCount ? `${stats.friendTopEmojiCount}次` : '暂无统计'}