diff --git a/.gitignore b/.gitignore index 8b7210e..c424a77 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,5 @@ wcdb/ *info 概述.md chatlab-format.md -*.bak \ No newline at end of file +*.bak +AGENTS.md \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index ebfd428..1e0de48 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -985,8 +985,8 @@ function registerIpcHandlers() { return analyticsService.getOverallStatistics(force) }) - ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => { - return analyticsService.getContactRankings(limit) + ipcMain.handle('analytics:getContactRankings', async (_, limit?: number, beginTimestamp?: number, endTimestamp?: number) => { + return analyticsService.getContactRankings(limit, beginTimestamp, endTimestamp) }) ipcMain.handle('analytics:getTimeDistribution', async () => { diff --git a/electron/preload.ts b/electron/preload.ts index b6a8559..29d3829 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -189,7 +189,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 数据分析 analytics: { getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force), - getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit), + getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) => + ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp), getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'), getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'), setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames), diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 8c04476..80a7169 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -31,6 +31,7 @@ export interface ContactRanking { username: string displayName: string avatarUrl?: string + wechatId?: string messageCount: number sentCount: number receivedCount: number @@ -576,7 +577,11 @@ class AnalyticsService { } } - async getContactRankings(limit: number = 20): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> { + async getContactRankings( + limit: number = 20, + beginTimestamp: number = 0, + endTimestamp: number = 0 + ): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> { try { const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } @@ -586,7 +591,7 @@ class AnalyticsService { return { success: false, error: '未找到消息会话' } } - const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0) + const result = await this.getAggregateWithFallback(sessionInfo.usernames, beginTimestamp, endTimestamp) if (!result.success || !result.data) { return { success: false, error: result.error || '聚合统计失败' } } @@ -594,9 +599,10 @@ class AnalyticsService { const d = result.data const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap) const usernames = Object.keys(sessions) - const [displayNames, avatarUrls] = await Promise.all([ + const [displayNames, avatarUrls, aliasMap] = await Promise.all([ wcdbService.getDisplayNames(usernames), - wcdbService.getAvatarUrls(usernames) + wcdbService.getAvatarUrls(usernames), + this.getAliasMap(usernames) ]) const rankings: ContactRanking[] = usernames @@ -608,10 +614,13 @@ class AnalyticsService { const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined + const alias = aliasMap[username] || '' + const wechatId = alias || (!username.startsWith('wxid_') ? username : '') return { username, displayName, avatarUrl, + wechatId, messageCount: stat.total, sentCount: stat.sent, receivedCount: stat.received, diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts index f62f947..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 { @@ -26,13 +32,17 @@ export interface DualReportStats { friendTopEmojiMd5?: string myTopEmojiUrl?: string friendTopEmojiUrl?: string + myTopEmojiCount?: number + friendTopEmojiCount?: number } export interface DualReportData { year: number selfName: string + selfAvatarUrl?: string friendUsername: string friendName: string + friendAvatarUrl?: string firstChat: DualReportFirstChat | null firstChatMessages?: DualReportMessage[] yearFirstChat?: { @@ -42,9 +52,17 @@ export interface DualReportData { isSentByMe: boolean friendName: string firstThreeMessages: DualReportMessage[] + localType?: number + emojiMd5?: string + emojiCdnUrl?: string } | 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 +93,7 @@ class DualReportService { } const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const cleaned = suffixMatch ? suffixMatch[1] : trimmed - + return cleaned } @@ -168,26 +186,258 @@ class DualReportService { return `${month}/${day} ${hour}:${minute}` } - private extractEmojiUrl(content: string): string | undefined { - if (!content) return undefined - const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content) - if (attrMatch) { - let url = attrMatch[1].replace(/&/g, '&') - try { - if (url.includes('%')) { - url = decodeURIComponent(url) - } - } catch { } - return url + private getRecordField(record: Record | undefined | null, keys: string[]): any { + if (!record) return undefined + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(record, key) && record[key] !== undefined && record[key] !== null) { + return record[key] + } } - const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content) - return tagMatch?.[1] + return undefined } - private extractEmojiMd5(content: string): string | undefined { + private coerceNumber(raw: any): number { + if (raw === undefined || raw === null || raw === '') return NaN + if (typeof raw === 'number') return raw + if (typeof raw === 'bigint') return Number(raw) + if (Buffer.isBuffer(raw)) return parseInt(raw.toString('utf-8'), 10) + if (raw instanceof Uint8Array) return parseInt(Buffer.from(raw).toString('utf-8'), 10) + const parsed = parseInt(String(raw), 10) + return Number.isFinite(parsed) ? parsed : NaN + } + + private coerceString(raw: any): string { + if (raw === undefined || raw === null) return '' + if (typeof raw === 'string') return raw + if (Buffer.isBuffer(raw)) return this.decodeBinaryContent(raw) + if (raw instanceof Uint8Array) return this.decodeBinaryContent(Buffer.from(raw)) + return String(raw) + } + + private coerceBoolean(raw: any): boolean | undefined { + if (raw === undefined || raw === null || raw === '') return undefined + if (typeof raw === 'boolean') return raw + if (typeof raw === 'number') return raw !== 0 + + const normalized = String(raw).trim().toLowerCase() + if (!normalized) return undefined + + if (['1', 'true', 'yes', 'me', 'self', 'mine', 'sent', 'out', 'outgoing'].includes(normalized)) return true + if (['0', 'false', 'no', 'friend', 'peer', 'other', 'recv', 'received', 'in', 'incoming'].includes(normalized)) return false + return undefined + } + + private normalizeEmojiMd5(raw: string): string | undefined { + if (!raw) return undefined + const trimmed = raw.trim() + if (!trimmed) return undefined + const match = /([a-fA-F0-9]{16,64})/.exec(trimmed) + return match ? match[1].toLowerCase() : undefined + } + + private normalizeEmojiUrl(raw: string): string | undefined { + if (!raw) return undefined + let url = raw.trim().replace(/&/g, '&') + if (!url) return undefined + try { + if (url.includes('%')) { + url = decodeURIComponent(url) + } + } catch { } + return url || undefined + } + + private extractEmojiUrl(content: string | undefined): string | undefined { if (!content) return undefined - const match = /md5="([^"]+)"/i.exec(content) || /([^<]+)<\/md5>/i.exec(content) - return match?.[1] + const direct = this.normalizeEmojiUrl(content) + if (direct && /^https?:\/\//i.test(direct)) return direct + + const attrMatch = /(?:cdnurl|thumburl)\s*=\s*['"]([^'"]+)['"]/i.exec(content) + || /(?:cdnurl|thumburl)\s*=\s*([^'"\s>]+)/i.exec(content) + if (attrMatch) return this.normalizeEmojiUrl(attrMatch[1]) + + const tagMatch = /<(?:cdnurl|thumburl)>([^<]+)<\/(?:cdnurl|thumburl)>/i.exec(content) + || /(?:cdnurl|thumburl)[^>]*>([^<]+)/i.exec(content) + return this.normalizeEmojiUrl(tagMatch?.[1] || '') + } + + private extractEmojiMd5(content: string | undefined): string | undefined { + if (!content) return undefined + const direct = this.normalizeEmojiMd5(content) + if (direct && direct.length >= 24) return direct + + const match = /md5\s*=\s*['"]([a-fA-F0-9]{16,64})['"]/i.exec(content) + || /md5\s*=\s*([a-fA-F0-9]{16,64})/i.exec(content) + || /([a-fA-F0-9]{16,64})<\/md5>/i.exec(content) + return this.normalizeEmojiMd5(match?.[1] || '') + } + + private resolveEmojiOwner(item: any, content: string): boolean | undefined { + const sentFlag = this.coerceBoolean(this.getRecordField(item, [ + 'isMe', + 'is_me', + 'isSent', + 'is_sent', + 'isSend', + 'is_send', + 'fromMe', + 'from_me' + ])) + if (sentFlag !== undefined) return sentFlag + + const sideRaw = this.coerceString(this.getRecordField(item, ['side', 'sender', 'from', 'owner', 'role', 'direction'])).trim().toLowerCase() + if (sideRaw) { + if (['me', 'self', 'mine', 'out', 'outgoing', 'sent'].includes(sideRaw)) return true + if (['friend', 'peer', 'other', 'in', 'incoming', 'received', 'recv'].includes(sideRaw)) return false + } + + const prefixMatch = /^\s*([01])\s*:\s*/.exec(content) + if (prefixMatch) return prefixMatch[1] === '1' + return undefined + } + + private stripEmojiOwnerPrefix(content: string): string { + if (!content) return '' + return content.replace(/^\s*[01]\s*:\s*/, '') + } + + private parseEmojiCandidate(item: any): { isMe?: boolean; md5?: string; url?: string; count: number } { + const rawContent = this.coerceString(this.getRecordField(item, [ + 'content', + 'xml', + 'message_content', + 'messageContent', + 'msg', + 'payload', + 'raw' + ])) + const content = this.stripEmojiOwnerPrefix(rawContent) + + const countRaw = this.getRecordField(item, ['count', 'cnt', 'times', 'total', 'num']) + const parsedCount = this.coerceNumber(countRaw) + const count = Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0 + + const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(item, [ + 'md5', + 'emojiMd5', + 'emoji_md5', + 'emd5' + ]))) + const md5 = directMd5 || this.extractEmojiMd5(content) + + const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(item, [ + 'cdnUrl', + 'cdnurl', + 'emojiUrl', + 'emoji_url', + 'url', + 'thumbUrl', + 'thumburl' + ]))) + const url = directUrl || this.extractEmojiUrl(content) + + return { + isMe: this.resolveEmojiOwner(item, rawContent), + md5, + url, + count + } + } + + private getRowInt(row: Record, keys: string[], fallback = 0): number { + const raw = this.getRecordField(row, keys) + const parsed = this.coerceNumber(raw) + return Number.isFinite(parsed) ? parsed : fallback + } + + private decodeRowMessageContent(row: Record): string { + const messageContent = this.getRecordField(row, [ + 'message_content', + 'messageContent', + 'content', + 'msg_content', + 'msgContent', + 'WCDB_CT_message_content', + 'WCDB_CT_messageContent' + ]) + const compressContent = this.getRecordField(row, [ + 'compress_content', + 'compressContent', + 'compressed_content', + 'WCDB_CT_compress_content', + 'WCDB_CT_compressContent' + ]) + return this.decodeMessageContent(messageContent, compressContent) + } + + private async scanEmojiTopFallback( + sessionId: string, + beginTimestamp: number, + endTimestamp: number, + rawWxid: string, + cleanedWxid: string + ): Promise<{ my?: { md5: string; url?: string; count: number }; friend?: { md5: string; url?: string; count: number } }> { + const cursorResult = await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp) + if (!cursorResult.success || !cursorResult.cursor) return {} + + const tallyMap = new Map() + try { + let hasMore = true + while (hasMore) { + const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) + if (!batch.success || !Array.isArray(batch.rows)) break + + for (const row of batch.rows) { + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0) + if (localType !== 47) continue + + const rawContent = this.decodeRowMessageContent(row) + const content = this.stripEmojiOwnerPrefix(rawContent) + const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) + const md5 = directMd5 || this.extractEmojiMd5(content) + if (!md5) continue + + const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, [ + 'emoji_cdn_url', + 'emojiCdnUrl', + 'cdnurl', + 'cdn_url', + 'emoji_url', + 'emojiUrl', + 'url', + 'thumburl', + 'thumb_url' + ]))) + const url = directUrl || this.extractEmojiUrl(content) + const isMe = this.resolveIsSent(row, rawWxid, cleanedWxid) + const mapKey = `${isMe ? '1' : '0'}:${md5}` + const existing = tallyMap.get(mapKey) + if (existing) { + existing.count += 1 + if (!existing.url && url) existing.url = url + } else { + tallyMap.set(mapKey, { isMe, md5, url, count: 1 }) + } + } + hasMore = batch.hasMore === true + } + } finally { + await wcdbService.closeMessageCursor(cursorResult.cursor) + } + + let myTop: { md5: string; url?: string; count: number } | undefined + let friendTop: { md5: string; url?: string; count: number } | undefined + for (const entry of tallyMap.values()) { + if (entry.isMe) { + if (!myTop || entry.count > myTop.count) { + myTop = { md5: entry.md5, url: entry.url, count: entry.count } + } + } else if (!friendTop || entry.count > friendTop.count) { + friendTop = { md5: entry.md5, url: entry.url, count: entry.count } + } + } + + return { my: myTop, friend: friendTop } } private async getDisplayName(username: string, fallback: string): Promise { @@ -271,189 +521,222 @@ class DualReportService { if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) { myName = await this.getDisplayName(cleanedWxid, rawWxid) } + const avatarCandidates = Array.from(new Set([ + friendUsername, + rawWxid, + cleanedWxid + ].filter(Boolean) as string[])) + let selfAvatarUrl: string | undefined + let friendAvatarUrl: string | undefined + const avatarResult = await wcdbService.getAvatarUrls(avatarCandidates) + if (avatarResult.success && avatarResult.map) { + selfAvatarUrl = avatarResult.map[rawWxid] || avatarResult.map[cleanedWxid] + friendAvatarUrl = avatarResult.map[friendUsername] + } 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 } } } 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) - 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) - } - } - } + let friendTopEmojiMd5: string | undefined + let friendTopEmojiUrl: string | undefined + let friendTopCount = -1 - 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(' 0) { - processed++ + if (candidate.isMe) { + if (candidate.count > myTopCount) { + myTopCount = candidate.count + myTopEmojiMd5 = candidate.md5 + myTopEmojiUrl = candidate.url } - } - 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 + } else if (candidate.count > friendTopCount) { + friendTopCount = candidate.count + friendTopEmojiMd5 = candidate.md5 + friendTopEmojiUrl = candidate.url } } - } 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 - } + const needsEmojiFallback = stats.emojiCount > 0 && (!myTopEmojiMd5 || !friendTopEmojiMd5) + if (needsEmojiFallback) { + const fallback = await this.scanEmojiTopFallback(friendUsername, startTime, endTime, rawWxid, cleanedWxid) + + if (!myTopEmojiMd5 && fallback.my?.md5) { + myTopEmojiMd5 = fallback.my.md5 + myTopEmojiUrl = myTopEmojiUrl || fallback.my.url + myTopCount = fallback.my.count + } + if (!friendTopEmojiMd5 && fallback.friend?.md5) { + friendTopEmojiMd5 = fallback.friend.md5 + friendTopEmojiUrl = friendTopEmojiUrl || fallback.friend.url + friendTopCount = fallback.friend.count } - return topKey } - const myTopEmojiMd5 = pickTop(myEmojiCounts) - const friendTopEmojiMd5 = pickTop(friendEmojiCounts) + const [myEmojiUrlResult, friendEmojiUrlResult] = await Promise.all([ + myTopEmojiMd5 && !myTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, myTopEmojiMd5) : Promise.resolve(null), + friendTopEmojiMd5 && !friendTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, friendTopEmojiMd5) : Promise.resolve(null) + ]) + if (myEmojiUrlResult?.success && myEmojiUrlResult.url) myTopEmojiUrl = myEmojiUrlResult.url + if (friendEmojiUrlResult?.success && friendEmojiUrlResult.url) friendTopEmojiUrl = friendEmojiUrlResult.url 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 + if (myTopCount >= 0) stats.myTopEmojiCount = myTopCount + if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount - 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 + })) const reportData: DualReportData = { year: reportYear, selfName: myName, + selfAvatarUrl, friendUsername, friendName, + friendAvatarUrl, firstChat, firstChatMessages, yearFirstChat, stats, - topPhrases - } + topPhrases, + heatmap: cppData.heatmap, + initiative: cppData.initiative, + response: cppData.response, + monthly: cppData.monthly, + streak: cppData.streak + } as any this.reportProgress('双人报告生成完成', 100, onProgress) return { success: true, data: reportData } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 383aede..9679cce 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -68,7 +68,7 @@ const MESSAGE_TYPE_MAP: Record = { } export interface ExportOptions { - format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' + format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' dateRange?: { start: number; end: number } | null exportMedia?: boolean exportAvatars?: boolean @@ -811,6 +811,55 @@ class ExportService { return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '') } + private getWeCloneTypeName(localType: number, content: string): string { + if (localType === 1) return 'text' + if (localType === 3) return 'image' + if (localType === 47) return 'sticker' + if (localType === 43) return 'video' + if (localType === 34) return 'voice' + if (localType === 48) return 'location' + if (localType === 49) { + const xmlType = this.extractXmlValue(content || '', 'type') + if (xmlType === '6') return 'file' + return 'text' + } + return 'text' + } + + private getWeCloneSource(msg: any, typeName: string, mediaItem: MediaExportItem | null): string { + if (mediaItem?.relativePath) { + return mediaItem.relativePath + } + + if (typeName === 'image') { + return msg.imageDatName || '' + } + if (typeName === 'sticker') { + return msg.emojiCdnUrl || '' + } + if (typeName === 'video') { + return '' + } + if (typeName === 'file') { + const xml = msg.content || '' + return this.extractXmlValue(xml, 'filename') || this.extractXmlValue(xml, 'title') || '' + } + return '' + } + + private escapeCsvCell(value: unknown): string { + if (value === null || value === undefined) return '' + const text = String(value) + if (/[",\r\n]/.test(text)) { + return `"${text.replace(/"/g, '""')}"` + } + return text + } + + private formatIsoTimestamp(timestamp: number): string { + return new Date(timestamp * 1000).toISOString() + } + /** * 从撤回消息内容中提取撤回者的 wxid * 撤回消息 XML 格式通常包含 等字段 @@ -3577,6 +3626,253 @@ class ExportService { } } + /** + * 导出单个会话为 WeClone CSV 格式 + */ + async exportSessionToWeCloneCsv( + sessionId: string, + outputPath: string, + options: ExportOptions, + onProgress?: (progress: ExportProgress) => void + ): Promise<{ success: boolean; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const cleanedMyWxid = conn.cleanedWxid + const isGroup = sessionId.includes('@chatroom') + const sessionInfo = await this.getContactInfo(sessionId) + const myInfo = await this.getContactInfo(cleanedMyWxid) + + const contactCache = new Map() + const getContactCached = async (username: string) => { + if (contactCache.has(username)) { + return contactCache.get(username)! + } + const result = await wcdbService.getContact(username) + contactCache.set(username, result) + return result + } + + onProgress?.({ + current: 0, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'preparing' + }) + + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + if (collected.rows.length === 0) { + return { success: false, error: '该会话在指定时间范围内没有消息' } + } + + const senderUsernames = new Set() + for (const msg of collected.rows) { + if (msg.senderUsername) senderUsernames.add(msg.senderUsername) + } + senderUsernames.add(sessionId) + await this.preloadContacts(senderUsernames, contactCache) + + const groupNicknameCandidates = isGroup + ? this.buildGroupNicknameIdCandidates([ + ...Array.from(senderUsernames.values()), + ...collected.rows.map(msg => msg.senderUsername), + cleanedMyWxid + ]) + : [] + const groupNicknamesMap = isGroup + ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) + : new Map() + + const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) + + const voiceMessages = options.exportVoiceAsText + ? sortedMessages.filter(msg => msg.localType === 34) + : [] + + if (options.exportVoiceAsText && voiceMessages.length > 0) { + await this.ensureVoiceModel(onProgress) + } + + const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) + const mediaMessages = exportMediaEnabled + ? sortedMessages.filter(msg => { + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 43 && options.exportVideos) || + (t === 34 && options.exportVoices) + }) + : [] + + const mediaCache = new Map() + + if (mediaMessages.length > 0) { + onProgress?.({ + current: 25, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` + }) + + const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 + await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { + const mediaKey = `${msg.localType}_${msg.localId}` + if (!mediaCache.has(mediaKey)) { + const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { + exportImages: options.exportImages, + exportVoices: options.exportVoices, + exportVideos: options.exportVideos, + exportEmojis: options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText + }) + mediaCache.set(mediaKey, mediaItem) + } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 25, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } + }) + } + + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 45, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` + }) + + const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 + await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) + voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 45, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) + }) + } + + onProgress?.({ + current: 60, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + + const lines: string[] = [] + lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') + + for (let i = 0; i < sortedMessages.length; i++) { + const msg = sortedMessages[i] + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) || null + + const typeName = this.getWeCloneTypeName(msg.localType, msg.content || '') + let senderWxid = cleanedMyWxid + if (!msg.isSend) { + senderWxid = isGroup && msg.senderUsername + ? msg.senderUsername + : sessionId + } + + let talker = myInfo.displayName || '我' + if (!msg.isSend) { + const contactDetail = await getContactCached(senderWxid) + const senderNickname = contactDetail.success && contactDetail.contact + ? (contactDetail.contact.nickName || senderWxid) + : senderWxid + const senderRemark = contactDetail.success && contactDetail.contact + ? (contactDetail.contact.remark || '') + : '' + const senderGroupNickname = isGroup + ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid]) + : '' + talker = this.getPreferredDisplayName( + senderWxid, + senderNickname, + senderRemark, + senderGroupNickname, + options.displayNamePreference || 'remark' + ) + } + + const msgText = msg.localType === 34 && options.exportVoiceAsText + ? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]') + : (this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime) || '') + const src = this.getWeCloneSource(msg, typeName, mediaItem) + + const row = [ + i + 1, + i + 1, + typeName, + msg.isSend ? 1 : 0, + talker, + msgText, + src, + this.formatIsoTimestamp(msg.createTime) + ] + + lines.push(row.map((value) => this.escapeCsvCell(value)).join(',')) + + if ((i + 1) % 200 === 0) { + const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30) + onProgress?.({ + current: progress, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + } + } + + onProgress?.({ + current: 92, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing' + }) + + fs.writeFileSync(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8') + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete' + }) + + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + private getVirtualScrollScript(): string { return ` class ChunkedRenderer { @@ -4228,6 +4524,7 @@ class ExportService { if (options.format === 'chatlab-jsonl') ext = '.jsonl' else if (options.format === 'excel') ext = '.xlsx' else if (options.format === 'txt') ext = '.txt' + else if (options.format === 'weclone') ext = '.csv' else if (options.format === 'html') ext = '.html' const outputPath = path.join(sessionDir, `${safeName}${ext}`) @@ -4240,6 +4537,8 @@ class ExportService { result = await this.exportSessionToExcel(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'txt') { result = await this.exportSessionToTxt(sessionId, outputPath, options, sessionProgress) + } else if (options.format === 'weclone') { + result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'html') { result = await this.exportSessionToHtml(sessionId, outputPath, options, sessionProgress) } else { 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..f24681d 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/components/BatchTranscribeGlobal.tsx b/src/components/BatchTranscribeGlobal.tsx index 51932be..3aa7c10 100644 --- a/src/components/BatchTranscribeGlobal.tsx +++ b/src/components/BatchTranscribeGlobal.tsx @@ -1,6 +1,6 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import { createPortal } from 'react-dom' -import { Loader2, X, CheckCircle, XCircle, AlertCircle } from 'lucide-react' +import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock } from 'lucide-react' import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' import '../styles/batchTranscribe.scss' @@ -16,10 +16,46 @@ export const BatchTranscribeGlobal: React.FC = () => { showResult, result, sessionName, + startTime, setShowToast, setShowResult } = useBatchTranscribeStore() + const [eta, setEta] = useState('') + + // 计算剩余时间 + useEffect(() => { + if (!isBatchTranscribing || !startTime || progress.current === 0) { + setEta('') + return + } + + const timer = setInterval(() => { + const now = Date.now() + const elapsed = now - startTime + const rate = progress.current / elapsed // ms per item + const remainingItems = progress.total - progress.current + + if (remainingItems <= 0) { + setEta('') + return + } + + const remainingTimeMs = remainingItems / rate + const remainingSeconds = Math.ceil(remainingTimeMs / 1000) + + if (remainingSeconds < 60) { + setEta(`${remainingSeconds}秒`) + } else { + const minutes = Math.floor(remainingSeconds / 60) + const seconds = remainingSeconds % 60 + setEta(`${minutes}分${seconds}秒`) + } + }, 1000) + + return () => clearInterval(timer) + }, [isBatchTranscribing, startTime, progress.current, progress.total]) + return ( <> {/* 批量转写进度浮窗(非阻塞) */} @@ -35,14 +71,23 @@ export const BatchTranscribeGlobal: React.FC = () => {
-
- {progress.current} / {progress.total} - - {progress.total > 0 - ? Math.round((progress.current / progress.total) * 100) - : 0}% - +
+
+ {progress.current} / {progress.total} + + {progress.total > 0 + ? Math.round((progress.current / progress.total) * 100) + : 0}% + +
+ {eta && ( +
+ + 剩余 {eta} +
+ )}
+
= ({ 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/ChatPage.tsx b/src/pages/ChatPage.tsx index c5d40a7..24fe60a 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1311,7 +1311,7 @@ function ChatPage(_props: ChatPageProps) { let successCount = 0 let failCount = 0 let completedCount = 0 - const concurrency = 3 + const concurrency = 10 const transcribeOne = async (msg: Message) => { try { @@ -2511,7 +2511,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o // 视频懒加载 const videoAutoLoadTriggered = useRef(false) const [videoClicked, setVideoClicked] = useState(false) - + useEffect(() => { if (!isVideo || !videoContainerRef.current) return @@ -2537,11 +2537,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o // 视频加载中状态引用,避免依赖问题 const videoLoadingRef = useRef(false) - + // 加载视频信息(添加重试机制) const requestVideoInfo = useCallback(async () => { if (!videoMd5 || videoLoadingRef.current) return - + videoLoadingRef.current = true setVideoLoading(true) try { @@ -2563,13 +2563,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o setVideoLoading(false) } }, [videoMd5]) - + // 视频进入视野时自动加载 useEffect(() => { if (!isVideo || !isVideoVisible) return if (videoInfo?.exists) return // 已成功加载,不需要重试 if (videoAutoLoadTriggered.current) return - + videoAutoLoadTriggered.current = true void requestVideoInfo() }, [isVideo, isVideoVisible, videoInfo, requestVideoInfo]) 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/DualReportPage.tsx b/src/pages/DualReportPage.tsx index 3516589..a8398fc 100644 --- a/src/pages/DualReportPage.tsx +++ b/src/pages/DualReportPage.tsx @@ -7,6 +7,7 @@ interface ContactRanking { username: string displayName: string avatarUrl?: string + wechatId?: string messageCount: number sentCount: number receivedCount: number @@ -15,28 +16,29 @@ interface ContactRanking { function DualReportPage() { const navigate = useNavigate() - const [year, setYear] = useState(0) + const [year] = useState(() => { + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') + const yearParam = params.get('year') + const parsedYear = yearParam ? parseInt(yearParam, 10) : 0 + return Number.isNaN(parsedYear) ? 0 : parsedYear + }) const [rankings, setRankings] = useState([]) const [isLoading, setIsLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [keyword, setKeyword] = useState('') useEffect(() => { - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') - const yearParam = params.get('year') - const parsedYear = yearParam ? parseInt(yearParam, 10) : 0 - setYear(Number.isNaN(parsedYear) ? 0 : parsedYear) - }, []) + void loadRankings(year) + }, [year]) - useEffect(() => { - loadRankings() - }, []) - - const loadRankings = async () => { + const loadRankings = async (reportYear: number) => { setIsLoading(true) setLoadError(null) try { - const result = await window.electronAPI.analytics.getContactRankings(200) + const isAllTime = reportYear <= 0 + const beginTimestamp = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000) + const endTimestamp = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000) + const result = await window.electronAPI.analytics.getContactRankings(200, beginTimestamp, endTimestamp) if (result.success && result.data) { setRankings(result.data) } else { @@ -55,7 +57,8 @@ function DualReportPage() { if (!keyword.trim()) return rankings const q = keyword.trim().toLowerCase() return rankings.filter((item) => { - return item.displayName.toLowerCase().includes(q) || item.username.toLowerCase().includes(q) + const wechatId = (item.wechatId || '').toLowerCase() + return item.displayName.toLowerCase().includes(q) || wechatId.includes(q) }) }, [rankings, keyword]) @@ -99,7 +102,7 @@ function DualReportPage() { setKeyword(e.target.value)} - placeholder="搜索好友(昵称/备注/wxid)" + placeholder="搜索好友(昵称/微信号)" />
@@ -119,7 +122,7 @@ function DualReportPage() {
{item.displayName}
-
{item.username}
+
{item.wechatId || '\u672A\u8bbe\u7f6e\u5fae\u4fe1\u53f7'}
{item.messageCount.toLocaleString()} 条
diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 646b9ab..8ccff4b 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; @@ -30,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 { @@ -60,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); @@ -81,25 +73,15 @@ } .first-chat-scene { - background: linear-gradient(180deg, #8f5b85 0%, #e38aa0 50%, #f6d0c8 100%); - border-radius: 20px; - padding: 28px 24px 24px; - color: #fff; + padding: 18px 16px 16px; + color: var(--ar-text-main); 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; + display: none; } .scene-title { @@ -107,6 +89,7 @@ font-weight: 700; text-align: center; margin-bottom: 8px; + color: var(--ar-text-main); } .scene-subtitle { @@ -114,92 +97,192 @@ font-weight: 500; text-align: center; margin-bottom: 20px; - opacity: 0.95; + opacity: 0.9; + color: var(--ar-text-sub); } .scene-messages { display: flex; flex-direction: column; - gap: 14px; + gap: 16px; } .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; } } .scene-avatar { - width: 40px; - height: 40px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.25); + width: 42px; + height: 42px; + border-radius: 50%; + background: var(--ar-card-bg); display: flex; align-items: center; justify-content: center; font-weight: 700; - color: #fff; + color: var(--ar-text-sub); + border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08)); + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .scene-content-wrapper { + display: flex; + flex-direction: column; + width: 100%; + max-width: min(78%, 720px); + } + + .scene-message.sent .scene-content-wrapper { + align-items: flex-end; } .scene-bubble { - background: rgba(255, 255, 255, 0.85); - color: #5a4d5e; + color: var(--ar-text-main); padding: 10px 14px; - border-radius: 14px; - max-width: 60%; - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12); - } + width: fit-content; + min-width: 40px; + max-width: 100%; + background: var(--ar-card-bg); + border-radius: 12px; + position: relative; - .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; + &.no-bubble { + background: transparent; + padding: 0; + box-shadow: none; + } } .scene-content { + line-height: 1.5; + font-size: clamp(14px, 1.8vw, 16px); + word-break: break-all; + white-space: pre-wrap; + 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 { font-size: 14px; - line-height: 1.4; - word-break: break-word; + } + + .scene-avatar.with-image { + background: transparent; + color: transparent; } .scene-message.sent .scene-avatar { - background: rgba(255, 224, 168, 0.9); - color: #4a3a2f; + border-color: color-mix(in srgb, var(--primary) 30%, var(--bg-tertiary, rgba(0, 0, 0, 0.08))); } .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 { @@ -215,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; @@ -250,4 +330,580 @@ text-align: center; padding: 24px 0; } -} + + .initiative-container { + padding: 32px 0; + width: 100%; + background: transparent; + border: none; + } + + .initiative-bar-wrapper { + display: flex; + align-items: center; + gap: 32px; + width: 100%; + padding: 24px 0; + margin-bottom: 24px; + position: relative; + } + + .initiative-side { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + min-width: 80px; + z-index: 2; + + .avatar-placeholder { + 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: 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; + } + } + + .count { + font-size: 11px; + font-weight: 500; + opacity: 0.4; + color: var(--ar-text-sub); + } + + .percent { + font-size: 14px; + color: var(--ar-text-main); + font-weight: 800; + opacity: 0.9; + } + } + + .initiative-progress { + flex: 1; + height: 1px; // 线条样式 + position: relative; + display: flex; + align-items: center; + + .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%); + } + + .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; + + &::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-desc { + 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; + } + } + + + .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-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; + } + } + } + + .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); + opacity: 0.2; + } + + 50% { + transform: scale(1.08); + opacity: 0.3; + } + } + + @keyframes emberRise { + 0% { + transform: translateY(100px) scale(1); + opacity: 0; + } + + 20% { + opacity: 0.8; + } + + 80% { + opacity: 0.3; + } + + 100% { + transform: translateY(-260px) scale(0.4); + opacity: 0; + } + } + + @keyframes sparkFlicker { + + 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) { + .pulse-visual { + transform: scale(0.85); + } + + .scene-avatar { + width: 36px; + height: 36px; + font-size: 13px; + } + + .scene-content-wrapper { + max-width: min(86%, 500px); + } + + .scene-bubble { + max-width: 100%; + min-width: 56px; + } + } +} \ No newline at end of file diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 8ba3f92..197a8ae 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -1,4 +1,6 @@ -import { useEffect, useState, type CSSProperties } from 'react' +import { useEffect, useState } from 'react' +import ReportHeatmap from '../components/ReportHeatmap' +import ReportWordCloud from '../components/ReportWordCloud' import './AnnualReportWindow.scss' import './DualReportWindow.scss' @@ -7,19 +9,27 @@ interface DualReportMessage { isSentByMe: boolean createTime: number createTimeStr: string + localType?: number + emojiMd5?: string + emojiCdnUrl?: string } interface DualReportData { year: number selfName: string + selfAvatarUrl?: string friendUsername: string friendName: string + friendAvatarUrl?: string firstChat: { createTime: number createTimeStr: string content: string isSentByMe: boolean senderUsername?: string + localType?: number + emojiMd5?: string + emojiCdnUrl?: string } | null firstChatMessages?: DualReportMessage[] yearFirstChat?: { @@ -29,6 +39,9 @@ interface DualReportData { isSentByMe: boolean friendName: string firstThreeMessages: DualReportMessage[] + localType?: number + emojiMd5?: string + emojiCdnUrl?: string } | null stats: { totalMessages: number @@ -40,111 +53,15 @@ interface DualReportData { friendTopEmojiMd5?: string myTopEmojiUrl?: string friendTopEmojiUrl?: string + myTopEmojiCount?: number + friendTopEmojiCount?: number } 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; slowest: number; count: number } + monthly?: Record + streak?: { days: number; startDate: string; endDate: string } } function DualReportWindow() { @@ -203,6 +120,8 @@ function DualReportWindow() { useEffect(() => { const loadEmojis = async () => { if (!reportData) return + setMyEmojiUrl(null) + setFriendEmojiUrl(null) const stats = reportData.stats if (stats.myTopEmojiUrl) { const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5) @@ -273,12 +192,15 @@ function DualReportWindow() { : null const yearFirstChat = reportData.yearFirstChat const stats = reportData.stats + const initiativeTotal = (reportData.initiative?.initiated || 0) + (reportData.initiative?.received || 0) + 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 }, - { label: '总字数', value: stats.totalWords }, - { label: '图片', value: stats.imageCount }, - { label: '语音', value: stats.voiceCount }, - { label: '表情', value: stats.emojiCount }, + { 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) => ( @@ -290,7 +212,28 @@ 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 + .replace(/\r\n/g, '\n') + .replace(/\s*\n+\s*/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim() + ) const extractXmlText = (content: string) => { const titleMatch = content.match(/([\s\S]*?)<\/title>/i) @@ -304,16 +247,62 @@ function DualReportWindow() { return '' } - const formatMessageContent = (content?: string) => { - const raw = String(content || '').trim() + 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 '(空)' + + // 1. 尝试提取 XML 关键字段 + const titleMatch = raw.match(/<title>([\s\S]*?)<\/title>/i) + if (titleMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(titleMatch[1]).trim())) + + const descMatch = raw.match(/<des>([\s\S]*?)<\/des>/i) + if (descMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(descMatch[1]).trim())) + + const summaryMatch = raw.match(/<summary>([\s\S]*?)<\/summary>/i) + if (summaryMatch?.[1]) return compactMessageText(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 compactMessageText(decodeEntities(stripped)) + } + + 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 ( + <div className="report-emoji-container"> + <img src={emojiUrl} alt="表情" className="report-emoji-img" onError={(e) => { + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); + }} /> + <span style={{ display: 'none' }}>[表情]</span> + </div> + ) + } + } + return <span>{formatMessageContent(msg.content, msg.localType)}</span> } const formatFullDate = (timestamp: number) => { const d = new Date(timestamp) @@ -325,6 +314,87 @@ function DualReportWindow() { return `${year}/${month}/${day} ${hour}:${minute}` } + const getMostActiveTime = (data: number[][]) => { + let maxHour = 0 + let maxWeekday = 0 + let maxVal = -1 + data.forEach((row, weekday) => { + row.forEach((value, hour) => { + if (value > maxVal) { + maxVal = value + maxHour = hour + maxWeekday = weekday + } + }) + }) + const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] + return { + weekday: weekdayNames[maxWeekday] || '周一', + hour: maxHour, + value: Math.max(0, maxVal) + } + } + + const mostActive = reportData.heatmap ? getMostActiveTime(reportData.heatmap) : null + const responseAvgMinutes = reportData.response ? Math.max(0, Math.round(reportData.response.avg / 60)) : 0 + const getSceneAvatarUrl = (isSentByMe: boolean) => (isSentByMe ? reportData.selfAvatarUrl : reportData.friendAvatarUrl) + const getSceneAvatarFallback = (isSentByMe: boolean) => (isSentByMe ? '我' : reportData.friendName.substring(0, 1)) + const renderSceneAvatar = (isSentByMe: boolean) => { + const avatarUrl = getSceneAvatarUrl(isSentByMe) + if (avatarUrl) { + return ( + <div className="scene-avatar with-image"> + <img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} /> + </div> + ) + } + return <div className="scene-avatar fallback">{getSceneAvatarFallback(isSentByMe)}</div> + } + + 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 ( + <div key={idx} className="scene-message system"> + {showTime && ( + <div className="scene-meta"> + {formatFullDate(msg.createTime).split(' ')[1]} + </div> + )} + <div className="system-msg-content"> + <ReportMessageItem msg={msg} /> + </div> + </div> + ) + } + return ( + <div key={idx} className={`scene-message ${msg.isSentByMe ? 'sent' : 'received'}`}> + {showTime && ( + <div className="scene-meta"> + {formatFullDate(msg.createTime).split(' ')[1]} + </div> + )} + <div className="scene-body"> + {renderSceneAvatar(msg.isSentByMe)} + <div className="scene-content-wrapper"> + <div className={`scene-bubble ${msg.localType === 47 ? 'no-bubble' : ''}`}> + <div className="scene-content"><ReportMessageItem msg={msg} /></div> + </div> + </div> + </div> + </div> + ) + }) + } + return ( <div className="annual-report-window dual-report-window"> <div className="drag-region" /> @@ -344,7 +414,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,105 +425,217 @@ 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"> - {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 className="scene-messages"> + {renderMessageList(firstChatMessages)} </div> - ) : null} - </> + ) : ( + <div className="hero-desc" style={{ textAlign: 'center' }}>暂无消息详情</div> + )} + <div className="scene-footer" style={{ marginTop: '20px', textAlign: 'center', fontSize: '14px', opacity: 0.6 }}> + 距离今天已经 {daysSince} 天 + </div> + </div> ) : ( <p className="hero-desc">暂无首条消息</p> )} </section> - {yearFirstChat ? ( + {yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? ( <section className="section"> <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">{formatFullDate(yearFirstChat.createTime)}</div> + <div className="first-chat-scene"> + <div className="scene-title">久别重逢</div> + <div className="scene-subtitle">{formatFullDate(yearFirstChat.createTime).split(' ')[0]}</div> + <div className="scene-messages"> + {renderMessageList(yearFirstChat.firstThreeMessages)} </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> - <div className="message-content">{formatMessageContent(msg.content)}</div> - </div> - ))} </div> </section> ) : null} + {reportData.heatmap && ( + <section className="section"> + <div className="label-text">聊天习惯</div> + <h2 className="hero-title">作息规律</h2> + {mostActive && ( + <p className="hero-desc active-time dual-active-time"> + 在 <span className="hl">{mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00</span> 最活跃({mostActive.value}条) + </p> + )} + <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-bar-wrapper"> + <div className="initiative-side"> + <div className="avatar-placeholder"> + {reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" /> : '我'} + </div> + <div className="count">{reportData.initiative.initiated}次</div> + <div className="percent">{initiatedPercent.toFixed(1)}%</div> + </div> + <div className="initiative-progress"> + <div className="line-bg" /> + <div + className="initiative-indicator" + style={{ left: `${initiatedPercent}%` }} + /> + </div> + <div className="initiative-side"> + <div className="avatar-placeholder"> + {reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" /> : reportData.friendName.substring(0, 1)} + </div> + <div className="count">{reportData.initiative.received}次</div> + <div className="percent">{receivedPercent.toFixed(1)}%</div> + </div> + </div> + <div className="initiative-desc"> + {reportData.initiative.initiated > reportData.initiative.received ? '每一个话题都是你对TA的在意' : 'TA总是那个率先打破沉默的人'} + </div> + </div> + </section> + )} + + {reportData.response && ( + <section className="section"> + <div className="label-text">回应速度</div> + <h2 className="hero-title">你说,我在</h2> + <div className="response-pulse-container"> + <div className="pulse-visual"> + <div className="pulse-ripple one" /> + <div className="pulse-ripple two" /> + <div className="pulse-ripple three" /> + + <div className="pulse-node left"> + <div className="label">最快回复</div> + <div className="value">{reportData.response.fastest}<span>秒</span></div> + </div> + + <div className="pulse-hub"> + <div className="label">平均回复</div> + <div className="value">{Math.round(reportData.response.avg / 60)}<span>分</span></div> + </div> + + <div className="pulse-node right"> + <div className="label">最慢回复</div> + <div className="value"> + {reportData.response.slowest > 3600 + ? (reportData.response.slowest / 3600).toFixed(1) + : Math.round(reportData.response.slowest / 60)} + <span>{reportData.response.slowest > 3600 ? '时' : '分'}</span> + </div> + </div> + </div> + </div> + <p className="hero-desc response-note"> + {`在 ${reportData.response.count} 次互动中,平均约 ${responseAvgMinutes} 分钟,最快 ${reportData.response.fastest} 秒。`} + </p> + </section> + )} + + {reportData.streak && ( + <section className="section"> + <div className="label-text">聊天火花</div> + <h2 className="hero-title">最长连续聊天</h2> + <div className="streak-spark-visual premium"> + <div className="spark-ambient-glow" /> + + <div className="spark-ember one" /> + <div className="spark-ember two" /> + <div className="spark-ember three" /> + <div className="spark-ember four" /> + + <div className="spark-core-wrapper"> + <div className="spark-flame-outer" /> + <div className="spark-flame-inner" /> + <div className="spark-core"> + <div className="spark-days">{reportData.streak.days}</div> + <div className="spark-label">DAYS</div> + </div> + </div> + + <div className="streak-bridge premium"> + <div className="bridge-date start"> + <div className="date-orb" /> + <span>{reportData.streak.startDate}</span> + </div> + <div className="bridge-line"> + <div className="line-glow" /> + <div className="line-string" /> + </div> + <div className="bridge-date end"> + <span>{reportData.streak.endDate}</span> + <div className="date-orb" /> + </div> + </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"> <div className="label-text">年度统计</div> <h2 className="hero-title">{yearTitle}数据概览</h2> <div className="dual-stat-grid"> - {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> - ) - })} + {statItems.slice(0, 2).map((item) => ( + <div key={item.label} className="dual-stat-card"> + <div className="stat-num">{item.value.toLocaleString()}</div> + <div className="stat-unit">{item.label}</div> + </div> + ))} + </div> + <div className="dual-stat-grid bottom"> + {statItems.slice(2).map((item) => ( + <div key={item.label} className="dual-stat-card"> + <div className="stat-num small">{item.value.toLocaleString()}</div> + <div className="stat-unit">{item.label}</div> + </div> + ))} </div> <div className="emoji-row"> <div className="emoji-card"> <div className="emoji-title">我常用的表情</div> {myEmojiUrl ? ( - <img src={myEmojiUrl} alt="my-emoji" /> - ) : ( - <div className="emoji-placeholder">{stats.myTopEmojiMd5 || '暂无'}</div> - )} + <img src={myEmojiUrl} alt="my-emoji" onError={(e) => { + (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); + (e.target as HTMLImageElement).style.display = 'none'; + }} /> + ) : null} + <div className="emoji-placeholder" style={myEmojiUrl ? { display: 'none' } : undefined}> + {stats.myTopEmojiMd5 || '暂无'} + </div> + <div className="emoji-count">{stats.myTopEmojiCount ? `${stats.myTopEmojiCount}次` : '暂无统计'}</div> </div> <div className="emoji-card"> <div className="emoji-title">{reportData.friendName}常用的表情</div> {friendEmojiUrl ? ( - <img src={friendEmojiUrl} alt="friend-emoji" /> - ) : ( - <div className="emoji-placeholder">{stats.friendTopEmojiMd5 || '暂无'}</div> - )} + <img src={friendEmojiUrl} alt="friend-emoji" onError={(e) => { + (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); + (e.target as HTMLImageElement).style.display = 'none'; + }} /> + ) : null} + <div className="emoji-placeholder" style={friendEmojiUrl ? { display: 'none' } : undefined}> + {stats.friendTopEmojiMd5 || '暂无'} + </div> + <div className="emoji-count">{stats.friendTopEmojiCount ? `${stats.friendTopEmojiCount}次` : '暂无统计'}</div> </div> </div> </section> diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 7ffc1cc..92b9d81 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -13,7 +13,7 @@ interface ChatSession { } interface ExportOptions { - format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' + format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' dateRange: { start: Date; end: Date } | null useAllTime: boolean exportAvatars: boolean @@ -360,7 +360,7 @@ function ExportPage() { } : null } - if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') { + if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html' || options.format === 'weclone') { const result = await window.electronAPI.export.exportSessions( sessionList, exportFolder, @@ -513,6 +513,7 @@ function ExportPage() { { value: 'html', label: 'HTML', icon: FileText, desc: '网页格式,可直接浏览' }, { value: 'txt', label: 'TXT', icon: Table, desc: '纯文本,通用格式' }, { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, + { value: 'weclone', label: 'WeClone CSV', icon: Table, desc: 'WeClone 兼容字段格式(CSV)' }, { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } ] const displayNameOptions = [ diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index b3187c9..b6a01d6 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1565,6 +1565,7 @@ function SettingsPage() { { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, + { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } ] const exportDateRangeOptions = [ diff --git a/src/stores/batchTranscribeStore.ts b/src/stores/batchTranscribeStore.ts index b96f085..a6e1f1f 100644 --- a/src/stores/batchTranscribeStore.ts +++ b/src/stores/batchTranscribeStore.ts @@ -12,6 +12,7 @@ export interface BatchTranscribeState { /** 转写结果 */ result: { success: number; fail: number } /** 当前转写的会话名 */ + startTime: number sessionName: string // Actions @@ -30,6 +31,7 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({ showResult: false, result: { success: 0, fail: 0 }, sessionName: '', + startTime: 0, startTranscribe: (total, sessionName) => set({ isBatchTranscribing: true, @@ -37,7 +39,8 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({ progress: { current: 0, total }, showResult: false, result: { success: 0, fail: 0 }, - sessionName + sessionName, + startTime: Date.now() }), updateProgress: (current, total) => set({ @@ -48,7 +51,8 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({ isBatchTranscribing: false, showToast: false, showResult: true, - result: { success, fail } + result: { success, fail }, + startTime: 0 }), setShowToast: (show) => set({ showToast: show }), @@ -60,6 +64,7 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({ showToast: false, showResult: false, result: { success: 0, fail: 0 }, - sessionName: '' + sessionName: '', + startTime: 0 }) })) diff --git a/src/styles/batchTranscribe.scss b/src/styles/batchTranscribe.scss index 175cc2c..5a7256f 100644 --- a/src/styles/batchTranscribe.scss +++ b/src/styles/batchTranscribe.scss @@ -26,13 +26,25 @@ } @keyframes batchFadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + + to { + opacity: 1; + } } @keyframes batchSlideUp { - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } } // 批量转写进度浮窗(非阻塞 toast) @@ -64,7 +76,9 @@ font-weight: 600; color: var(--text-primary); - svg { color: var(--primary-color); } + svg { + color: var(--primary); + } } } @@ -90,18 +104,38 @@ .batch-progress-toast-body { padding: 12px 14px; - .progress-text { + .progress-info-row { display: flex; - justify-content: space-between; - align-items: center; + flex-direction: column; + gap: 4px; margin-bottom: 8px; - font-size: 12px; - color: var(--text-secondary); - .progress-percent { - font-weight: 600; - color: var(--primary-color); - font-size: 13px; + .progress-text { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: var(--text-secondary); + + .progress-percent { + font-weight: 600; + color: var(--primary); + font-size: 13px; + } + } + + .progress-eta { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; // 稍微小一点 + color: var(--text-tertiary, #999); // 使用更淡的颜色 + + svg { + width: 12px; + height: 12px; + opacity: 0.8; + } } } @@ -113,7 +147,7 @@ .progress-fill { height: 100%; - background: linear-gradient(90deg, var(--primary-color), var(--primary-color)); + background: linear-gradient(90deg, var(--primary), var(--primary)); border-radius: 3px; transition: width 0.3s ease; } @@ -122,8 +156,15 @@ } @keyframes batchToastSlideIn { - from { opacity: 0; transform: translateY(16px) scale(0.96); } - to { opacity: 1; transform: translateY(0) scale(1); } + from { + opacity: 0; + transform: translateY(16px) scale(0.96); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } } // 批量转写结果对话框 @@ -138,7 +179,9 @@ padding: 1.5rem; border-bottom: 1px solid var(--border-color); - svg { color: #4caf50; } + svg { + color: #4caf50; + } h3 { margin: 0; @@ -165,7 +208,9 @@ border-radius: 8px; background: var(--bg-tertiary); - svg { flex-shrink: 0; } + svg { + flex-shrink: 0; + } .label { font-size: 14px; @@ -179,13 +224,23 @@ } &.success { - svg { color: #4caf50; } - .value { color: #4caf50; } + svg { + color: #4caf50; + } + + .value { + color: #4caf50; + } } &.fail { - svg { color: #f44336; } - .value { color: #f44336; } + svg { + color: #f44336; + } + + .value { + color: #f44336; + } } } } @@ -229,10 +284,13 @@ border: none; &.btn-primary { - background: var(--primary-color); + background: var(--primary); color: white; - &:hover { opacity: 0.9; } + + &:hover { + opacity: 0.9; + } } } } -} +} \ No newline at end of file diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e66004c..ad0daf3 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -163,12 +163,13 @@ export interface ElectronAPI { } error?: string }> - getContactRankings: (limit?: number) => Promise<{ + getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) => Promise<{ success: boolean data?: Array<{ username: string displayName: string avatarUrl?: string + wechatId?: string messageCount: number sentCount: number receivedCount: number @@ -357,8 +358,10 @@ export interface ElectronAPI { data?: { year: number selfName: string + selfAvatarUrl?: string friendUsername: string friendName: string + friendAvatarUrl?: string firstChat: { createTime: number createTimeStr: string @@ -395,8 +398,15 @@ export interface ElectronAPI { friendTopEmojiMd5?: string myTopEmojiUrl?: string friendTopEmojiUrl?: string + myTopEmojiCount?: number + friendTopEmojiCount?: number } topPhrases: Array<{ phrase: string; count: number }> + heatmap?: number[][] + initiative?: { initiated: number; received: number } + response?: { avg: number; fastest: number; count: number } + monthly?: Record<string, number> + streak?: { days: number; startDate: string; endDate: string } } error?: string }>