diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index 607872b..87a6dc5 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -69,6 +69,20 @@ export interface AnnualReportData { phrase: string count: number }[] + snsStats?: { + totalPosts: number + typeCounts?: Record + topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[] + topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] + } + lostFriend: { + username: string + displayName: string + avatarUrl?: string + earlyCount: number + lateCount: number + periodDesc: string + } | null } class AnnualReportService { @@ -402,6 +416,11 @@ class AnnualReportService { const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000) const endTime = isAllTime ? 0 : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000) + const now = new Date() + // 全局统计始终使用自然年范围 (Jan 1st - Now/YearEnd) + const actualStartTime = startTime + const actualEndTime = endTime + let totalMessages = 0 const contactStats = new Map() const monthlyStats = new Map>() @@ -422,7 +441,7 @@ class AnnualReportService { const CONVERSATION_GAP = 3600 this.reportProgress('统计会话消息...', 20, onProgress) - const result = await wcdbService.getAnnualReportStats(sessionIds, startTime, endTime) + const result = await wcdbService.getAnnualReportStats(sessionIds, actualStartTime, actualEndTime) if (!result.success || !result.data) { return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' } } @@ -476,7 +495,7 @@ class AnnualReportService { } this.reportProgress('加载扩展统计... (初始化)', 30, onProgress) - const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd) + const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd) if (extras.success && extras.data) { this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress) const extrasData = extras.data as any @@ -556,7 +575,7 @@ class AnnualReportService { // 为保持功能完整,我们进行深度集成的轻量遍历: for (let i = 0; i < sessionIds.length; i++) { const sessionId = sessionIds[i] - const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, startTime, endTime) + const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, actualStartTime, actualEndTime) if (!cursor.success || !cursor.cursor) continue let lastDayIndex: number | null = null @@ -691,7 +710,7 @@ class AnnualReportService { if (!streakComputedInLoop) { this.reportProgress('计算连续聊天...', 45, onProgress) - const streakResult = await this.computeLongestStreak(sessionIds, startTime, endTime, onProgress, 45, 75) + const streakResult = await this.computeLongestStreak(sessionIds, actualStartTime, actualEndTime, onProgress, 45, 75) if (streakResult.days > longestStreakDays) { longestStreakDays = streakResult.days longestStreakSessionId = streakResult.sessionId @@ -700,6 +719,42 @@ class AnnualReportService { } } + // 获取朋友圈统计 + this.reportProgress('分析朋友圈数据...', 75, onProgress) + let snsStatsResult: { + totalPosts: number + typeCounts?: Record + topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[] + topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] + } | undefined + + const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime) + + if (snsStats.success && snsStats.data) { + const d = snsStats.data + const usersToFetch = new Set() + d.topLikers?.forEach((u: any) => usersToFetch.add(u.username)) + d.topLiked?.forEach((u: any) => usersToFetch.add(u.username)) + + const snsUserIds = Array.from(usersToFetch) + const [snsDisplayNames, snsAvatarUrls] = await Promise.all([ + wcdbService.getDisplayNames(snsUserIds), + wcdbService.getAvatarUrls(snsUserIds) + ]) + + const getSnsUserInfo = (username: string) => ({ + displayName: snsDisplayNames.success && snsDisplayNames.map ? (snsDisplayNames.map[username] || username) : username, + avatarUrl: snsAvatarUrls.success && snsAvatarUrls.map ? snsAvatarUrls.map[username] : undefined + }) + + snsStatsResult = { + totalPosts: d.totalPosts || 0, + typeCounts: d.typeCounts, + topLikers: (d.topLikers || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })), + topLiked: (d.topLiked || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })) + } + } + this.reportProgress('整理联系人信息...', 85, onProgress) const contactIds = Array.from(contactStats.keys()) @@ -903,6 +958,128 @@ class AnnualReportService { .slice(0, 32) .map(([phrase, count]) => ({ phrase, count })) + // 曾经的好朋友 (Once Best Friend / Lost Friend) + let lostFriend: AnnualReportData['lostFriend'] = null + let maxRatio = 5 + let bestEarlyCount = 0 + let bestLateCount = 0 + let bestSid = '' + let bestPeriodDesc = '' + + const currentMonthIndex = new Date().getMonth() + 1 // 1-12 + + const currentYearNum = now.getFullYear() + + if (isAllTime) { + const days = Object.keys(d.daily).sort() + if (days.length >= 2) { + const firstDay = Math.floor(new Date(days[0]).getTime() / 1000) + const lastDay = Math.floor(new Date(days[days.length - 1]).getTime() / 1000) + const midPoint = Math.floor((firstDay + lastDay) / 2) + + this.reportProgress('分析历史趋势 (1/2)...', 86, onProgress) + const earlyRes = await wcdbService.getAggregateStats(sessionIds, 0, midPoint) + this.reportProgress('分析历史趋势 (2/2)...', 88, onProgress) + const lateRes = await wcdbService.getAggregateStats(sessionIds, midPoint, 0) + + if (earlyRes.success && lateRes.success && earlyRes.data) { + const earlyData = earlyRes.data.sessions || {} + const lateData = (lateRes.data?.sessions) || {} + for (const sid of sessionIds) { + const e = earlyData[sid] || { sent: 0, received: 0 } + const l = lateData[sid] || { sent: 0, received: 0 } + const early = (e.sent || 0) + (e.received || 0) + const late = (l.sent || 0) + (l.received || 0) + if (early > 100 && early > late * 5) { + const ratio = early / (late || 1) + if (ratio > maxRatio) { + maxRatio = ratio + bestEarlyCount = early + bestLateCount = late + bestSid = sid + bestPeriodDesc = '账号历史早期' + } + } + } + } + } + } else if (year === currentYearNum) { + // 当前年份:独立获取过去12个月的滚动数据 + this.reportProgress('分析近期好友趋势...', 86, onProgress) + // 往前数12个月的起点、中点、终点 + const rollingStart = Math.floor(new Date(now.getFullYear(), now.getMonth() - 11, 1).getTime() / 1000) + const rollingMid = Math.floor(new Date(now.getFullYear(), now.getMonth() - 5, 1).getTime() / 1000) + const rollingEnd = Math.floor(now.getTime() / 1000) + + const earlyRes = await wcdbService.getAggregateStats(sessionIds, rollingStart, rollingMid - 1) + const lateRes = await wcdbService.getAggregateStats(sessionIds, rollingMid, rollingEnd) + + if (earlyRes.success && lateRes.success && earlyRes.data) { + const earlyData = earlyRes.data.sessions || {} + const lateData = lateRes.data?.sessions || {} + for (const sid of sessionIds) { + const e = earlyData[sid] || { sent: 0, received: 0 } + const l = lateData[sid] || { sent: 0, received: 0 } + const early = (e.sent || 0) + (e.received || 0) + const late = (l.sent || 0) + (l.received || 0) + if (early > 80 && early > late * 5) { + const ratio = early / (late || 1) + if (ratio > maxRatio) { + maxRatio = ratio + bestEarlyCount = early + bestLateCount = late + bestSid = sid + bestPeriodDesc = '去年的这个时候' + } + } + } + } + } else { + // 指定完整年份 (1-6 vs 7-12) + for (const [sid, stat] of Object.entries(d.sessions)) { + const s = stat as any + const mWeights = s.monthly || {} + let early = 0 + let late = 0 + for (let m = 1; m <= 6; m++) early += mWeights[m] || 0 + for (let m = 7; m <= 12; m++) late += mWeights[m] || 0 + + if (early > 80 && early > late * 5) { + const ratio = early / (late || 1) + if (ratio > maxRatio) { + maxRatio = ratio + bestEarlyCount = early + bestLateCount = late + bestSid = sid + bestPeriodDesc = `${year}年上半年` + } + } + } + } + + if (bestSid) { + let info = contactInfoMap.get(bestSid) + // 如果 contactInfoMap 中没有该联系人,则单独获取 + if (!info) { + const [displayNameRes, avatarUrlRes] = await Promise.all([ + wcdbService.getDisplayNames([bestSid]), + wcdbService.getAvatarUrls([bestSid]) + ]) + info = { + displayName: displayNameRes.success && displayNameRes.map ? (displayNameRes.map[bestSid] || bestSid) : bestSid, + avatarUrl: avatarUrlRes.success && avatarUrlRes.map ? avatarUrlRes.map[bestSid] : undefined + } + } + lostFriend = { + username: bestSid, + displayName: info?.displayName || bestSid, + avatarUrl: info?.avatarUrl, + earlyCount: bestEarlyCount, + lateCount: bestLateCount, + periodDesc: bestPeriodDesc + } + } + const reportData: AnnualReportData = { year: reportYear, totalMessages, @@ -917,7 +1094,9 @@ class AnnualReportService { mutualFriend, socialInitiative, responseSpeed, - topPhrases + topPhrases, + snsStats: snsStatsResult, + lostFriend } return { success: true, data: reportData } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 3609f00..ad35ec8 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -208,145 +208,18 @@ class ExportService { } /** - * 解析 ext_buffer 二进制数据,提取群成员的群昵称 - * ext_buffer 包含类似 protobuf 编码的数据,格式示例: - * wxid_xxx群昵称wxid_yyy群昵称... - */ - private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map { - const nicknameMap = new Map() - - try { - // 将 buffer 转为字符串,允许部分乱码 - const raw = buffer.toString('utf8') - - // 提取所有 wxid 格式的字符串: wxid_ 或 wxid_后跟字母数字下划线 - const wxidPattern = /wxid_[a-z0-9_]+/gi - const wxids = raw.match(wxidPattern) || [] - - // 对每个 wxid,尝试提取其后的群昵称 - for (const wxid of wxids) { - const wxidLower = wxid.toLowerCase() - const wxidIndex = raw.toLowerCase().indexOf(wxidLower) - - if (wxidIndex === -1) continue - - // 从 wxid 结束位置开始查找 - const afterWxid = raw.slice(wxidIndex + wxid.length) - - // 提取紧跟在 wxid 后面的可打印字符(中文、字母、数字等) - // 跳过前面的不可打印字符和特定控制字符 - let nickname = '' - let foundStart = false - - for (let i = 0; i < afterWxid.length && i < 100; i++) { - const char = afterWxid[i] - const code = char.charCodeAt(0) - - // 判断是否为可打印字符(中文、字母、数字、常见符号) - const isPrintable = ( - (code >= 0x4E00 && code <= 0x9FFF) || // 中文 - (code >= 0x3000 && code <= 0x303F) || // CJK 符号 - (code >= 0xFF00 && code <= 0xFFEF) || // 全角字符 - (code >= 0x20 && code <= 0x7E) // ASCII 可打印字符 - ) - - if (isPrintable && code !== 0x01 && code !== 0x18) { - foundStart = true - nickname += char - } else if (foundStart) { - // 遇到不可打印字符,停止 - break - } - } - - // 清理昵称:去除前后空白和特殊字符 - nickname = this.normalizeGroupNickname(nickname) - - // 只保存有效的群昵称(长度 > 0 且 < 50) - if (nickname && nickname.length > 0 && nickname.length < 50) { - nicknameMap.set(wxidLower, nickname) - } - } - } catch (e) { - // 解析失败时返回空 Map - console.error('Failed to parse ext_buffer:', e) - } - - return nicknameMap - } - - /** - * 从 contact.db 的 chat_room 表获取群成员的群昵称 - * @param chatroomId 群聊ID (如 "xxxxx@chatroom") - * @returns Map + * 从 DLL 获取群成员的群昵称 */ async getGroupNicknamesForRoom(chatroomId: string): Promise> { - console.log('========== getGroupNicknamesForRoom START ==========', chatroomId) try { - // 查询 contact.db 的 chat_room 表 - // path设为null,因为contact.db已经随handle一起打开了 - const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'` - console.log('执行SQL查询:', sql) - - const result = await wcdbService.execQuery('contact', null, sql) - console.log('execQuery结果:', { success: result.success, rowCount: result.rows?.length, error: result.error }) - - if (!result.success || !result.rows || result.rows.length === 0) { - console.log('❌ 群昵称查询失败或无数据:', chatroomId, result.error) - return new Map() + const result = await wcdbService.getGroupNicknames(chatroomId) + if (result.success && result.nicknames) { + return new Map(Object.entries(result.nicknames)) } - - let extBuffer = result.rows[0].ext_buffer - console.log('ext_buffer原始类型:', typeof extBuffer, 'isBuffer:', Buffer.isBuffer(extBuffer)) - - // execQuery返回的二进制数据会被编码为字符串(hex或base64) - // 需要转换回Buffer - if (typeof extBuffer === 'string') { - console.log('🔄 ext_buffer是字符串,尝试转换为Buffer...') - - // 尝试判断是hex还是base64 - if (this.looksLikeHex(extBuffer)) { - console.log('✅ 检测到hex编码,使用hex解码') - extBuffer = Buffer.from(extBuffer, 'hex') - } else if (this.looksLikeBase64(extBuffer)) { - console.log('✅ 检测到base64编码,使用base64解码') - extBuffer = Buffer.from(extBuffer, 'base64') - } else { - // 默认尝试hex - console.log(' 无法判断编码格式,默认尝试hex') - try { - extBuffer = Buffer.from(extBuffer, 'hex') - } catch (e) { - console.log('❌ hex解码失败,尝试base64') - extBuffer = Buffer.from(extBuffer, 'base64') - } - } - console.log('✅ 转换后的Buffer长度:', extBuffer.length) - } - - if (!extBuffer || !Buffer.isBuffer(extBuffer)) { - console.log('❌ ext_buffer转换失败,不是Buffer类型:', typeof extBuffer) - return new Map() - } - - console.log('✅ 开始解析ext_buffer, 长度:', extBuffer.length) - const nicknamesMap = this.parseGroupNicknamesFromExtBuffer(extBuffer) - console.log('✅ 解析完成, 找到', nicknamesMap.size, '个群昵称') - - // 打印前5个群昵称作为示例 - let count = 0 - for (const [wxid, nickname] of nicknamesMap.entries()) { - if (count++ < 5) { - console.log(` - ${wxid}: "${nickname}"`) - } - } - - return nicknamesMap - } catch (e) { - console.error('❌ getGroupNicknamesForRoom异常:', e) return new Map() - } finally { - console.log('========== getGroupNicknamesForRoom END ==========') + } catch (e) { + console.error('getGroupNicknamesForRoom error:', e) + return new Map() } } @@ -2329,11 +2202,9 @@ class ExportService { } // 预加载群昵称 (仅群聊且完整列模式) - console.log('预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId) const groupNicknamesMap = (isGroup && !useCompactColumns) ? await this.getGroupNicknamesForRoom(sessionId) : new Map() - console.log('群昵称Map大小:', groupNicknamesMap.size) // 填充数据 @@ -2475,11 +2346,11 @@ class ExportService { ) : (mediaItem?.relativePath || this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(msg.localId) - )) + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(msg.localId) + )) // 调试日志 if (msg.localType === 3 || msg.localType === 47) { @@ -2724,11 +2595,11 @@ class ExportService { ) : (mediaItem?.relativePath || this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(msg.localId) - )) + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(msg.localId) + )) let senderRole: string let senderWxid: string diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index bd4e123..889efdf 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -93,99 +93,16 @@ class GroupAnalyticsService { return { success: true } } - private looksLikeHex(s: string): boolean { - if (s.length % 2 !== 0) return false - return /^[0-9a-fA-F]+$/.test(s) - } - - private looksLikeBase64(s: string): boolean { - if (s.length % 4 !== 0) return false - return /^[A-Za-z0-9+/=]+$/.test(s) - } - /** - * 解析 ext_buffer 二进制数据,提取群成员的群昵称 - */ - private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map { - const nicknameMap = new Map() - - try { - const raw = buffer.toString('utf8') - const wxidPattern = /wxid_[a-z0-9_]+/gi - const wxids = raw.match(wxidPattern) || [] - - for (const wxid of wxids) { - const wxidLower = wxid.toLowerCase() - const wxidIndex = raw.toLowerCase().indexOf(wxidLower) - if (wxidIndex === -1) continue - - const afterWxid = raw.slice(wxidIndex + wxid.length) - let nickname = '' - let foundStart = false - - for (let i = 0; i < afterWxid.length && i < 100; i++) { - const char = afterWxid[i] - const code = char.charCodeAt(0) - const isPrintable = ( - (code >= 0x4E00 && code <= 0x9FFF) || - (code >= 0x3000 && code <= 0x303F) || - (code >= 0xFF00 && code <= 0xFFEF) || - (code >= 0x20 && code <= 0x7E) - ) - - if (isPrintable && code !== 0x01 && code !== 0x18) { - foundStart = true - nickname += char - } else if (foundStart) { - break - } - } - - nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '') - if (nickname && nickname.length < 50) { - nicknameMap.set(wxidLower, nickname) - } - } - } catch (e) { - console.error('Failed to parse ext_buffer:', e) - } - - return nicknameMap - } - - /** - * 从 contact.db 的 chat_room 表获取群成员的群昵称 + * 从 DLL 获取群成员的群昵称 */ private async getGroupNicknamesForRoom(chatroomId: string): Promise> { try { - const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'` - const result = await wcdbService.execQuery('contact', null, sql) - - if (!result.success || !result.rows || result.rows.length === 0) { - return new Map() + const result = await wcdbService.getGroupNicknames(chatroomId) + if (result.success && result.nicknames) { + return new Map(Object.entries(result.nicknames)) } - - let extBuffer = result.rows[0].ext_buffer - - if (typeof extBuffer === 'string') { - if (this.looksLikeHex(extBuffer)) { - extBuffer = Buffer.from(extBuffer, 'hex') - } else if (this.looksLikeBase64(extBuffer)) { - extBuffer = Buffer.from(extBuffer, 'base64') - } else { - try { - extBuffer = Buffer.from(extBuffer, 'hex') - } catch { - extBuffer = Buffer.from(extBuffer, 'base64') - } - } - } - - if (!extBuffer || !Buffer.isBuffer(extBuffer)) { - return new Map() - } - - return this.parseGroupNicknamesFromExtBuffer(extBuffer) + return new Map() } catch (e) { console.error('getGroupNicknamesForRoom error:', e) return new Map() diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 14bf1ff..b6b17c9 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -35,6 +35,7 @@ export class WcdbCore { private wcdbGetGroupMemberCount: any = null private wcdbGetGroupMemberCounts: any = null private wcdbGetGroupMembers: any = null + private wcdbGetGroupNicknames: any = null private wcdbGetMessageTables: any = null private wcdbGetMessageMeta: any = null private wcdbGetContact: any = null @@ -57,6 +58,7 @@ export class WcdbCore { private wcdbGetDbStatus: any = null private wcdbGetVoiceData: any = null private wcdbGetSnsTimeline: any = null + private wcdbGetSnsAnnualStats: any = null private wcdbVerifyUser: any = null private avatarUrlCache: Map = new Map() private readonly avatarCacheTtlMs = 10 * 60 * 1000 @@ -333,6 +335,13 @@ export class WcdbCore { // wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json) this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)') + // wcdb_status wcdb_get_group_nicknames(wcdb_handle handle, const char* chatroom_id, char** out_json) + try { + this.wcdbGetGroupNicknames = this.lib.func('int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)') + } catch { + this.wcdbGetGroupNicknames = null + } + // wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json) this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)') @@ -369,6 +378,13 @@ export class WcdbCore { this.wcdbGetAnnualReportExtras = null } + // wcdb_status wcdb_get_logs(char** out_json) + try { + this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)') + } catch { + this.wcdbGetLogs = null + } + // wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) try { this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)') @@ -431,6 +447,13 @@ export class WcdbCore { this.wcdbGetSnsTimeline = null } + // wcdb_status wcdb_get_sns_annual_stats(wcdb_handle handle, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) + try { + this.wcdbGetSnsAnnualStats = this.lib.func('int32 wcdb_get_sns_annual_stats(int64 handle, int32 begin, int32 end, _Out_ void** outJson)') + } catch { + this.wcdbGetSnsAnnualStats = null + } + // void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len) try { this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)') @@ -1002,6 +1025,28 @@ export class WcdbCore { } } + async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetGroupNicknames) { + return { success: false, error: '当前 DLL 版本不支持获取群昵称接口' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetGroupNicknames(this.handle, chatroomId, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取群昵称失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析群昵称失败' } + const nicknames = JSON.parse(jsonStr) + return { success: true, nicknames } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } @@ -1343,13 +1388,31 @@ export class WcdbCore { } } + async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> { + if (!this.lib) return { success: false, error: 'DLL 未加载' } + if (!this.wcdbGetLogs) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetLogs(outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取日志失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析日志失败' } + return { success: true, logs: JSON.parse(jsonStr) } + } catch (e) { + return { success: false, error: String(e) } + } + } + async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } try { + if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' } const outPtr = [null as any] - const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr) + const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr) if (result !== 0 || !outPtr[0]) { return { success: false, error: `执行查询失败: ${result}` } } @@ -1502,4 +1565,29 @@ export class WcdbCore { return { success: false, error: String(e) } } } + + async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + if (!this.wcdbGetSnsAnnualStats) { + return { success: false, error: 'wcdbGetSnsAnnualStats 未找到' } + } + await new Promise(resolve => setImmediate(resolve)) + const outPtr = [null as any] + const result = this.wcdbGetSnsAnnualStats(this.handle, beginTimestamp, endTimestamp, outPtr) + await new Promise(resolve => setImmediate(resolve)) + + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `getSnsAnnualStats failed: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: 'Failed to decode JSON' } + return { success: true, data: JSON.parse(jsonStr) } + } catch (e) { + console.error('getSnsAnnualStats 异常:', 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 b885088..107c2c8 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -229,6 +229,11 @@ export class WcdbService { return this.callWorker('getGroupMembers', { chatroomId }) } + // 获取群成员群名片昵称 + async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record; error?: string }> { + return this.callWorker('getGroupNicknames', { chatroomId }) + } + /** * 获取消息表列表 */ @@ -369,6 +374,20 @@ export class WcdbService { return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime }) } + /** + * 获取朋友圈年度统计 + */ + async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp }) + } + + /** + * 获取 DLL 内部日志 + */ + async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> { + return this.callWorker('getLogs') + } + /** * 验证 Windows Hello */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index b03d49a..259b372 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -56,6 +56,9 @@ if (parentPort) { case 'getGroupMembers': result = await core.getGroupMembers(payload.chatroomId) break + case 'getGroupNicknames': + result = await core.getGroupNicknames(payload.chatroomId) + break case 'getMessageTables': result = await core.getMessageTables(payload.sessionId) break @@ -119,6 +122,12 @@ if (parentPort) { case 'getSnsTimeline': result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) break + case 'getSnsAnnualStats': + result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp) + break + case 'getLogs': + result = await core.getLogs() + break case 'verifyUser': result = await core.verifyUser(payload.message, payload.hwnd) break diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index c4233c0..d63b6bd 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/pages/AnnualReportWindow.scss b/src/pages/AnnualReportWindow.scss index db26b11..9a8efec 100644 --- a/src/pages/AnnualReportWindow.scss +++ b/src/pages/AnnualReportWindow.scss @@ -1279,3 +1279,134 @@ 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; + + .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; + } + } +} + +.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; +} + +@keyframes flowAcross { + 0% { + left: -20%; + opacity: 0; + } + + 10% { + opacity: 0.8; + } + + 50% { + opacity: 0.4; + } + + 90% { + opacity: 0.1; + } + + 100% { + left: 120%; + opacity: 0; + } +} + +@keyframes fadeInRight { + from { + opacity: 0; + transform: translateX(-20px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes fadeInLeft { + from { + opacity: 0; + transform: translateX(20px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index 8def63d..0e7db77 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -71,6 +71,20 @@ interface AnnualReportData { socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null topPhrases?: { phrase: string; count: number }[] + snsStats?: { + totalPosts: number + typeCounts?: Record + topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[] + topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] + } + lostFriend: { + username: string + displayName: string + avatarUrl?: string + earlyCount: number + lateCount: number + periodDesc: string + } | null } interface SectionInfo { @@ -274,6 +288,8 @@ function AnnualReportWindow() { responseSpeed: useRef(null), topPhrases: useRef(null), ranking: useRef(null), + sns: useRef(null), + lostFriend: useRef(null), ending: useRef(null), } @@ -373,10 +389,16 @@ function AnnualReportWindow() { if (reportData.responseSpeed) { sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed }) } + if (reportData.lostFriend) { + sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend }) + } if (reportData.topPhrases && reportData.topPhrases.length > 0) { sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases }) } sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking }) + if (reportData.snsStats && reportData.snsStats.totalPosts > 0) { + sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns }) + } sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending }) return sections } @@ -741,7 +763,7 @@ function AnnualReportWindow() { ) } - const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData + const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData const topFriend = coreFriends[0] const mostActive = getMostActiveTime(activityHeatmap.data) const socialStoryName = topFriend?.displayName || '好友' @@ -1024,6 +1046,41 @@ function AnnualReportWindow() { )} + {/* 曾经的好朋友 */} + {lostFriend && ( +
+
曾经的好朋友
+

