From ddbb0c3b2621ae277c31d77cb2cd1b50c520e834 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 1 Feb 2026 02:26:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/dualReportService.ts | 33 +++++- src/pages/AnnualReportPage.tsx | 4 +- src/pages/DualReportWindow.scss | 139 +++++++++++++++++++++-- src/pages/DualReportWindow.tsx | 149 ++++++++++++++++++------- src/types/electron.d.ts | 6 + 5 files changed, 276 insertions(+), 55 deletions(-) diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts index 1b2f8f9..3d9a857 100644 --- a/electron/services/dualReportService.ts +++ b/electron/services/dualReportService.ts @@ -34,6 +34,7 @@ export interface DualReportData { friendUsername: string friendName: string firstChat: DualReportFirstChat | null + firstChatMessages?: DualReportMessage[] yearFirstChat?: { createTime: number createTimeStr: string @@ -210,12 +211,23 @@ class DualReportService { beginTimestamp: number, endTimestamp: number ): Promise { - const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, beginTimestamp, endTimestamp) + const safeBegin = Math.max(0, beginTimestamp || 0) + const safeEnd = endTimestamp && endTimestamp > 0 ? endTimestamp : Math.floor(Date.now() / 1000) + const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, safeBegin, safeEnd) if (!cursorResult.success || !cursorResult.cursor) return [] try { - const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) - if (!batch.success || !batch.rows) return [] - return batch.rows.slice(0, limit) + const rows: any[] = [] + let hasMore = true + while (hasMore && rows.length < limit) { + const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) + if (!batch.success || !batch.rows) break + for (const row of batch.rows) { + rows.push(row) + if (rows.length >= limit) break + } + hasMore = batch.hasMore === true + } + return rows.slice(0, limit) } finally { await wcdbService.closeMessageCursor(cursorResult.cursor) } @@ -251,7 +263,7 @@ class DualReportService { } this.reportProgress('获取首条聊天记录...', 15, onProgress) - const firstRows = await this.getFirstMessages(friendUsername, 1, 0, 0) + const firstRows = await this.getFirstMessages(friendUsername, 3, 0, 0) let firstChat: DualReportFirstChat | null = null if (firstRows.length > 0) { const row = firstRows[0] @@ -265,6 +277,16 @@ class DualReportService { senderUsername: row.sender_username || row.sender } } + 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) + return { + content: String(msgContent || ''), + isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid), + createTime: msgTime, + createTimeStr: this.formatDateTime(msgTime) + } + }) let yearFirstChat: DualReportData['yearFirstChat'] = null if (!isAllTime) { @@ -417,6 +439,7 @@ class DualReportService { friendUsername, friendName, firstChat, + firstChatMessages, yearFirstChat, stats, topPhrases diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx index 7bd8b10..d0aa943 100644 --- a/src/pages/AnnualReportPage.tsx +++ b/src/pages/AnnualReportPage.tsx @@ -25,8 +25,8 @@ function AnnualReportPage() { const result = await window.electronAPI.annualReport.getAvailableYears() if (result.success && result.data && result.data.length > 0) { setAvailableYears(result.data) - setSelectedYear(result.data[0]) - setSelectedPairYear(result.data[0]) + setSelectedYear((prev) => prev ?? result.data[0]) + setSelectedPairYear((prev) => prev ?? result.data[0]) } else if (!result.success) { setLoadError(result.error || '加载年度数据失败') } diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 14d6e6c..646b9ab 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -1,4 +1,13 @@ .annual-report-window.dual-report-window { + .hero-title { + font-size: clamp(22px, 4vw, 34px); + white-space: nowrap; + } + + .dual-cover-title { + font-size: clamp(26px, 5vw, 44px); + white-space: normal; + } .dual-names { font-size: clamp(24px, 4vw, 40px); font-weight: 700; @@ -71,30 +80,144 @@ } } + .first-chat-scene { + background: linear-gradient(180deg, #8f5b85 0%, #e38aa0 50%, #f6d0c8 100%); + border-radius: 20px; + padding: 28px 24px 24px; + color: #fff; + position: relative; + overflow: hidden; + margin-top: 16px; + } + + .first-chat-scene::before { + content: ""; + 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%); + opacity: 0.6; + pointer-events: none; + } + + .scene-title { + font-size: 24px; + font-weight: 700; + text-align: center; + margin-bottom: 8px; + } + + .scene-subtitle { + font-size: 18px; + font-weight: 500; + text-align: center; + margin-bottom: 20px; + opacity: 0.95; + } + + .scene-messages { + display: flex; + flex-direction: column; + gap: 14px; + } + + .scene-message { + display: flex; + align-items: flex-end; + gap: 12px; + + &.sent { + flex-direction: row-reverse; + } + } + + .scene-avatar { + width: 40px; + height: 40px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.25); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: #fff; + } + + .scene-bubble { + background: rgba(255, 255, 255, 0.85); + color: #5a4d5e; + padding: 10px 14px; + border-radius: 14px; + max-width: 60%; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12); + } + + .scene-message.sent .scene-bubble { + background: rgba(255, 224, 168, 0.9); + color: #4a3a2f; + } + + .scene-meta { + font-size: 11px; + opacity: 0.7; + margin-bottom: 4px; + } + + .scene-content { + font-size: 14px; + line-height: 1.4; + word-break: break-word; + } + + .scene-message.sent .scene-avatar { + background: rgba(255, 224, 168, 0.9); + color: #4a3a2f; + } + .dual-stat-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 16px; - margin: 20px 0 24px; + grid-template-columns: repeat(5, minmax(140px, 1fr)); + gap: 14px; + margin: 20px -28px 24px; + padding: 0 28px; + overflow: visible; } .dual-stat-card { background: var(--ar-card-bg); - border-radius: 16px; - padding: 18px; + border-radius: 14px; + padding: 14px 12px; text-align: center; } + .stat-num { + font-size: clamp(20px, 2.8vw, 30px); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .stat-unit { + font-size: 12px; + } + + .dual-stat-card.long .stat-num { + font-size: clamp(18px, 2.4vw, 26px); + letter-spacing: -0.02em; + } + .emoji-row { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; + grid-template-columns: repeat(2, minmax(260px, 1fr)); + gap: 20px; + margin: 0 -12px; } .emoji-card { border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08)); border-radius: 16px; - padding: 16px; + padding: 18px 16px; display: flex; flex-direction: column; gap: 10px; diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 883acc3..8ba3f92 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -21,6 +21,7 @@ interface DualReportData { isSentByMe: boolean senderUsername?: string } | null + firstChatMessages?: DualReportMessage[] yearFirstChat?: { createTime: number createTimeStr: string @@ -257,11 +258,72 @@ function DualReportWindow() { const yearTitle = reportData.year === 0 ? '全部时间' : `${reportData.year}年` const firstChat = reportData.firstChat + const firstChatMessages = (reportData.firstChatMessages && reportData.firstChatMessages.length > 0) + ? reportData.firstChatMessages.slice(0, 3) + : firstChat + ? [{ + content: firstChat.content, + isSentByMe: firstChat.isSentByMe, + createTime: firstChat.createTime, + createTimeStr: firstChat.createTimeStr + }] + : [] const daysSince = firstChat ? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000)) : null const yearFirstChat = reportData.yearFirstChat const stats = reportData.stats + const statItems = [ + { label: '总消息数', value: stats.totalMessages }, + { label: '总字数', value: stats.totalWords }, + { label: '图片', value: stats.imageCount }, + { label: '语音', value: stats.voiceCount }, + { label: '表情', value: stats.emojiCount }, + ] + + const decodeEntities = (text: string) => ( + text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + ) + + const stripCdata = (text: string) => text.replace(//g, '$1') + + const extractXmlText = (content: string) => { + const titleMatch = content.match(/([\s\S]*?)<\/title>/i) + if (titleMatch?.[1]) return titleMatch[1] + const descMatch = content.match(/<des>([\s\S]*?)<\/des>/i) + if (descMatch?.[1]) return descMatch[1] + const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i) + if (summaryMatch?.[1]) return summaryMatch[1] + const contentMatch = content.match(/<content>([\s\S]*?)<\/content>/i) + if (contentMatch?.[1]) return contentMatch[1] + return '' + } + + const formatMessageContent = (content?: string) => { + const raw = String(content || '').trim() + if (!raw) return '(空)' + const hasXmlTag = /<\s*[a-zA-Z]+[^>]*>/.test(raw) + const looksLikeXml = /<\?xml|<msg\b|<appmsg\b|<sysmsg\b|<appattach\b|<emoji\b|<img\b|<voip\b/i.test(raw) + || hasXmlTag + if (!looksLikeXml) return raw + const extracted = extractXmlText(raw) + if (!extracted) return '(XML消息)' + return decodeEntities(stripCdata(extracted).trim()) || '(XML消息)' + } + const formatFullDate = (timestamp: number) => { + const d = new Date(timestamp) + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const hour = String(d.getHours()).padStart(2, '0') + const minute = String(d.getMinutes()).padStart(2, '0') + return `${year}/${month}/${day} ${hour}:${minute}` + } return ( <div className="annual-report-window dual-report-window"> @@ -279,7 +341,7 @@ function DualReportWindow() { <div className="report-container"> <section className="section"> <div className="label-text">WEFLOW · DUAL REPORT</div> - <h1 className="hero-title">{yearTitle}<br />双人聊天报告</h1> + <h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1> <hr className="divider" /> <div className="dual-names"> <span>{reportData.selfName}</span> @@ -293,20 +355,33 @@ 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">{firstChat.createTimeStr}</div> + <> + <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="dual-info-card"> - <div className="info-label">距今天数</div> - <div className="info-value">{daysSince} 天</div> - </div> - <div className="dual-info-card full"> - <div className="info-label">首条消息</div> - <div className="info-value">{firstChat.content || '(空)'}</div> - </div> - </div> + {firstChatMessages.length > 0 ? ( + <div className="dual-message-list"> + {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> + <div className="message-content">{formatMessageContent(msg.content)}</div> + </div> + ))} + </div> + ) : null} + </> ) : ( <p className="hero-desc">暂无首条消息</p> )} @@ -314,12 +389,14 @@ function DualReportWindow() { {yearFirstChat ? ( <section className="section"> - <div className="label-text">今年首次聊天</div> - <h2 className="hero-title">新一年的开场</h2> + <div className="label-text">第一段对话</div> + <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">{yearFirstChat.createTimeStr}</div> + <div className="info-label">第一段对话时间</div> + <div className="info-value">{formatFullDate(yearFirstChat.createTime)}</div> </div> <div className="dual-info-card"> <div className="info-label">发起者</div> @@ -329,8 +406,10 @@ function DualReportWindow() { <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} · {msg.createTimeStr}</div> - <div className="message-content">{msg.content || '(空)'}</div> + <div className="message-meta"> + {msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)} + </div> + <div className="message-content">{formatMessageContent(msg.content)}</div> </div> ))} </div> @@ -347,26 +426,16 @@ function DualReportWindow() { <div className="label-text">年度统计</div> <h2 className="hero-title">{yearTitle}数据概览</h2> <div className="dual-stat-grid"> - <div className="dual-stat-card"> - <div className="stat-num">{stats.totalMessages.toLocaleString()}</div> - <div className="stat-unit">总消息数</div> - </div> - <div className="dual-stat-card"> - <div className="stat-num">{stats.totalWords.toLocaleString()}</div> - <div className="stat-unit">总字数</div> - </div> - <div className="dual-stat-card"> - <div className="stat-num">{stats.imageCount.toLocaleString()}</div> - <div className="stat-unit">图片</div> - </div> - <div className="dual-stat-card"> - <div className="stat-num">{stats.voiceCount.toLocaleString()}</div> - <div className="stat-unit">语音</div> - </div> - <div className="dual-stat-card"> - <div className="stat-num">{stats.emojiCount.toLocaleString()}</div> - <div className="stat-unit">表情</div> - </div> + {statItems.map((item) => { + const valueText = item.value.toLocaleString() + const isLong = valueText.length > 7 + return ( + <div key={item.label} className={`dual-stat-card ${isLong ? 'long' : ''}`}> + <div className="stat-num">{valueText}</div> + <div className="stat-unit">{item.label}</div> + </div> + ) + })} </div> <div className="emoji-row"> diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index b67b8f8..68e2cf5 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -352,6 +352,12 @@ export interface ElectronAPI { isSentByMe: boolean senderUsername?: string } | null + firstChatMessages?: Array<{ + content: string + isSentByMe: boolean + createTime: number + createTimeStr: string + }> yearFirstChat?: { createTime: number createTimeStr: string