{lostFriend.displayName}

+
+ {formatNumber(lostFriend.earlyCount)} + 条消息 +
+

+ 在 {lostFriend.periodDesc} +
你们曾有聊不完的话题 +

+
+
+ + TA +
+
+
+
+
+
+
+ + +
+
+

+ 人类发明后悔 +
来证明拥有的珍贵 +

+
+ )} + {/* 年度常用语 - 词云 */} {topPhrases && topPhrases.length > 0 && (
@@ -1041,6 +1098,57 @@ function AnnualReportWindow() {
)} + {/* 朋友圈 */} + {reportData.snsStats && reportData.snsStats.totalPosts > 0 && ( +
+
朋友圈
+

记录生活时刻

+

+ 这一年,你发布了 +

+
+ {reportData.snsStats.totalPosts} + 条朋友圈 +
+ +
+ {reportData.snsStats.topLikers.length > 0 && ( +
+

更关心你的Ta

+
+ {reportData.snsStats.topLikers.slice(0, 3).map((u, i) => ( +
+ +
+ {u.displayName} +
+ {u.count}赞 +
+ ))} +
+
+ )} + + {reportData.snsStats.topLiked.length > 0 && ( +
+

你最关心的Ta

+
+ {reportData.snsStats.topLiked.slice(0, 3).map((u, i) => ( +
+ +
+ {u.displayName} +
+ {u.count}赞 +
+ ))} +
+
+ )} +
+
+ )} + {/* 好友排行 */}
好友排行
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index eb9188f..645cd44 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -547,10 +547,41 @@ .sns-content-wrapper { flex: 1; display: flex; + flex-direction: column; overflow: hidden; position: relative; } + .sns-notice-banner { + margin: 16px 24px 0 24px; + padding: 10px 16px; + background: rgba(var(--accent-color-rgb), 0.08); + border-radius: 10px; + border: 1px solid rgba(var(--accent-color-rgb), 0.2); + display: flex; + align-items: center; + gap: 10px; + color: var(--accent-color); + font-size: 13px; + font-weight: 500; + animation: banner-slide-down 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); + + svg { + flex-shrink: 0; + } + } + + @keyframes banner-slide-down { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } .sns-content { flex: 1; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index e3efe81..6e512a5 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react' -import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight } from 'lucide-react' +import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight, AlertTriangle } from 'lucide-react' import { Avatar } from '../components/Avatar' import { ImagePreview } from '../components/ImagePreview' import JumpToDateDialog from '../components/JumpToDateDialog' @@ -412,6 +412,10 @@ export default function SnsPage() {
+
+ + 由于技术限制,当前无法解密显示部分图片与视频等加密资源文件 +
{loadingNewer && (