diff --git a/.gitignore b/.gitignore index ae42d85..5cc4ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ Thumbs.db wcdb/ *info +*.md diff --git a/electron/dualReportWorker.ts b/electron/dualReportWorker.ts new file mode 100644 index 0000000..003c82c --- /dev/null +++ b/electron/dualReportWorker.ts @@ -0,0 +1,45 @@ +import { parentPort, workerData } from 'worker_threads' +import { wcdbService } from './services/wcdbService' +import { dualReportService } from './services/dualReportService' + +interface WorkerConfig { + year: number + friendUsername: string + dbPath: string + decryptKey: string + myWxid: string + resourcesPath?: string + userDataPath?: string + logEnabled?: boolean +} + +const config = workerData as WorkerConfig +process.env.WEFLOW_WORKER = '1' +if (config.resourcesPath) { + process.env.WCDB_RESOURCES_PATH = config.resourcesPath +} + +wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '') +wcdbService.setLogEnabled(config.logEnabled === true) + +async function run() { + const result = await dualReportService.generateReportWithConfig({ + year: config.year, + friendUsername: config.friendUsername, + dbPath: config.dbPath, + decryptKey: config.decryptKey, + wxid: config.myWxid, + onProgress: (status: string, progress: number) => { + parentPort?.postMessage({ + type: 'dualReport:progress', + data: { status, progress } + }) + } + }) + + parentPort?.postMessage({ type: 'dualReport:result', data: result }) +} + +run().catch((err) => { + parentPort?.postMessage({ type: 'dualReport:error', error: String(err) }) +}) diff --git a/electron/main.ts b/electron/main.ts index f152dc3..81e0c5d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -845,6 +845,18 @@ function registerIpcHandlers() { return analyticsService.getTimeDistribution() }) + ipcMain.handle('analytics:getExcludedUsernames', async () => { + return analyticsService.getExcludedUsernames() + }) + + ipcMain.handle('analytics:setExcludedUsernames', async (_, usernames: string[]) => { + return analyticsService.setExcludedUsernames(usernames) + }) + + ipcMain.handle('analytics:getExcludeCandidates', async () => { + return analyticsService.getExcludeCandidates() + }) + // 缓存管理 ipcMain.handle('cache:clearAnalytics', async () => { return analyticsService.clearCache() @@ -1017,6 +1029,73 @@ function registerIpcHandlers() { }) }) + ipcMain.handle('dualReport:generateReport', async (_, payload: { friendUsername: string; year: number }) => { + const cfg = configService || new ConfigService() + configService = cfg + + const dbPath = cfg.get('dbPath') + const decryptKey = cfg.get('decryptKey') + const wxid = cfg.get('myWxid') + const logEnabled = cfg.get('logEnabled') + const friendUsername = payload?.friendUsername + const year = payload?.year ?? 0 + + if (!friendUsername) { + return { success: false, error: '缺少好友用户名' } + } + + const resourcesPath = app.isPackaged + ? join(process.resourcesPath, 'resources') + : join(app.getAppPath(), 'resources') + const userDataPath = app.getPath('userData') + + const workerPath = join(__dirname, 'dualReportWorker.js') + + return await new Promise((resolve) => { + const worker = new Worker(workerPath, { + workerData: { year, friendUsername, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled } + }) + + const cleanup = () => { + worker.removeAllListeners() + } + + worker.on('message', (msg: any) => { + if (msg && msg.type === 'dualReport:progress') { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send('dualReport:progress', msg.data) + } + } + return + } + if (msg && (msg.type === 'dualReport:result' || msg.type === 'done')) { + cleanup() + void worker.terminate() + resolve(msg.data ?? msg.result) + return + } + if (msg && (msg.type === 'dualReport:error' || msg.type === 'error')) { + cleanup() + void worker.terminate() + resolve({ success: false, error: msg.error || '双人报告生成失败' }) + } + }) + + worker.on('error', (err) => { + cleanup() + resolve({ success: false, error: String(err) }) + }) + + worker.on('exit', (code) => { + if (code !== 0) { + cleanup() + resolve({ success: false, error: `双人报告线程异常退出: ${code}` }) + } + }) + }) + }) + ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => { try { const { baseDir, folderName, images } = payload diff --git a/electron/preload.ts b/electron/preload.ts index 7d6d5c3..2c259eb 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -162,9 +162,12 @@ contextBridge.exposeInMainWorld('electronAPI', { // 数据分析 analytics: { - getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'), + getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force), getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit), getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'), + getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'), + setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames), + getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'), onProgress: (callback: (payload: { status: string; progress: number }) => void) => { ipcRenderer.on('analytics:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('analytics:progress') @@ -199,6 +202,14 @@ contextBridge.exposeInMainWorld('electronAPI', { return () => ipcRenderer.removeAllListeners('annualReport:progress') } }, + dualReport: { + generateReport: (payload: { friendUsername: string; year: number }) => + ipcRenderer.invoke('dualReport:generateReport', payload), + onProgress: (callback: (payload: { status: string; progress: number }) => void) => { + ipcRenderer.on('dualReport:progress', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('dualReport:progress') + } + }, // 导出 export: { diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index d52508a..e9d965e 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -3,6 +3,7 @@ import { wcdbService } from './wcdbService' import { join } from 'path' import { readFile, writeFile, rm } from 'fs/promises' import { app } from 'electron' +import { createHash } from 'crypto' export interface ChatStatistics { totalMessages: number @@ -46,6 +47,58 @@ class AnalyticsService { this.configService = new ConfigService() } + private normalizeUsername(username: string): string { + return username.trim().toLowerCase() + } + + private normalizeExcludedUsernames(value: unknown): string[] { + if (!Array.isArray(value)) return [] + const normalized = value + .map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '') + .filter((item) => item.length > 0) + return Array.from(new Set(normalized)) + } + + private getExcludedUsernamesList(): string[] { + return this.normalizeExcludedUsernames(this.configService.get('analyticsExcludedUsernames')) + } + + private getExcludedUsernamesSet(): Set { + return new Set(this.getExcludedUsernamesList()) + } + + private escapeSqlValue(value: string): string { + return value.replace(/'/g, "''") + } + + private async getAliasMap(usernames: string[]): Promise> { + const map: Record = {} + if (usernames.length === 0) return map + + const chunkSize = 200 + for (let i = 0; i < usernames.length; i += chunkSize) { + const chunk = usernames.slice(i, i + chunkSize) + const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',') + if (!inList) continue + const sql = ` + SELECT username, alias + FROM contact + WHERE username IN (${inList}) + ` + const result = await wcdbService.execQuery('contact', null, sql) + if (!result.success || !result.rows) continue + for (const row of result.rows as Record[]) { + const username = row.username || '' + const alias = row.alias || '' + if (username && alias) { + map[username] = alias + } + } + } + + return map + } + private cleanAccountDirName(name: string): string { const trimmed = name.trim() if (!trimmed) return trimmed @@ -97,13 +150,15 @@ class AnalyticsService { } private async getPrivateSessions( - cleanedWxid: string + cleanedWxid: string, + excludedUsernames?: Set ): Promise<{ usernames: string[]; numericIds: string[] }> { const sessionResult = await wcdbService.getSessions() if (!sessionResult.success || !sessionResult.sessions) { return { usernames: [], numericIds: [] } } const rows = sessionResult.sessions as Record[] + const excluded = excludedUsernames ?? this.getExcludedUsernamesSet() const sample = rows[0] void sample @@ -124,7 +179,11 @@ class AnalyticsService { return { username, idValue } }) const usernames = sessions.map((s) => s.username) - const privateSessions = sessions.filter((s) => this.isPrivateSession(s.username, cleanedWxid)) + const privateSessions = sessions.filter((s) => { + if (!this.isPrivateSession(s.username, cleanedWxid)) return false + if (excluded.size === 0) return true + return !excluded.has(this.normalizeUsername(s.username)) + }) const privateUsernames = privateSessions.map((s) => s.username) const numericIds = privateSessions .map((s) => s.idValue) @@ -177,8 +236,12 @@ class AnalyticsService { } private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string { - const sample = sessionIds.slice(0, 5).join(',') - return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}` + if (sessionIds.length === 0) { + return `${beginTimestamp}-${endTimestamp}-0-empty` + } + const normalized = Array.from(new Set(sessionIds.map((id) => String(id)))).sort() + const hash = createHash('sha1').update(normalized.join('|')).digest('hex').slice(0, 12) + return `${beginTimestamp}-${endTimestamp}-${normalized.length}-${hash}` } private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise { @@ -369,6 +432,65 @@ class AnalyticsService { void results } + async getExcludedUsernames(): Promise<{ success: boolean; data?: string[]; error?: string }> { + try { + return { success: true, data: this.getExcludedUsernamesList() } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async setExcludedUsernames(usernames: string[]): Promise<{ success: boolean; data?: string[]; error?: string }> { + try { + const normalized = this.normalizeExcludedUsernames(usernames) + this.configService.set('analyticsExcludedUsernames', normalized) + await this.clearCache() + return { success: true, data: normalized } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getExcludeCandidates(): Promise<{ success: boolean; data?: Array<{ username: string; displayName: string; avatarUrl?: string; wechatId?: string }>; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const excluded = this.getExcludedUsernamesSet() + const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid, new Set()) + + const usernames = new Set(sessionInfo.usernames) + for (const name of excluded) usernames.add(name) + + if (usernames.size === 0) { + return { success: true, data: [] } + } + + const usernameList = Array.from(usernames) + const [displayNames, avatarUrls, aliasMap] = await Promise.all([ + wcdbService.getDisplayNames(usernameList), + wcdbService.getAvatarUrls(usernameList), + this.getAliasMap(usernameList) + ]) + + const entries = usernameList.map((username) => { + const displayName = displayNames.success && displayNames.map + ? (displayNames.map[username] || username) + : username + 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 } + }) + + return { success: true, data: entries } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> { try { const conn = await this.ensureConnected() diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index caab4be..4caf50e 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -69,6 +69,20 @@ export interface AnnualReportData { phrase: string count: number }[] + snsStats?: { + totalPosts: number + typeCounts?: Record + topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[] + topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] + } + lostFriend: { + username: string + displayName: string + avatarUrl?: string + earlyCount: number + lateCount: number + periodDesc: string + } | null } class AnnualReportService { @@ -397,8 +411,15 @@ class AnnualReportService { this.reportProgress('加载会话列表...', 15, onProgress) - const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000) - const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000) + const isAllTime = year <= 0 + const reportYear = isAllTime ? 0 : year + const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000) + const endTime = isAllTime ? 0 : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000) + + const now = new Date() + // 全局统计始终使用自然年范围 (Jan 1st - Now/YearEnd) + const actualStartTime = startTime + const actualEndTime = endTime let totalMessages = 0 const contactStats = new Map() @@ -420,7 +441,7 @@ class AnnualReportService { const CONVERSATION_GAP = 3600 this.reportProgress('统计会话消息...', 20, onProgress) - const result = await wcdbService.getAnnualReportStats(sessionIds, startTime, endTime) + const result = await wcdbService.getAnnualReportStats(sessionIds, actualStartTime, actualEndTime) if (!result.success || !result.data) { return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' } } @@ -474,7 +495,7 @@ class AnnualReportService { } this.reportProgress('加载扩展统计... (初始化)', 30, onProgress) - const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd) + const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd) if (extras.success && extras.data) { this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress) const extrasData = extras.data as any @@ -554,7 +575,7 @@ class AnnualReportService { // 为保持功能完整,我们进行深度集成的轻量遍历: for (let i = 0; i < sessionIds.length; i++) { const sessionId = sessionIds[i] - const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, startTime, endTime) + const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, actualStartTime, actualEndTime) if (!cursor.success || !cursor.cursor) continue let lastDayIndex: number | null = null @@ -689,7 +710,7 @@ class AnnualReportService { if (!streakComputedInLoop) { this.reportProgress('计算连续聊天...', 45, onProgress) - const streakResult = await this.computeLongestStreak(sessionIds, startTime, endTime, onProgress, 45, 75) + const streakResult = await this.computeLongestStreak(sessionIds, actualStartTime, actualEndTime, onProgress, 45, 75) if (streakResult.days > longestStreakDays) { longestStreakDays = streakResult.days longestStreakSessionId = streakResult.sessionId @@ -698,6 +719,42 @@ class AnnualReportService { } } + // 获取朋友圈统计 + this.reportProgress('分析朋友圈数据...', 75, onProgress) + let snsStatsResult: { + totalPosts: number + typeCounts?: Record + topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[] + topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] + } | undefined + + const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime) + + if (snsStats.success && snsStats.data) { + const d = snsStats.data + const usersToFetch = new Set() + d.topLikers?.forEach((u: any) => usersToFetch.add(u.username)) + d.topLiked?.forEach((u: any) => usersToFetch.add(u.username)) + + const snsUserIds = Array.from(usersToFetch) + const [snsDisplayNames, snsAvatarUrls] = await Promise.all([ + wcdbService.getDisplayNames(snsUserIds), + wcdbService.getAvatarUrls(snsUserIds) + ]) + + const getSnsUserInfo = (username: string) => ({ + displayName: snsDisplayNames.success && snsDisplayNames.map ? (snsDisplayNames.map[username] || username) : username, + avatarUrl: snsAvatarUrls.success && snsAvatarUrls.map ? snsAvatarUrls.map[username] : undefined + }) + + snsStatsResult = { + totalPosts: d.totalPosts || 0, + typeCounts: d.typeCounts, + topLikers: (d.topLikers || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })), + topLiked: (d.topLiked || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })) + } + } + this.reportProgress('整理联系人信息...', 85, onProgress) const contactIds = Array.from(contactStats.keys()) @@ -901,8 +958,130 @@ class AnnualReportService { .slice(0, 32) .map(([phrase, count]) => ({ phrase, count })) + // 曾经的好朋友 (Once Best Friend / Lost Friend) + let lostFriend: AnnualReportData['lostFriend'] = null + let maxEarlyCount = 80 // 最低门槛 + let bestEarlyCount = 0 + let bestLateCount = 0 + let bestSid = '' + let bestPeriodDesc = '' + + const currentMonthIndex = new Date().getMonth() + 1 // 1-12 + + const currentYearNum = now.getFullYear() + + if (isAllTime) { + const days = Object.keys(d.daily).sort() + if (days.length >= 2) { + const firstDay = Math.floor(new Date(days[0]).getTime() / 1000) + const lastDay = Math.floor(new Date(days[days.length - 1]).getTime() / 1000) + const midPoint = Math.floor((firstDay + lastDay) / 2) + + this.reportProgress('分析历史趋势 (1/2)...', 86, onProgress) + const earlyRes = await wcdbService.getAggregateStats(sessionIds, 0, midPoint) + this.reportProgress('分析历史趋势 (2/2)...', 88, onProgress) + const lateRes = await wcdbService.getAggregateStats(sessionIds, midPoint, 0) + + if (earlyRes.success && lateRes.success && earlyRes.data) { + const earlyData = earlyRes.data.sessions || {} + const lateData = (lateRes.data?.sessions) || {} + for (const sid of sessionIds) { + const e = earlyData[sid] || { sent: 0, received: 0 } + const l = lateData[sid] || { sent: 0, received: 0 } + const early = (e.sent || 0) + (e.received || 0) + const late = (l.sent || 0) + (l.received || 0) + if (early > 100 && early > late * 5) { + // 选择前期消息量最多的 + if (early > maxEarlyCount) { + maxEarlyCount = early + bestEarlyCount = early + bestLateCount = late + bestSid = sid + bestPeriodDesc = '这段时间以来' + } + } + } + } + } + } else if (year === currentYearNum) { + // 当前年份:独立获取过去12个月的滚动数据 + this.reportProgress('分析近期好友趋势...', 86, onProgress) + // 往前数12个月的起点、中点、终点 + const rollingStart = Math.floor(new Date(now.getFullYear(), now.getMonth() - 11, 1).getTime() / 1000) + const rollingMid = Math.floor(new Date(now.getFullYear(), now.getMonth() - 5, 1).getTime() / 1000) + const rollingEnd = Math.floor(now.getTime() / 1000) + + const earlyRes = await wcdbService.getAggregateStats(sessionIds, rollingStart, rollingMid - 1) + const lateRes = await wcdbService.getAggregateStats(sessionIds, rollingMid, rollingEnd) + + if (earlyRes.success && lateRes.success && earlyRes.data) { + const earlyData = earlyRes.data.sessions || {} + const lateData = lateRes.data?.sessions || {} + for (const sid of sessionIds) { + const e = earlyData[sid] || { sent: 0, received: 0 } + const l = lateData[sid] || { sent: 0, received: 0 } + const early = (e.sent || 0) + (e.received || 0) + const late = (l.sent || 0) + (l.received || 0) + if (early > 80 && early > late * 5) { + // 选择前期消息量最多的 + if (early > maxEarlyCount) { + maxEarlyCount = early + bestEarlyCount = early + bestLateCount = late + bestSid = sid + bestPeriodDesc = '去年的这个时候' + } + } + } + } + } else { + // 指定完整年份 (1-6 vs 7-12) + for (const [sid, stat] of Object.entries(d.sessions)) { + const s = stat as any + const mWeights = s.monthly || {} + let early = 0 + let late = 0 + for (let m = 1; m <= 6; m++) early += mWeights[m] || 0 + for (let m = 7; m <= 12; m++) late += mWeights[m] || 0 + + if (early > 80 && early > late * 5) { + // 选择前期消息量最多的 + if (early > maxEarlyCount) { + maxEarlyCount = early + bestEarlyCount = early + bestLateCount = late + bestSid = sid + bestPeriodDesc = `${year}年上半年` + } + } + } + } + + if (bestSid) { + let info = contactInfoMap.get(bestSid) + // 如果 contactInfoMap 中没有该联系人,则单独获取 + if (!info) { + const [displayNameRes, avatarUrlRes] = await Promise.all([ + wcdbService.getDisplayNames([bestSid]), + wcdbService.getAvatarUrls([bestSid]) + ]) + info = { + displayName: displayNameRes.success && displayNameRes.map ? (displayNameRes.map[bestSid] || bestSid) : bestSid, + avatarUrl: avatarUrlRes.success && avatarUrlRes.map ? avatarUrlRes.map[bestSid] : undefined + } + } + lostFriend = { + username: bestSid, + displayName: info?.displayName || bestSid, + avatarUrl: info?.avatarUrl, + earlyCount: bestEarlyCount, + lateCount: bestLateCount, + periodDesc: bestPeriodDesc + } + } + const reportData: AnnualReportData = { - year, + year: reportYear, totalMessages, totalFriends: contactStats.size, coreFriends, @@ -915,7 +1094,9 @@ class AnnualReportService { mutualFriend, socialInitiative, responseSpeed, - topPhrases + topPhrases, + snsStats: snsStatsResult, + lostFriend } return { success: true, data: reportData } diff --git a/electron/services/config.ts b/electron/services/config.ts index 2be308d..621ca08 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -27,6 +27,7 @@ interface ConfigSchema { autoTranscribeVoice: boolean transcribeLanguages: string[] exportDefaultConcurrency: number + analyticsExcludedUsernames: string[] // 安全相关 authEnabled: boolean @@ -62,6 +63,7 @@ export class ConfigService { autoTranscribeVoice: false, transcribeLanguages: ['zh'], exportDefaultConcurrency: 2, + analyticsExcludedUsernames: [], authEnabled: false, authPassword: '', diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts new file mode 100644 index 0000000..3d9a857 --- /dev/null +++ b/electron/services/dualReportService.ts @@ -0,0 +1,456 @@ +import { parentPort } from 'worker_threads' +import { wcdbService } from './wcdbService' + +export interface DualReportMessage { + content: string + isSentByMe: boolean + createTime: number + createTimeStr: string +} + +export interface DualReportFirstChat { + createTime: number + createTimeStr: string + content: string + isSentByMe: boolean + senderUsername?: string +} + +export interface DualReportStats { + totalMessages: number + totalWords: number + imageCount: number + voiceCount: number + emojiCount: number + myTopEmojiMd5?: string + friendTopEmojiMd5?: string + myTopEmojiUrl?: string + friendTopEmojiUrl?: string +} + +export interface DualReportData { + year: number + selfName: string + friendUsername: string + friendName: string + firstChat: DualReportFirstChat | null + firstChatMessages?: DualReportMessage[] + yearFirstChat?: { + createTime: number + createTimeStr: string + content: string + isSentByMe: boolean + friendName: string + firstThreeMessages: DualReportMessage[] + } | null + stats: DualReportStats + topPhrases: Array<{ phrase: string; count: number }> +} + +class DualReportService { + private broadcastProgress(status: string, progress: number) { + if (parentPort) { + parentPort.postMessage({ + type: 'dualReport:progress', + data: { status, progress } + }) + } + } + + private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) { + if (onProgress) { + onProgress(status, progress) + return + } + this.broadcastProgress(status, progress) + } + + private cleanAccountDirName(dirName: string): string { + const trimmed = dirName.trim() + if (!trimmed) return trimmed + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed + } + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + return trimmed + } + + private async ensureConnectedWithConfig( + dbPath: string, + decryptKey: string, + wxid: string + ): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> { + if (!wxid) return { success: false, error: '未配置微信ID' } + if (!dbPath) return { success: false, error: '未配置数据库路径' } + if (!decryptKey) return { success: false, error: '未配置解密密钥' } + + const cleanedWxid = this.cleanAccountDirName(wxid) + const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid) + if (!ok) return { success: false, error: 'WCDB 打开失败' } + return { success: true, cleanedWxid, rawWxid: wxid } + } + + private decodeMessageContent(messageContent: any, compressContent: any): string { + let content = this.decodeMaybeCompressed(compressContent) + if (!content || content.length === 0) { + content = this.decodeMaybeCompressed(messageContent) + } + return content + } + + private decodeMaybeCompressed(raw: any): string { + if (!raw) return '' + if (typeof raw === 'string') { + if (raw.length === 0) return '' + if (this.looksLikeHex(raw)) { + const bytes = Buffer.from(raw, 'hex') + if (bytes.length > 0) return this.decodeBinaryContent(bytes) + } + if (this.looksLikeBase64(raw)) { + try { + const bytes = Buffer.from(raw, 'base64') + return this.decodeBinaryContent(bytes) + } catch { + return raw + } + } + return raw + } + return '' + } + + private decodeBinaryContent(data: Buffer): string { + if (data.length === 0) return '' + try { + if (data.length >= 4) { + const magic = data.readUInt32LE(0) + if (magic === 0xFD2FB528) { + const fzstd = require('fzstd') + const decompressed = fzstd.decompress(data) + return Buffer.from(decompressed).toString('utf-8') + } + } + const decoded = data.toString('utf-8') + const replacementCount = (decoded.match(/\uFFFD/g) || []).length + if (replacementCount < decoded.length * 0.2) { + return decoded.replace(/\uFFFD/g, '') + } + return data.toString('latin1') + } catch { + return '' + } + } + + private looksLikeHex(s: string): boolean { + if (s.length % 2 !== 0) return false + return /^[0-9a-fA-F]+$/.test(s) + } + + private looksLikeBase64(s: string): boolean { + if (s.length % 4 !== 0) return false + return /^[A-Za-z0-9+/=]+$/.test(s) + } + + private formatDateTime(milliseconds: number): string { + const dt = new Date(milliseconds) + const month = String(dt.getMonth() + 1).padStart(2, '0') + const day = String(dt.getDate()).padStart(2, '0') + const hour = String(dt.getHours()).padStart(2, '0') + const minute = String(dt.getMinutes()).padStart(2, '0') + 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 + } + const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content) + return tagMatch?.[1] + } + + private extractEmojiMd5(content: string): string | undefined { + if (!content) return undefined + const match = /md5="([^"]+)"/i.exec(content) || /([^<]+)<\/md5>/i.exec(content) + return match?.[1] + } + + private async getDisplayName(username: string, fallback: string): Promise { + const result = await wcdbService.getDisplayNames([username]) + if (result.success && result.map) { + return result.map[username] || fallback + } + return fallback + } + + private resolveIsSent(row: any, rawWxid?: string, cleanedWxid?: string): boolean { + const isSendRaw = row.computed_is_send ?? row.is_send + if (isSendRaw !== undefined && isSendRaw !== null) { + return parseInt(isSendRaw, 10) === 1 + } + const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase() + if (!sender) return false + const rawLower = rawWxid ? rawWxid.toLowerCase() : '' + const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : '' + return sender === rawLower || sender === cleanedLower + } + + private async getFirstMessages( + sessionId: string, + limit: number, + beginTimestamp: number, + endTimestamp: number + ): Promise { + const safeBegin = Math.max(0, beginTimestamp || 0) + const safeEnd = endTimestamp && endTimestamp > 0 ? endTimestamp : Math.floor(Date.now() / 1000) + const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, safeBegin, safeEnd) + if (!cursorResult.success || !cursorResult.cursor) return [] + try { + const rows: any[] = [] + let hasMore = true + while (hasMore && rows.length < limit) { + const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) + if (!batch.success || !batch.rows) break + for (const row of batch.rows) { + rows.push(row) + if (rows.length >= limit) break + } + hasMore = batch.hasMore === true + } + return rows.slice(0, limit) + } finally { + await wcdbService.closeMessageCursor(cursorResult.cursor) + } + } + + async generateReportWithConfig(params: { + year: number + friendUsername: string + dbPath: string + decryptKey: string + wxid: string + onProgress?: (status: string, progress: number) => void + }): Promise<{ success: boolean; data?: DualReportData; error?: string }> { + try { + const { year, friendUsername, dbPath, decryptKey, wxid, onProgress } = params + this.reportProgress('正在连接数据库...', 5, onProgress) + const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid) + if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error } + + const cleanedWxid = conn.cleanedWxid + const rawWxid = conn.rawWxid + + const reportYear = year <= 0 ? 0 : year + const isAllTime = reportYear === 0 + const startTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000) + const endTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000) + + this.reportProgress('加载联系人信息...', 10, onProgress) + const friendName = await this.getDisplayName(friendUsername, friendUsername) + let myName = await this.getDisplayName(rawWxid, rawWxid) + if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) { + myName = await this.getDisplayName(cleanedWxid, rawWxid) + } + + this.reportProgress('获取首条聊天记录...', 15, onProgress) + const firstRows = await this.getFirstMessages(friendUsername, 3, 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) + firstChat = { + createTime, + createTimeStr: this.formatDateTime(createTime), + content: String(content || ''), + isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid), + senderUsername: row.sender_username || row.sender + } + } + const firstChatMessages: DualReportMessage[] = firstRows.map((row) => { + const msgTime = parseInt(row.create_time || '0', 10) * 1000 + const msgContent = this.decodeMessageContent(row.message_content, row.compress_content) + return { + content: String(msgContent || ''), + isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid), + createTime: msgTime, + createTimeStr: this.formatDateTime(msgTime) + } + }) + + let yearFirstChat: DualReportData['yearFirstChat'] = null + if (!isAllTime) { + this.reportProgress('获取今年首次聊天...', 20, onProgress) + const firstYearRows = await this.getFirstMessages(friendUsername, 3, 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) + return { + content: String(msgContent || ''), + isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid), + createTime: msgTime, + createTimeStr: this.formatDateTime(msgTime) + } + }) + yearFirstChat = { + createTime, + createTimeStr: this.formatDateTime(createTime), + content: String(this.decodeMessageContent(firstRow.message_content, firstRow.compress_content) || ''), + isSentByMe: this.resolveIsSent(firstRow, rawWxid, cleanedWxid), + friendName, + firstThreeMessages + } + } + } + + this.reportProgress('统计聊天数据...', 30, onProgress) + 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 || '打开消息游标失败' } + } + + 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 + + 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) + } + } + } + + 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++ + } + } + hasMore = batch.hasMore === true + + const now = Date.now() + if (now - lastProgressAt > 200) { + if (totalForProgress > 0) { + const ratio = Math.min(1, processed / totalForProgress) + const progress = 30 + Math.floor(ratio * 50) + this.reportProgress('统计聊天数据...', progress, onProgress) + } + lastProgressAt = now + } + } + } finally { + await wcdbService.closeMessageCursor(cursorResult.cursor) + } + + const pickTop = (map: Map): string | undefined => { + let topKey: string | undefined + let topCount = -1 + for (const [key, count] of map.entries()) { + if (count > topCount) { + topCount = count + topKey = key + } + } + return topKey + } + + const myTopEmojiMd5 = pickTop(myEmojiCounts) + const friendTopEmojiMd5 = pickTop(friendEmojiCounts) + + stats.myTopEmojiMd5 = myTopEmojiMd5 + stats.friendTopEmojiMd5 = friendTopEmojiMd5 + stats.myTopEmojiUrl = myTopEmojiMd5 ? myEmojiUrlMap.get(myTopEmojiMd5) : undefined + stats.friendTopEmojiUrl = friendTopEmojiMd5 ? friendEmojiUrlMap.get(friendTopEmojiMd5) : undefined + + 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 reportData: DualReportData = { + year: reportYear, + selfName: myName, + friendUsername, + friendName, + firstChat, + firstChatMessages, + yearFirstChat, + stats, + topPhrases + } + + this.reportProgress('双人报告生成完成', 100, onProgress) + return { success: true, data: reportData } + } catch (e) { + return { success: false, error: String(e) } + } + } +} + +export const dualReportService = new DualReportService() diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 948a2b0..87dc4e4 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -208,145 +208,18 @@ class ExportService { } /** - * 解析 ext_buffer 二进制数据,提取群成员的群昵称 - * ext_buffer 包含类似 protobuf 编码的数据,格式示例: - * wxid_xxx群昵称wxid_yyy群昵称... - */ - private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map { - const nicknameMap = new Map() - - try { - // 将 buffer 转为字符串,允许部分乱码 - const raw = buffer.toString('utf8') - - // 提取所有 wxid 格式的字符串: wxid_ 或 wxid_后跟字母数字下划线 - const wxidPattern = /wxid_[a-z0-9_]+/gi - const wxids = raw.match(wxidPattern) || [] - - // 对每个 wxid,尝试提取其后的群昵称 - for (const wxid of wxids) { - const wxidLower = wxid.toLowerCase() - const wxidIndex = raw.toLowerCase().indexOf(wxidLower) - - if (wxidIndex === -1) continue - - // 从 wxid 结束位置开始查找 - const afterWxid = raw.slice(wxidIndex + wxid.length) - - // 提取紧跟在 wxid 后面的可打印字符(中文、字母、数字等) - // 跳过前面的不可打印字符和特定控制字符 - let nickname = '' - let foundStart = false - - for (let i = 0; i < afterWxid.length && i < 100; i++) { - const char = afterWxid[i] - const code = char.charCodeAt(0) - - // 判断是否为可打印字符(中文、字母、数字、常见符号) - const isPrintable = ( - (code >= 0x4E00 && code <= 0x9FFF) || // 中文 - (code >= 0x3000 && code <= 0x303F) || // CJK 符号 - (code >= 0xFF00 && code <= 0xFFEF) || // 全角字符 - (code >= 0x20 && code <= 0x7E) // ASCII 可打印字符 - ) - - if (isPrintable && code !== 0x01 && code !== 0x18) { - foundStart = true - nickname += char - } else if (foundStart) { - // 遇到不可打印字符,停止 - break - } - } - - // 清理昵称:去除前后空白和特殊字符 - nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '') - - // 只保存有效的群昵称(长度 > 0 且 < 50) - if (nickname && nickname.length > 0 && nickname.length < 50) { - nicknameMap.set(wxidLower, nickname) - } - } - } catch (e) { - // 解析失败时返回空 Map - console.error('Failed to parse ext_buffer:', e) - } - - return nicknameMap - } - - /** - * 从 contact.db 的 chat_room 表获取群成员的群昵称 - * @param chatroomId 群聊ID (如 "xxxxx@chatroom") - * @returns Map + * 从 DLL 获取群成员的群昵称 */ async getGroupNicknamesForRoom(chatroomId: string): Promise> { - console.log('========== getGroupNicknamesForRoom START ==========', chatroomId) try { - // 查询 contact.db 的 chat_room 表 - // path设为null,因为contact.db已经随handle一起打开了 - const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'` - console.log('执行SQL查询:', sql) - - const result = await wcdbService.execQuery('contact', null, sql) - console.log('execQuery结果:', { success: result.success, rowCount: result.rows?.length, error: result.error }) - - if (!result.success || !result.rows || result.rows.length === 0) { - console.log('❌ 群昵称查询失败或无数据:', chatroomId, result.error) - return new Map() + const result = await wcdbService.getGroupNicknames(chatroomId) + if (result.success && result.nicknames) { + return new Map(Object.entries(result.nicknames)) } - - let extBuffer = result.rows[0].ext_buffer - console.log('ext_buffer原始类型:', typeof extBuffer, 'isBuffer:', Buffer.isBuffer(extBuffer)) - - // execQuery返回的二进制数据会被编码为字符串(hex或base64) - // 需要转换回Buffer - if (typeof extBuffer === 'string') { - console.log('🔄 ext_buffer是字符串,尝试转换为Buffer...') - - // 尝试判断是hex还是base64 - if (this.looksLikeHex(extBuffer)) { - console.log('✅ 检测到hex编码,使用hex解码') - extBuffer = Buffer.from(extBuffer, 'hex') - } else if (this.looksLikeBase64(extBuffer)) { - console.log('✅ 检测到base64编码,使用base64解码') - extBuffer = Buffer.from(extBuffer, 'base64') - } else { - // 默认尝试hex - console.log(' 无法判断编码格式,默认尝试hex') - try { - extBuffer = Buffer.from(extBuffer, 'hex') - } catch (e) { - console.log('❌ hex解码失败,尝试base64') - extBuffer = Buffer.from(extBuffer, 'base64') - } - } - console.log('✅ 转换后的Buffer长度:', extBuffer.length) - } - - if (!extBuffer || !Buffer.isBuffer(extBuffer)) { - console.log('❌ ext_buffer转换失败,不是Buffer类型:', typeof extBuffer) - return new Map() - } - - console.log('✅ 开始解析ext_buffer, 长度:', extBuffer.length) - const nicknamesMap = this.parseGroupNicknamesFromExtBuffer(extBuffer) - console.log('✅ 解析完成, 找到', nicknamesMap.size, '个群昵称') - - // 打印前5个群昵称作为示例 - let count = 0 - for (const [wxid, nickname] of nicknamesMap.entries()) { - if (count++ < 5) { - console.log(` - ${wxid}: "${nickname}"`) - } - } - - return nicknamesMap - } catch (e) { - console.error('❌ getGroupNicknamesForRoom异常:', e) return new Map() - } finally { - console.log('========== getGroupNicknamesForRoom END ==========') + } catch (e) { + console.error('getGroupNicknamesForRoom error:', e) + return new Map() } } @@ -432,6 +305,15 @@ class ExportService { return /^[0-9a-fA-F]+$/.test(s) } + private normalizeGroupNickname(value: string): string { + const trimmed = (value || '').trim() + if (!trimmed) return '' + const cleaned = trimmed.replace(/[\x00-\x1F\x7F]/g, '') + if (!cleaned) return '' + if (/^[,"'“”‘’,、]+$/.test(cleaned)) return '' + return cleaned + } + /** * 根据用户偏好获取显示名称 */ @@ -1595,6 +1477,87 @@ class ExportService { return result } + /** + * 导出头像为外部文件(仅用于HTML格式) + * 将头像保存到 avatars/ 子目录,返回相对路径 + */ + private async exportAvatarsToFiles( + members: Array<{ username: string; avatarUrl?: string }>, + outputDir: string + ): Promise> { + const result = new Map() + if (members.length === 0) return result + + // 创建 avatars 子目录 + const avatarsDir = path.join(outputDir, 'avatars') + if (!fs.existsSync(avatarsDir)) { + fs.mkdirSync(avatarsDir, { recursive: true }) + } + + for (const member of members) { + const fileInfo = this.resolveAvatarFile(member.avatarUrl) + if (!fileInfo) continue + try { + let data: Buffer | null = null + let mime = fileInfo.mime + if (fileInfo.data) { + data = fileInfo.data + } else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) { + data = await fs.promises.readFile(fileInfo.sourcePath) + } else if (fileInfo.sourceUrl) { + const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl) + if (downloaded) { + data = downloaded.data + mime = downloaded.mime || mime + } + } + if (!data) continue + + // 优先使用内容检测出的 MIME 类型 + const detectedMime = this.detectMimeType(data) + const finalMime = detectedMime || mime || this.inferImageMime(fileInfo.ext) + + // 根据 MIME 类型确定文件扩展名 + const ext = this.getExtensionFromMime(finalMime) + + // 清理用户名作为文件名(移除非法字符,限制长度) + const sanitizedUsername = member.username + .replace(/[<>:"/\\|?*@]/g, '_') + .substring(0, 100) + + const filename = `${sanitizedUsername}${ext}` + const avatarPath = path.join(avatarsDir, filename) + + // 保存头像文件 + await fs.promises.writeFile(avatarPath, data) + + // 返回相对路径 + result.set(member.username, `avatars/${filename}`) + } catch { + continue + } + } + + return result + } + + private getExtensionFromMime(mime: string): string { + switch (mime) { + case 'image/png': + return '.png' + case 'image/gif': + return '.gif' + case 'image/webp': + return '.webp' + case 'image/bmp': + return '.bmp' + case 'image/jpeg': + default: + return '.jpg' + } + } + + private detectMimeType(buffer: Buffer): string | null { if (buffer.length < 4) return null @@ -2034,7 +1997,7 @@ class ExportService { ? contact.contact.nickName : (senderInfo.displayName || senderWxid) const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : '' - const senderGroupNickname = groupNicknamesMap.get(senderWxid?.toLowerCase() || '') || '' + const senderGroupNickname = this.normalizeGroupNickname(groupNicknamesMap.get(senderWxid?.toLowerCase() || '') || '') // 使用用户偏好的显示名称 const senderDisplayName = this.getPreferredDisplayName( @@ -2080,7 +2043,7 @@ class ExportService { ? sessionContact.contact.remark : '' const sessionGroupNickname = isGroup - ? (groupNicknamesMap.get(sessionId.toLowerCase()) || '') + ? this.normalizeGroupNickname(groupNicknamesMap.get(sessionId.toLowerCase()) || '') : '' // 使用用户偏好的显示名称 @@ -2320,11 +2283,9 @@ class ExportService { } // 预加载群昵称 (仅群聊且完整列模式) - console.log('预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId) const groupNicknamesMap = (isGroup && !useCompactColumns) ? await this.getGroupNicknamesForRoom(sessionId) : new Map() - console.log('群昵称Map大小:', groupNicknamesMap.size) // 填充数据 @@ -2447,7 +2408,7 @@ class ExportService { // 获取群昵称 (仅群聊且完整列模式) if (isGroup && !useCompactColumns && senderWxid) { - senderGroupNickname = groupNicknamesMap.get(senderWxid.toLowerCase()) || '' + senderGroupNickname = this.normalizeGroupNickname(groupNicknamesMap.get(senderWxid.toLowerCase()) || '') } @@ -2466,11 +2427,11 @@ class ExportService { ) : (mediaItem?.relativePath || this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(msg.localId) - )) + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(msg.localId) + )) // 调试日志 if (msg.localType === 3 || msg.localType === 47) { @@ -2715,11 +2676,11 @@ class ExportService { ) : (mediaItem?.relativePath || this.formatPlainExportContent( - msg.content, - msg.localType, - options, - voiceTranscriptMap.get(msg.localId) - )) + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(msg.localId) + )) let senderRole: string let senderWxid: string @@ -2892,7 +2853,7 @@ class ExportService { } const avatarMap = options.exportAvatars - ? await this.exportAvatars( + ? await this.exportAvatarsToFiles( [ ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ username, @@ -2900,7 +2861,8 @@ class ExportService { })), { username: sessionId, avatarUrl: sessionInfo.avatarUrl }, { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl } - ] + ], + path.dirname(outputPath) ) : new Map() @@ -2917,7 +2879,7 @@ class ExportService { : (sessionInfo.displayName || sessionId)) const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername) const avatarHtml = avatarData - ? `${this.escapeAttribute(senderName)}` + ? `${this.escapeAttribute(senderName)}` : `${this.escapeHtml(this.getAvatarFallback(senderName))}` const timeText = this.formatTimestamp(msg.createTime) diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index bd4e123..3e09181 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -16,6 +16,10 @@ export interface GroupMember { username: string displayName: string avatarUrl?: string + nickname?: string + alias?: string + remark?: string + groupNickname?: string } export interface GroupMessageRank { @@ -93,99 +97,16 @@ class GroupAnalyticsService { return { success: true } } - private looksLikeHex(s: string): boolean { - if (s.length % 2 !== 0) return false - return /^[0-9a-fA-F]+$/.test(s) - } - - private looksLikeBase64(s: string): boolean { - if (s.length % 4 !== 0) return false - return /^[A-Za-z0-9+/=]+$/.test(s) - } - /** - * 解析 ext_buffer 二进制数据,提取群成员的群昵称 - */ - private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map { - const nicknameMap = new Map() - - try { - const raw = buffer.toString('utf8') - const wxidPattern = /wxid_[a-z0-9_]+/gi - const wxids = raw.match(wxidPattern) || [] - - for (const wxid of wxids) { - const wxidLower = wxid.toLowerCase() - const wxidIndex = raw.toLowerCase().indexOf(wxidLower) - if (wxidIndex === -1) continue - - const afterWxid = raw.slice(wxidIndex + wxid.length) - let nickname = '' - let foundStart = false - - for (let i = 0; i < afterWxid.length && i < 100; i++) { - const char = afterWxid[i] - const code = char.charCodeAt(0) - const isPrintable = ( - (code >= 0x4E00 && code <= 0x9FFF) || - (code >= 0x3000 && code <= 0x303F) || - (code >= 0xFF00 && code <= 0xFFEF) || - (code >= 0x20 && code <= 0x7E) - ) - - if (isPrintable && code !== 0x01 && code !== 0x18) { - foundStart = true - nickname += char - } else if (foundStart) { - break - } - } - - nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '') - if (nickname && nickname.length < 50) { - nicknameMap.set(wxidLower, nickname) - } - } - } catch (e) { - console.error('Failed to parse ext_buffer:', e) - } - - return nicknameMap - } - - /** - * 从 contact.db 的 chat_room 表获取群成员的群昵称 + * 从 DLL 获取群成员的群昵称 */ private async getGroupNicknamesForRoom(chatroomId: string): Promise> { try { - const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'` - const result = await wcdbService.execQuery('contact', null, sql) - - if (!result.success || !result.rows || result.rows.length === 0) { - return new Map() + const result = await wcdbService.getGroupNicknames(chatroomId) + if (result.success && result.nicknames) { + return new Map(Object.entries(result.nicknames)) } - - let extBuffer = result.rows[0].ext_buffer - - if (typeof extBuffer === 'string') { - if (this.looksLikeHex(extBuffer)) { - extBuffer = Buffer.from(extBuffer, 'hex') - } else if (this.looksLikeBase64(extBuffer)) { - extBuffer = Buffer.from(extBuffer, 'base64') - } else { - try { - extBuffer = Buffer.from(extBuffer, 'hex') - } catch { - extBuffer = Buffer.from(extBuffer, 'base64') - } - } - } - - if (!extBuffer || !Buffer.isBuffer(extBuffer)) { - return new Map() - } - - return this.parseGroupNicknamesFromExtBuffer(extBuffer) + return new Map() } catch (e) { console.error('getGroupNicknamesForRoom error:', e) return new Map() @@ -294,14 +215,55 @@ class GroupAnalyticsService { } const members = result.members as { username: string; avatarUrl?: string }[] - const usernames = members.map((m) => m.username) - const displayNames = await wcdbService.getDisplayNames(usernames) + const usernames = members.map((m) => m.username).filter(Boolean) - const data: GroupMember[] = members.map((m) => ({ - username: m.username, - displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username, - avatarUrl: m.avatarUrl - })) + const [displayNames, groupNicknames] = await Promise.all([ + wcdbService.getDisplayNames(usernames), + this.getGroupNicknamesForRoom(chatroomId) + ]) + + const contactMap = new Map() + const concurrency = 6 + await this.parallelLimit(usernames, concurrency, async (username) => { + const contactResult = await wcdbService.getContact(username) + if (contactResult.success && contactResult.contact) { + const contact = contactResult.contact as any + contactMap.set(username, { + remark: contact.remark || '', + nickName: contact.nickName || contact.nick_name || '', + alias: contact.alias || '' + }) + } else { + contactMap.set(username, { remark: '', nickName: '', alias: '' }) + } + }) + + const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') + const data: GroupMember[] = members.map((m) => { + const wxid = m.username || '' + const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid + const contact = contactMap.get(wxid) + const nickname = contact?.nickName || '' + const remark = contact?.remark || '' + const alias = contact?.alias || '' + const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || '' + const normalizedWxid = this.cleanAccountDirName(wxid) + const groupNickname = this.normalizeGroupNickname( + rawGroupNickname, + normalizedWxid === myWxid ? myWxid : wxid, + '' + ) + + return { + username: wxid, + displayName, + nickname, + alias, + remark, + groupNickname, + avatarUrl: m.avatarUrl + } + }) return { success: true, data } } catch (e) { diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 14bf1ff..b6b17c9 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -35,6 +35,7 @@ export class WcdbCore { private wcdbGetGroupMemberCount: any = null private wcdbGetGroupMemberCounts: any = null private wcdbGetGroupMembers: any = null + private wcdbGetGroupNicknames: any = null private wcdbGetMessageTables: any = null private wcdbGetMessageMeta: any = null private wcdbGetContact: any = null @@ -57,6 +58,7 @@ export class WcdbCore { private wcdbGetDbStatus: any = null private wcdbGetVoiceData: any = null private wcdbGetSnsTimeline: any = null + private wcdbGetSnsAnnualStats: any = null private wcdbVerifyUser: any = null private avatarUrlCache: Map = new Map() private readonly avatarCacheTtlMs = 10 * 60 * 1000 @@ -333,6 +335,13 @@ export class WcdbCore { // wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json) this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)') + // wcdb_status wcdb_get_group_nicknames(wcdb_handle handle, const char* chatroom_id, char** out_json) + try { + this.wcdbGetGroupNicknames = this.lib.func('int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)') + } catch { + this.wcdbGetGroupNicknames = null + } + // wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json) this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)') @@ -369,6 +378,13 @@ export class WcdbCore { this.wcdbGetAnnualReportExtras = null } + // wcdb_status wcdb_get_logs(char** out_json) + try { + this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)') + } catch { + this.wcdbGetLogs = null + } + // wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) try { this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)') @@ -431,6 +447,13 @@ export class WcdbCore { this.wcdbGetSnsTimeline = null } + // wcdb_status wcdb_get_sns_annual_stats(wcdb_handle handle, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) + try { + this.wcdbGetSnsAnnualStats = this.lib.func('int32 wcdb_get_sns_annual_stats(int64 handle, int32 begin, int32 end, _Out_ void** outJson)') + } catch { + this.wcdbGetSnsAnnualStats = null + } + // void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len) try { this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)') @@ -1002,6 +1025,28 @@ export class WcdbCore { } } + async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetGroupNicknames) { + return { success: false, error: '当前 DLL 版本不支持获取群昵称接口' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetGroupNicknames(this.handle, chatroomId, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取群昵称失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析群昵称失败' } + const nicknames = JSON.parse(jsonStr) + return { success: true, nicknames } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } @@ -1343,13 +1388,31 @@ export class WcdbCore { } } + async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> { + if (!this.lib) return { success: false, error: 'DLL 未加载' } + if (!this.wcdbGetLogs) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetLogs(outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取日志失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析日志失败' } + return { success: true, logs: JSON.parse(jsonStr) } + } catch (e) { + return { success: false, error: String(e) } + } + } + async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } try { + if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' } const outPtr = [null as any] - const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr) + const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr) if (result !== 0 || !outPtr[0]) { return { success: false, error: `执行查询失败: ${result}` } } @@ -1502,4 +1565,29 @@ export class WcdbCore { return { success: false, error: String(e) } } } + + async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + if (!this.wcdbGetSnsAnnualStats) { + return { success: false, error: 'wcdbGetSnsAnnualStats 未找到' } + } + await new Promise(resolve => setImmediate(resolve)) + const outPtr = [null as any] + const result = this.wcdbGetSnsAnnualStats(this.handle, beginTimestamp, endTimestamp, outPtr) + await new Promise(resolve => setImmediate(resolve)) + + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `getSnsAnnualStats failed: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: 'Failed to decode JSON' } + return { success: true, data: JSON.parse(jsonStr) } + } catch (e) { + console.error('getSnsAnnualStats 异常:', e) + return { success: false, error: String(e) } + } + } } \ No newline at end of file diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index b885088..107c2c8 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -229,6 +229,11 @@ export class WcdbService { return this.callWorker('getGroupMembers', { chatroomId }) } + // 获取群成员群名片昵称 + async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record; error?: string }> { + return this.callWorker('getGroupNicknames', { chatroomId }) + } + /** * 获取消息表列表 */ @@ -369,6 +374,20 @@ export class WcdbService { return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime }) } + /** + * 获取朋友圈年度统计 + */ + async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp }) + } + + /** + * 获取 DLL 内部日志 + */ + async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> { + return this.callWorker('getLogs') + } + /** * 验证 Windows Hello */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index b03d49a..259b372 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -56,6 +56,9 @@ if (parentPort) { case 'getGroupMembers': result = await core.getGroupMembers(payload.chatroomId) break + case 'getGroupNicknames': + result = await core.getGroupNicknames(payload.chatroomId) + break case 'getMessageTables': result = await core.getMessageTables(payload.sessionId) break @@ -119,6 +122,12 @@ if (parentPort) { case 'getSnsTimeline': result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) break + case 'getSnsAnnualStats': + result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp) + break + case 'getLogs': + result = await core.getLogs() + break case 'verifyUser': result = await core.verifyUser(payload.message, payload.hwnd) break diff --git a/package-lock.json b/package-lock.json index faac1e1..ee1d0f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "weflow", - "version": "1.4.4", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "weflow", - "version": "1.4.4", + "version": "1.5.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.5.0", diff --git a/package.json b/package.json index 534b89f..4ba7a88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "weflow", - "version": "1.4.4", + "version": "1.5.0", "description": "WeFlow", "main": "dist-electron/main.js", "author": "cc", diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index c4233c0..d63b6bd 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/App.tsx b/src/App.tsx index df67e50..95d9e9d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,8 @@ import AnalyticsPage from './pages/AnalyticsPage' import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage' import AnnualReportPage from './pages/AnnualReportPage' import AnnualReportWindow from './pages/AnnualReportWindow' +import DualReportPage from './pages/DualReportPage' +import DualReportWindow from './pages/DualReportWindow' import AgreementPage from './pages/AgreementPage' import GroupAnalyticsPage from './pages/GroupAnalyticsPage' import SettingsPage from './pages/SettingsPage' @@ -398,6 +400,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/pages/AnalyticsPage.scss b/src/pages/AnalyticsPage.scss index 702983a..c45c74e 100644 --- a/src/pages/AnalyticsPage.scss +++ b/src/pages/AnalyticsPage.scss @@ -47,6 +47,24 @@ } } +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + + h1 { + margin: 0; + } + + .header-actions { + display: flex; + align-items: center; + gap: 8px; + } +} + @keyframes spin { from { transform: rotate(0deg); @@ -292,4 +310,185 @@ grid-column: span 1; } } -} \ No newline at end of file +} + +// 排除好友弹窗 +.exclude-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.exclude-modal { + width: 560px; + max-width: calc(100vw - 48px); + background: var(--card-bg); + border-radius: 16px; + border: 1px solid var(--border-color); + padding: 20px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + + .exclude-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + + h3 { + margin: 0; + font-size: 16px; + color: var(--text-primary); + } + } + + .modal-close { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); + transition: all 0.15s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .exclude-modal-search { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + margin-bottom: 12px; + color: var(--text-tertiary); + + input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-size: 13px; + } + + .clear-search { + background: none; + border: none; + cursor: pointer; + color: var(--text-tertiary); + padding: 2px; + + &:hover { + color: var(--text-primary); + } + } + } + + .exclude-modal-body { + max-height: 420px; + overflow: auto; + padding-right: 4px; + } + + .exclude-loading, + .exclude-error, + .exclude-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-secondary); + padding: 24px 0; + font-size: 13px; + } + + .exclude-list { + display: flex; + flex-direction: column; + gap: 6px; + } + + .exclude-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 10px; + border-radius: 10px; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.15s; + background: var(--bg-primary); + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + border-color: rgba(7, 193, 96, 0.4); + background: rgba(7, 193, 96, 0.08); + } + + input { + margin: 0; + } + } + + .exclude-avatar { + flex-shrink: 0; + } + + .exclude-info { + display: flex; + flex-direction: column; + min-width: 0; + gap: 2px; + } + + .exclude-name { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .exclude-username { + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .exclude-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16px; + } + + .exclude-count { + font-size: 12px; + color: var(--text-tertiary); + } + + .exclude-actions { + display: flex; + gap: 8px; + } +} diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 528070d..9e56515 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -1,20 +1,51 @@ import { useState, useEffect, useCallback } from 'react' import { useLocation } from 'react-router-dom' -import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react' +import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react' import ReactECharts from 'echarts-for-react' import { useAnalyticsStore } from '../stores/analyticsStore' import { useThemeStore } from '../stores/themeStore' import './AnalyticsPage.scss' import { Avatar } from '../components/Avatar' +interface ExcludeCandidate { + username: string + displayName: string + avatarUrl?: string + wechatId?: string +} + +const normalizeUsername = (value: string) => value.trim().toLowerCase() + function AnalyticsPage() { const [isLoading, setIsLoading] = useState(false) const [loadingStatus, setLoadingStatus] = useState('') const [error, setError] = useState(null) const [progress, setProgress] = useState(0) + const [isExcludeDialogOpen, setIsExcludeDialogOpen] = useState(false) + const [excludeCandidates, setExcludeCandidates] = useState([]) + const [excludeQuery, setExcludeQuery] = useState('') + const [excludeLoading, setExcludeLoading] = useState(false) + const [excludeError, setExcludeError] = useState(null) + const [excludedUsernames, setExcludedUsernames] = useState>(new Set()) + const [draftExcluded, setDraftExcluded] = useState>(new Set()) const themeMode = useThemeStore((state) => state.themeMode) - const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore() + const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore() + + const loadExcludedUsernames = useCallback(async () => { + try { + const result = await window.electronAPI.analytics.getExcludedUsernames() + if (result.success && result.data) { + setExcludedUsernames(new Set(result.data.map(normalizeUsername))) + } else { + setExcludedUsernames(new Set()) + } + } catch (e) { + console.warn('加载排除名单失败', e) + setExcludedUsernames(new Set()) + } + }, []) + const loadData = useCallback(async (forceRefresh = false) => { if (isLoaded && !forceRefresh) return setIsLoading(true) @@ -65,14 +96,89 @@ function AnalyticsPage() { useEffect(() => { const handleChange = () => { + loadExcludedUsernames() loadData(true) } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadData]) + }, [loadData, loadExcludedUsernames]) + + useEffect(() => { + loadExcludedUsernames() + }, [loadExcludedUsernames]) const handleRefresh = () => loadData(true) + const loadExcludeCandidates = useCallback(async () => { + setExcludeLoading(true) + setExcludeError(null) + try { + const result = await window.electronAPI.analytics.getExcludeCandidates() + if (result.success && result.data) { + setExcludeCandidates(result.data) + } else { + setExcludeError(result.error || '加载好友列表失败') + } + } catch (e) { + setExcludeError(String(e)) + } finally { + setExcludeLoading(false) + } + }, []) + + const openExcludeDialog = async () => { + setExcludeQuery('') + setDraftExcluded(new Set(excludedUsernames)) + setIsExcludeDialogOpen(true) + await loadExcludeCandidates() + } + + const toggleExcluded = (username: string) => { + const key = normalizeUsername(username) + setDraftExcluded((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + } + + const handleApplyExcluded = async () => { + const payload = Array.from(draftExcluded) + setIsExcludeDialogOpen(false) + try { + const result = await window.electronAPI.analytics.setExcludedUsernames(payload) + if (!result.success) { + alert(result.error || '更新排除名单失败') + return + } + setExcludedUsernames(new Set((result.data || payload).map(normalizeUsername))) + clearCache() + await window.electronAPI.cache.clearAnalytics() + await loadData(true) + } catch (e) { + alert(`更新排除名单失败:${String(e)}`) + } + } + + const visibleExcludeCandidates = excludeCandidates + .filter((candidate) => { + const query = excludeQuery.trim().toLowerCase() + if (!query) return true + const wechatId = candidate.wechatId || '' + const haystack = `${candidate.displayName} ${candidate.username} ${wechatId}`.toLowerCase() + return haystack.includes(query) + }) + .sort((a, b) => { + const aSelected = draftExcluded.has(normalizeUsername(a.username)) + const bSelected = draftExcluded.has(normalizeUsername(b.username)) + if (aSelected !== bSelected) return aSelected ? -1 : 1 + return a.displayName.localeCompare(b.displayName, 'zh') + }) + const formatDate = (timestamp: number | null) => { if (!timestamp) return '-' const date = new Date(timestamp * 1000) @@ -247,10 +353,16 @@ function AnalyticsPage() { <>

私聊分析

- +
+ + +
@@ -316,6 +428,84 @@ function AnalyticsPage() {
+ {isExcludeDialogOpen && ( +
setIsExcludeDialogOpen(false)}> +
e.stopPropagation()}> +
+

选择不统计的好友

+ +
+
+ + setExcludeQuery(e.target.value)} + disabled={excludeLoading} + /> + {excludeQuery && ( + + )} +
+
+ {excludeLoading && ( +
+ + 正在加载好友列表... +
+ )} + {!excludeLoading && excludeError && ( +
{excludeError}
+ )} + {!excludeLoading && !excludeError && ( +
+ {visibleExcludeCandidates.map((candidate) => { + const isChecked = draftExcluded.has(normalizeUsername(candidate.username)) + const wechatId = candidate.wechatId?.trim() || candidate.username + return ( + + ) + })} + {visibleExcludeCandidates.length === 0 && ( +
+ {excludeQuery.trim() ? '未找到匹配好友' : '暂无可选好友'} +
+ )} +
+ )} +
+
+ 已排除 {draftExcluded.size} 人 +
+ + +
+
+
+
+ )} ) } diff --git a/src/pages/AnnualReportPage.scss b/src/pages/AnnualReportPage.scss index e5839c4..5f58d7f 100644 --- a/src/pages/AnnualReportPage.scss +++ b/src/pages/AnnualReportPage.scss @@ -5,6 +5,7 @@ justify-content: center; min-height: 100%; text-align: center; + padding: 40px 24px; } .header-icon { @@ -25,6 +26,63 @@ margin: 0 0 48px; } +.report-sections { + display: flex; + flex-direction: column; + gap: 32px; + width: min(760px, 100%); +} + +.report-section { + width: 100%; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 28px; + text-align: left; +} + +.section-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.section-title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: var(--text-primary); +} + +.section-desc { + margin: 8px 0 0; + font-size: 14px; + color: var(--text-tertiary); +} + +.section-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.section-hint { + margin: 12px 0 0; + font-size: 12px; + color: var(--text-tertiary); +} + .year-grid { display: flex; flex-wrap: wrap; @@ -34,6 +92,12 @@ margin-bottom: 48px; } +.report-section .year-grid { + justify-content: flex-start; + max-width: none; + margin-bottom: 24px; +} + .year-card { width: 120px; height: 100px; @@ -104,6 +168,13 @@ opacity: 0.6; cursor: not-allowed; } + + &.secondary { + background: var(--card-bg); + color: var(--text-primary); + border: 1px solid var(--border-color); + box-shadow: none; + } } .spin { diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx index 7931764..d0aa943 100644 --- a/src/pages/AnnualReportPage.tsx +++ b/src/pages/AnnualReportPage.tsx @@ -1,12 +1,15 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { Calendar, Loader2, Sparkles } from 'lucide-react' +import { Calendar, Loader2, Sparkles, Users } from 'lucide-react' import './AnnualReportPage.scss' +type YearOption = number | 'all' + function AnnualReportPage() { const navigate = useNavigate() const [availableYears, setAvailableYears] = useState([]) - const [selectedYear, setSelectedYear] = useState(null) + const [selectedYear, setSelectedYear] = useState(null) + const [selectedPairYear, setSelectedPairYear] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isGenerating, setIsGenerating] = useState(false) const [loadError, setLoadError] = useState(null) @@ -22,7 +25,8 @@ function AnnualReportPage() { const result = await window.electronAPI.annualReport.getAvailableYears() if (result.success && result.data && result.data.length > 0) { setAvailableYears(result.data) - setSelectedYear(result.data[0]) + setSelectedYear((prev) => prev ?? result.data[0]) + setSelectedPairYear((prev) => prev ?? result.data[0]) } else if (!result.success) { setLoadError(result.error || '加载年度数据失败') } @@ -35,10 +39,11 @@ function AnnualReportPage() { } const handleGenerateReport = async () => { - if (!selectedYear) return + if (selectedYear === null) return setIsGenerating(true) try { - navigate(`/annual-report/view?year=${selectedYear}`) + const yearParam = selectedYear === 'all' ? 0 : selectedYear + navigate(`/annual-report/view?year=${yearParam}`) } catch (e) { console.error('生成报告失败:', e) } finally { @@ -46,6 +51,12 @@ function AnnualReportPage() { } } + const handleGenerateDualReport = () => { + if (selectedPairYear === null) return + const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear + navigate(`/dual-report?year=${yearParam}`) + } + if (isLoading) { return (
@@ -67,42 +78,98 @@ function AnnualReportPage() { ) } + const yearOptions: YearOption[] = availableYears.length > 0 + ? ['all', ...availableYears] + : [] + + const getYearLabel = (value: YearOption | null) => { + if (!value) return '' + return value === 'all' ? '全部时间' : `${value} 年` + } + return (

年度报告

选择年份,生成你的微信聊天年度回顾

-
- {availableYears.map(year => ( -
setSelectedYear(year)} - > - {year} - +
+
+
+
+

总年度报告

+

包含所有会话与消息

+
- ))} -
- +
+ {yearOptions.map(option => ( +
setSelectedYear(option)} + > + {option === 'all' ? '全部' : option} + {option === 'all' ? '时间' : '年'} +
+ ))} +
+ + + + +
+
+
+

双人年度报告

+

选择一位好友,只看你们的私聊

+
+
+ + 私聊 +
+
+ +
+ {yearOptions.map(option => ( +
setSelectedPairYear(option)} + > + {option === 'all' ? '全部' : option} + {option === 'all' ? '时间' : '年'} +
+ ))} +
+ + +

从聊天排行中选择好友生成双人报告

+
+
) } diff --git a/src/pages/AnnualReportWindow.scss b/src/pages/AnnualReportWindow.scss index db26b11..9a8efec 100644 --- a/src/pages/AnnualReportWindow.scss +++ b/src/pages/AnnualReportWindow.scss @@ -1279,3 +1279,134 @@ color: var(--ar-text-sub) !important; text-align: center; } +// 曾经的好朋友 视觉效果 +.lost-friend-visual { + display: flex; + align-items: center; + justify-content: center; + gap: 32px; + margin: 64px auto 48px; + position: relative; + max-width: 480px; + + .avatar-group { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + z-index: 2; + + .avatar-label { + font-size: 13px; + color: var(--ar-text-sub); + font-weight: 500; + opacity: 0.6; + } + + &.sender { + animation: fadeInRight 1s ease-out backwards; + } + + &.receiver { + animation: fadeInLeft 1s ease-out backwards; + } + } + + .fading-line { + position: relative; + flex: 1; + height: 2px; + min-width: 120px; + display: flex; + align-items: center; + justify-content: center; + + .line-path { + width: 100%; + height: 100%; + background: linear-gradient(to right, + var(--ar-primary) 0%, + rgba(var(--ar-primary-rgb), 0.4) 50%, + rgba(var(--ar-primary-rgb), 0.05) 100%); + border-radius: 2px; + } + + .line-glow { + position: absolute; + inset: -4px 0; + background: linear-gradient(to right, + rgba(var(--ar-primary-rgb), 0.2) 0%, + transparent 100%); + filter: blur(8px); + pointer-events: none; + } + + .flow-particle { + position: absolute; + width: 40px; + height: 2px; + background: linear-gradient(to right, transparent, var(--ar-primary), transparent); + border-radius: 2px; + opacity: 0; + animation: flowAcross 4s infinite linear; + } + } +} + +.hero-desc.fading { + opacity: 0.7; + font-style: italic; + font-size: 16px; + margin-top: 32px; + line-height: 1.8; + letter-spacing: 0.05em; + animation: fadeIn 1.5s ease-out 0.5s backwards; +} + +@keyframes flowAcross { + 0% { + left: -20%; + opacity: 0; + } + + 10% { + opacity: 0.8; + } + + 50% { + opacity: 0.4; + } + + 90% { + opacity: 0.1; + } + + 100% { + left: 120%; + opacity: 0; + } +} + +@keyframes fadeInRight { + from { + opacity: 0; + transform: translateX(-20px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes fadeInLeft { + from { + opacity: 0; + transform: translateX(20px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index bf105f3..afa4765 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -71,6 +71,20 @@ interface AnnualReportData { socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null topPhrases?: { phrase: string; count: number }[] + snsStats?: { + totalPosts: number + typeCounts?: Record + topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[] + topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] + } + lostFriend: { + username: string + displayName: string + avatarUrl?: string + earlyCount: number + lateCount: number + periodDesc: string + } | null } interface SectionInfo { @@ -274,6 +288,8 @@ function AnnualReportWindow() { responseSpeed: useRef(null), topPhrases: useRef(null), ranking: useRef(null), + sns: useRef(null), + lostFriend: useRef(null), ending: useRef(null), } @@ -282,7 +298,8 @@ function AnnualReportWindow() { useEffect(() => { const params = new URLSearchParams(window.location.hash.split('?')[1] || '') const yearParam = params.get('year') - const year = yearParam ? parseInt(yearParam) : new Date().getFullYear() + const parsedYear = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear() + const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear generateReport(year) }, []) @@ -337,6 +354,11 @@ function AnnualReportWindow() { return `${Math.round(seconds / 3600)}小时` } + const formatYearLabel = (value: number, withSuffix: boolean = true) => { + if (value === 0) return '历史以来' + return withSuffix ? `${value}年` : `${value}` + } + // 获取可用的板块列表 const getAvailableSections = (): SectionInfo[] => { if (!reportData) return [] @@ -367,10 +389,16 @@ function AnnualReportWindow() { if (reportData.responseSpeed) { sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed }) } + if (reportData.lostFriend) { + sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend }) + } if (reportData.topPhrases && reportData.topPhrases.length > 0) { sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases }) } sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking }) + if (reportData.snsStats && reportData.snsStats.totalPosts > 0) { + sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns }) + } sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending }) return sections } @@ -595,7 +623,8 @@ function AnnualReportWindow() { const dataUrl = outputCanvas.toDataURL('image/png') const link = document.createElement('a') - link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png` + const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : '' + link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png` link.href = dataUrl document.body.appendChild(link) link.click() @@ -658,11 +687,12 @@ function AnnualReportWindow() { } setExportProgress('正在写入文件...') + const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : '' const exportResult = await window.electronAPI.annualReport.exportImages({ baseDir: dirResult.filePaths[0], - folderName: `${reportData?.year}年度报告_分模块`, + folderName: `${yearFilePrefix}年度报告_分模块`, images: exportedImages.map((img) => ({ - name: `${reportData?.year}年度报告_${img.name}.png`, + name: `${yearFilePrefix}年度报告_${img.name}.png`, dataUrl: img.data })) }) @@ -733,10 +763,14 @@ function AnnualReportWindow() { ) } - const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData + const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData const topFriend = coreFriends[0] const mostActive = getMostActiveTime(activityHeatmap.data) const socialStoryName = topFriend?.displayName || '好友' + const yearTitle = formatYearLabel(year, true) + const yearTitleShort = formatYearLabel(year, false) + const monthlyTitle = year === 0 ? '历史以来月度好友' : `${year}年月度好友` + const phrasesTitle = year === 0 ? '你在历史以来的常用语' : `你在${year}年的年度常用语` return (
@@ -827,7 +861,7 @@ function AnnualReportWindow() { {/* 封面 */}
WEFLOW · ANNUAL REPORT
-

{year}年
微信聊天报告

+

{yearTitle}
微信聊天报告


每一条消息背后
都藏着一段独特的故事

@@ -869,7 +903,7 @@ function AnnualReportWindow() { {/* 月度好友 */}
月度好友
-

{year}年月度好友

+

{monthlyTitle}

根据12个月的聊天习惯

{monthlyTopFriends.map((m, i) => ( @@ -1012,11 +1046,46 @@ function AnnualReportWindow() {
)} + {/* 曾经的好朋友 */} + {lostFriend && ( +
+
曾经的好朋友
+

{lostFriend.displayName}

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

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

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

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

+
+ )} + {/* 年度常用语 - 词云 */} {topPhrases && topPhrases.length > 0 && (
年度常用语
-

你在{year}年的年度常用语

+

{phrasesTitle}

这一年,你说得最多的是:
@@ -1029,6 +1098,57 @@ function AnnualReportWindow() {

)} + {/* 朋友圈 */} + {reportData.snsStats && reportData.snsStats.totalPosts > 0 && ( +
+
朋友圈
+

记录生活时刻

+

+ 这一年,你发布了 +

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

更关心你的Ta

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

你最关心的Ta

+
+ {reportData.snsStats.topLiked.slice(0, 3).map((u, i) => ( +
+ +
+ {u.displayName} +
+ {u.count}赞 +
+ ))} +
+
+ )} +
+
+ )} + {/* 好友排行 */}
好友排行
@@ -1085,7 +1205,7 @@ function AnnualReportWindow() {
愿新的一年,
所有期待,皆有回声。

-
{year}
+
{yearTitleShort}
WEFLOW
diff --git a/src/pages/DualReportPage.scss b/src/pages/DualReportPage.scss new file mode 100644 index 0000000..293efef --- /dev/null +++ b/src/pages/DualReportPage.scss @@ -0,0 +1,171 @@ +.dual-report-page { + padding: 32px 28px; + color: var(--text-primary); +} + +.dual-report-page.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: 12px; + color: var(--text-tertiary); +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; + + h1 { + margin: 0; + font-size: 24px; + font-weight: 700; + } + + p { + margin: 8px 0 0; + color: var(--text-secondary); + } +} + +.year-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.search-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + margin-bottom: 20px; + + input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-size: 14px; + } +} + +.ranking-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ranking-item { + display: grid; + grid-template-columns: auto auto 1fr auto; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 14px; + text-align: left; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--primary); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); + transform: translateY(-1px); + } +} + +.rank-badge { + width: 28px; + height: 28px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--border-color); + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + + &.top { + background: color-mix(in srgb, var(--primary) 18%, transparent); + color: var(--primary); + } +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + overflow: hidden; + background: var(--primary-light); + display: flex; + align-items: center; + justify-content: center; + color: var(--primary); + font-weight: 700; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.info { + display: flex; + flex-direction: column; + gap: 4px; + + .name { + font-weight: 600; + } + + .sub { + font-size: 12px; + color: var(--text-tertiary); + } +} + +.meta { + text-align: right; + font-size: 12px; + color: var(--text-tertiary); + + .count { + font-weight: 600; + color: var(--text-primary); + } +} + +.empty { + text-align: center; + color: var(--text-tertiary); + padding: 40px 0; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/pages/DualReportPage.tsx b/src/pages/DualReportPage.tsx new file mode 100644 index 0000000..3516589 --- /dev/null +++ b/src/pages/DualReportPage.tsx @@ -0,0 +1,138 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Loader2, Search, Users } from 'lucide-react' +import './DualReportPage.scss' + +interface ContactRanking { + username: string + displayName: string + avatarUrl?: string + messageCount: number + sentCount: number + receivedCount: number + lastMessageTime?: number | null +} + +function DualReportPage() { + const navigate = useNavigate() + const [year, setYear] = useState(0) + 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) + }, []) + + useEffect(() => { + loadRankings() + }, []) + + const loadRankings = async () => { + setIsLoading(true) + setLoadError(null) + try { + const result = await window.electronAPI.analytics.getContactRankings(200) + if (result.success && result.data) { + setRankings(result.data) + } else { + setLoadError(result.error || '加载好友列表失败') + } + } catch (e) { + setLoadError(String(e)) + } finally { + setIsLoading(false) + } + } + + const yearLabel = year === 0 ? '全部时间' : `${year}年` + + const filteredRankings = useMemo(() => { + 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) + }) + }, [rankings, keyword]) + + const handleSelect = (username: string) => { + const yearParam = year === 0 ? 0 : year + navigate(`/dual-report/view?username=${encodeURIComponent(username)}&year=${yearParam}`) + } + + if (isLoading) { + return ( +
+ +

正在加载聊天排行...

+
+ ) + } + + if (loadError) { + return ( +
+

加载失败:{loadError}

+
+ ) + } + + return ( +
+
+
+

双人年度报告

+

选择一位好友,生成你们的专属聊天报告

+
+
+ + {yearLabel} +
+
+ +
+ + setKeyword(e.target.value)} + placeholder="搜索好友(昵称/备注/wxid)" + /> +
+ +
+ {filteredRankings.map((item, index) => ( + + ))} + {filteredRankings.length === 0 ? ( +
没有匹配的好友
+ ) : null} +
+
+ ) +} + +export default DualReportPage diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss new file mode 100644 index 0000000..646b9ab --- /dev/null +++ b/src/pages/DualReportWindow.scss @@ -0,0 +1,253 @@ +.annual-report-window.dual-report-window { + .hero-title { + font-size: clamp(22px, 4vw, 34px); + white-space: nowrap; + } + + .dual-cover-title { + font-size: clamp(26px, 5vw, 44px); + white-space: normal; + } + .dual-names { + font-size: clamp(24px, 4vw, 40px); + font-weight: 700; + display: flex; + align-items: center; + gap: 12px; + margin: 8px 0 16px; + color: var(--ar-text-main); + + .amp { + color: var(--ar-primary); + } + } + + .dual-info-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 16px; + } + + .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 { + grid-column: 1 / -1; + } + + .info-label { + font-size: 12px; + color: var(--ar-text-sub); + margin-bottom: 8px; + } + + .info-value { + font-size: 16px; + font-weight: 600; + color: var(--ar-text-main); + } + } + + .dual-message-list { + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 12px; + } + + .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); + margin-bottom: 6px; + } + + .message-content { + font-size: 14px; + color: var(--ar-text-main); + } + } + + .first-chat-scene { + background: linear-gradient(180deg, #8f5b85 0%, #e38aa0 50%, #f6d0c8 100%); + border-radius: 20px; + padding: 28px 24px 24px; + color: #fff; + position: relative; + overflow: hidden; + margin-top: 16px; + } + + .first-chat-scene::before { + content: ""; + position: absolute; + inset: 0; + background-image: + radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.2), transparent 40%), + radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.15), transparent 35%), + radial-gradient(circle at 50% 80%, rgba(255, 255, 255, 0.12), transparent 45%); + opacity: 0.6; + pointer-events: none; + } + + .scene-title { + font-size: 24px; + font-weight: 700; + text-align: center; + margin-bottom: 8px; + } + + .scene-subtitle { + font-size: 18px; + font-weight: 500; + text-align: center; + margin-bottom: 20px; + opacity: 0.95; + } + + .scene-messages { + display: flex; + flex-direction: column; + gap: 14px; + } + + .scene-message { + display: flex; + align-items: flex-end; + gap: 12px; + + &.sent { + flex-direction: row-reverse; + } + } + + .scene-avatar { + width: 40px; + height: 40px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.25); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: #fff; + } + + .scene-bubble { + background: rgba(255, 255, 255, 0.85); + color: #5a4d5e; + padding: 10px 14px; + border-radius: 14px; + max-width: 60%; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12); + } + + .scene-message.sent .scene-bubble { + background: rgba(255, 224, 168, 0.9); + color: #4a3a2f; + } + + .scene-meta { + font-size: 11px; + opacity: 0.7; + margin-bottom: 4px; + } + + .scene-content { + font-size: 14px; + line-height: 1.4; + word-break: break-word; + } + + .scene-message.sent .scene-avatar { + background: rgba(255, 224, 168, 0.9); + color: #4a3a2f; + } + + .dual-stat-grid { + display: grid; + grid-template-columns: repeat(5, minmax(140px, 1fr)); + gap: 14px; + margin: 20px -28px 24px; + padding: 0 28px; + overflow: visible; + } + + .dual-stat-card { + background: var(--ar-card-bg); + border-radius: 14px; + padding: 14px 12px; + text-align: center; + } + + .stat-num { + font-size: clamp(20px, 2.8vw, 30px); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .stat-unit { + font-size: 12px; + } + + .dual-stat-card.long .stat-num { + font-size: clamp(18px, 2.4vw, 26px); + letter-spacing: -0.02em; + } + + .emoji-row { + display: grid; + grid-template-columns: repeat(2, minmax(260px, 1fr)); + gap: 20px; + margin: 0 -12px; + } + + .emoji-card { + border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08)); + border-radius: 16px; + padding: 18px 16px; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + justify-content: center; + background: var(--ar-card-bg); + + img { + width: 64px; + height: 64px; + object-fit: contain; + } + } + + .emoji-title { + font-size: 12px; + color: var(--ar-text-sub); + } + + .emoji-placeholder { + font-size: 12px; + color: var(--ar-text-sub); + word-break: break-all; + text-align: center; + } + + .word-cloud-empty { + color: var(--ar-text-sub); + font-size: 14px; + text-align: center; + padding: 24px 0; + } +} diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx new file mode 100644 index 0000000..8ba3f92 --- /dev/null +++ b/src/pages/DualReportWindow.tsx @@ -0,0 +1,472 @@ +import { useEffect, useState, type CSSProperties } from 'react' +import './AnnualReportWindow.scss' +import './DualReportWindow.scss' + +interface DualReportMessage { + content: string + isSentByMe: boolean + createTime: number + createTimeStr: string +} + +interface DualReportData { + year: number + selfName: string + friendUsername: string + friendName: string + firstChat: { + createTime: number + createTimeStr: string + content: string + isSentByMe: boolean + senderUsername?: string + } | null + firstChatMessages?: DualReportMessage[] + yearFirstChat?: { + createTime: number + createTimeStr: string + content: string + isSentByMe: boolean + friendName: string + firstThreeMessages: DualReportMessage[] + } | null + stats: { + totalMessages: number + totalWords: number + imageCount: number + voiceCount: number + emojiCount: number + myTopEmojiMd5?: string + friendTopEmojiMd5?: string + myTopEmojiUrl?: string + friendTopEmojiUrl?: string + } + topPhrases: Array<{ phrase: string; count: number }> +} + +const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => { + if (!words || words.length === 0) { + return
暂无高频语句
+ } + const sortedWords = [...words].sort((a, b) => b.count - a.count) + const maxCount = sortedWords.length > 0 ? sortedWords[0].count : 1 + const topWords = sortedWords.slice(0, 32) + const baseSize = 520 + + const seededRandom = (seed: number) => { + const x = Math.sin(seed) * 10000 + return x - Math.floor(x) + } + + const placedItems: { x: number; y: number; w: number; h: number }[] = [] + + const canPlace = (x: number, y: number, w: number, h: number): boolean => { + const halfW = w / 2 + const halfH = h / 2 + const dx = x - 50 + const dy = y - 50 + const dist = Math.sqrt(dx * dx + dy * dy) + const maxR = 49 - Math.max(halfW, halfH) + if (dist > maxR) return false + + const pad = 1.8 + for (const p of placedItems) { + if ((x - halfW - pad) < (p.x + p.w / 2) && + (x + halfW + pad) > (p.x - p.w / 2) && + (y - halfH - pad) < (p.y + p.h / 2) && + (y + halfH + pad) > (p.y - p.h / 2)) { + return false + } + } + return true + } + + const wordItems = topWords.map((item, i) => { + const ratio = item.count / maxCount + const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20) + const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65)) + const delay = (i * 0.04).toFixed(2) + + const charCount = Math.max(1, item.phrase.length) + const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase) + const hasLatin = /[A-Za-z0-9]/.test(item.phrase) + const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6 + const widthPx = fontSize * (charCount * widthFactor) + const heightPx = fontSize * 1.1 + const widthPct = (widthPx / baseSize) * 100 + const heightPct = (heightPx / baseSize) * 100 + + let x = 50, y = 50 + let placedOk = false + const tries = i === 0 ? 1 : 420 + + for (let t = 0; t < tries; t++) { + if (i === 0) { + x = 50 + y = 50 + } else { + const idx = i + t * 0.28 + const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6) + const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35 + x = 50 + radius * Math.cos(angle) + y = 50 + radius * Math.sin(angle) + } + if (canPlace(x, y, widthPct, heightPct)) { + placedOk = true + break + } + } + + if (!placedOk) return null + placedItems.push({ x, y, w: widthPct, h: heightPct }) + + return ( + + {item.phrase} + + ) + }).filter(Boolean) + + return ( +
+
+ {wordItems} +
+
+ ) +} + +function DualReportWindow() { + const [reportData, setReportData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [loadingStage, setLoadingStage] = useState('准备中') + const [loadingProgress, setLoadingProgress] = useState(0) + const [myEmojiUrl, setMyEmojiUrl] = useState(null) + const [friendEmojiUrl, setFriendEmojiUrl] = useState(null) + + useEffect(() => { + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') + const username = params.get('username') + const yearParam = params.get('year') + const parsedYear = yearParam ? parseInt(yearParam, 10) : 0 + const year = Number.isNaN(parsedYear) ? 0 : parsedYear + if (!username) { + setError('缺少好友信息') + setIsLoading(false) + return + } + generateReport(username, year) + }, []) + + const generateReport = async (friendUsername: string, year: number) => { + setIsLoading(true) + setError(null) + setLoadingProgress(0) + + const removeProgressListener = window.electronAPI.dualReport.onProgress?.((payload: { status: string; progress: number }) => { + setLoadingProgress(payload.progress) + setLoadingStage(payload.status) + }) + + try { + const result = await window.electronAPI.dualReport.generateReport({ friendUsername, year }) + removeProgressListener?.() + setLoadingProgress(100) + setLoadingStage('完成') + + if (result.success && result.data) { + setReportData(result.data) + setIsLoading(false) + } else { + setError(result.error || '生成报告失败') + setIsLoading(false) + } + } catch (e) { + removeProgressListener?.() + setError(String(e)) + setIsLoading(false) + } + } + + useEffect(() => { + const loadEmojis = async () => { + if (!reportData) return + const stats = reportData.stats + if (stats.myTopEmojiUrl) { + const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5) + if (res.success && res.localPath) { + setMyEmojiUrl(res.localPath) + } + } + if (stats.friendTopEmojiUrl) { + const res = await window.electronAPI.chat.downloadEmoji(stats.friendTopEmojiUrl, stats.friendTopEmojiMd5) + if (res.success && res.localPath) { + setFriendEmojiUrl(res.localPath) + } + } + } + void loadEmojis() + }, [reportData]) + + if (isLoading) { + return ( +
+
+ + + + + {loadingProgress}% +
+

{loadingStage}

+

进行中

+
+ ) + } + + if (error) { + return ( +
+

生成报告失败: {error}

+
+ ) + } + + if (!reportData) { + return ( +
+

暂无数据

+
+ ) + } + + const yearTitle = reportData.year === 0 ? '全部时间' : `${reportData.year}年` + const firstChat = reportData.firstChat + const firstChatMessages = (reportData.firstChatMessages && reportData.firstChatMessages.length > 0) + ? reportData.firstChatMessages.slice(0, 3) + : firstChat + ? [{ + content: firstChat.content, + isSentByMe: firstChat.isSentByMe, + createTime: firstChat.createTime, + createTimeStr: firstChat.createTimeStr + }] + : [] + const daysSince = firstChat + ? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000)) + : null + const yearFirstChat = reportData.yearFirstChat + const stats = reportData.stats + const statItems = [ + { label: '总消息数', value: stats.totalMessages }, + { label: '总字数', value: stats.totalWords }, + { label: '图片', value: stats.imageCount }, + { label: '语音', value: stats.voiceCount }, + { label: '表情', value: stats.emojiCount }, + ] + + const decodeEntities = (text: string) => ( + text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + ) + + const stripCdata = (text: string) => text.replace(//g, '$1') + + const extractXmlText = (content: string) => { + const titleMatch = content.match(/([\s\S]*?)<\/title>/i) + if (titleMatch?.[1]) return titleMatch[1] + const descMatch = content.match(/<des>([\s\S]*?)<\/des>/i) + if (descMatch?.[1]) return descMatch[1] + const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i) + if (summaryMatch?.[1]) return summaryMatch[1] + const contentMatch = content.match(/<content>([\s\S]*?)<\/content>/i) + if (contentMatch?.[1]) return contentMatch[1] + return '' + } + + const formatMessageContent = (content?: string) => { + const raw = String(content || '').trim() + if (!raw) return '(空)' + const hasXmlTag = /<\s*[a-zA-Z]+[^>]*>/.test(raw) + const looksLikeXml = /<\?xml|<msg\b|<appmsg\b|<sysmsg\b|<appattach\b|<emoji\b|<img\b|<voip\b/i.test(raw) + || hasXmlTag + if (!looksLikeXml) return raw + const extracted = extractXmlText(raw) + if (!extracted) return '(XML消息)' + return decodeEntities(stripCdata(extracted).trim()) || '(XML消息)' + } + const formatFullDate = (timestamp: number) => { + const d = new Date(timestamp) + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const hour = String(d.getHours()).padStart(2, '0') + const minute = String(d.getMinutes()).padStart(2, '0') + return `${year}/${month}/${day} ${hour}:${minute}` + } + + return ( + <div className="annual-report-window dual-report-window"> + <div className="drag-region" /> + + <div className="bg-decoration"> + <div className="deco-circle c1" /> + <div className="deco-circle c2" /> + <div className="deco-circle c3" /> + <div className="deco-circle c4" /> + <div className="deco-circle c5" /> + </div> + + <div className="report-scroll-view"> + <div className="report-container"> + <section className="section"> + <div className="label-text">WEFLOW · DUAL REPORT</div> + <h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1> + <hr className="divider" /> + <div className="dual-names"> + <span>{reportData.selfName}</span> + <span className="amp">&</span> + <span>{reportData.friendName}</span> + </div> + <p className="hero-desc">每一次对话都值得被珍藏</p> + </section> + + <section className="section"> + <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> + {firstChatMessages.length > 0 ? ( + <div className="dual-message-list"> + {firstChatMessages.map((msg, idx) => ( + <div + key={idx} + className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`} + > + <div className="message-meta"> + {msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)} + </div> + <div className="message-content">{formatMessageContent(msg.content)}</div> + </div> + ))} + </div> + ) : null} + </> + ) : ( + <p className="hero-desc">暂无首条消息</p> + )} + </section> + + {yearFirstChat ? ( + <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> + <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} + + <section className="section"> + <div className="label-text">常用语</div> + <h2 className="hero-title">{yearTitle}常用语</h2> + <WordCloud 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> + ) + })} + </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> + )} + </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> + )} + </div> + </div> + </section> + + <section className="section"> + <div className="label-text">尾声</div> + <h2 className="hero-title">谢谢你一直在</h2> + <p className="hero-desc">愿我们继续把故事写下去</p> + </section> + </div> + </div> + </div> + ) +} + +export default DualReportWindow diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index c7e6a36..c37f0f4 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -16,6 +16,10 @@ interface GroupMember { username: string displayName: string avatarUrl?: string + nickname?: string + alias?: string + remark?: string + groupNickname?: string } interface GroupMessageRank { @@ -298,6 +302,10 @@ function GroupAnalyticsPage() { const renderMemberModal = () => { if (!selectedMember) return null + const nickname = (selectedMember.nickname || '').trim() + const alias = (selectedMember.alias || '').trim() + const remark = (selectedMember.remark || '').trim() + const groupNickname = (selectedMember.groupNickname || '').trim() return ( <div className="member-modal-overlay" onClick={() => setSelectedMember(null)}> @@ -320,11 +328,40 @@ function GroupAnalyticsPage() { </div> <div className="detail-row"> <span className="detail-label">昵称</span> - <span className="detail-value">{selectedMember.displayName}</span> - <button className="copy-btn" onClick={() => handleCopy(selectedMember.displayName, 'displayName')}> - {copiedField === 'displayName' ? <Check size={14} /> : <Copy size={14} />} - </button> + <span className="detail-value">{nickname || '未设置'}</span> + {nickname && ( + <button className="copy-btn" onClick={() => handleCopy(nickname, 'nickname')}> + {copiedField === 'nickname' ? <Check size={14} /> : <Copy size={14} />} + </button> + )} </div> + {alias && ( + <div className="detail-row"> + <span className="detail-label">微信号</span> + <span className="detail-value">{alias}</span> + <button className="copy-btn" onClick={() => handleCopy(alias, 'alias')}> + {copiedField === 'alias' ? <Check size={14} /> : <Copy size={14} />} + </button> + </div> + )} + {groupNickname && ( + <div className="detail-row"> + <span className="detail-label">群昵称</span> + <span className="detail-value">{groupNickname}</span> + <button className="copy-btn" onClick={() => handleCopy(groupNickname, 'groupNickname')}> + {copiedField === 'groupNickname' ? <Check size={14} /> : <Copy size={14} />} + </button> + </div> + )} + {remark && ( + <div className="detail-row"> + <span className="detail-label">备注</span> + <span className="detail-value">{remark}</span> + <button className="copy-btn" onClick={() => handleCopy(remark, 'remark')}> + {copiedField === 'remark' ? <Check size={14} /> : <Copy size={14} />} + </button> + </div> + )} </div> </div> </div> diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index eb9188f..645cd44 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -547,10 +547,41 @@ .sns-content-wrapper { flex: 1; display: flex; + flex-direction: column; overflow: hidden; position: relative; } + .sns-notice-banner { + margin: 16px 24px 0 24px; + padding: 10px 16px; + background: rgba(var(--accent-color-rgb), 0.08); + border-radius: 10px; + border: 1px solid rgba(var(--accent-color-rgb), 0.2); + display: flex; + align-items: center; + gap: 10px; + color: var(--accent-color); + font-size: 13px; + font-weight: 500; + animation: banner-slide-down 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); + + svg { + flex-shrink: 0; + } + } + + @keyframes banner-slide-down { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } .sns-content { flex: 1; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index e3efe81..6e512a5 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react' -import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight } from 'lucide-react' +import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight, AlertTriangle } from 'lucide-react' import { Avatar } from '../components/Avatar' import { ImagePreview } from '../components/ImagePreview' import JumpToDateDialog from '../components/JumpToDateDialog' @@ -412,6 +412,10 @@ export default function SnsPage() { </div> <div className="sns-content-wrapper"> + <div className="sns-notice-banner"> + <AlertTriangle size={16} /> + <span>由于技术限制,当前无法解密显示部分图片与视频等加密资源文件</span> + </div> <div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}> <div className="posts-list"> {loadingNewer && ( diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 892f430..67b1097 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -175,6 +175,26 @@ export interface ElectronAPI { } error?: string }> + getExcludedUsernames: () => Promise<{ + success: boolean + data?: string[] + error?: string + }> + setExcludedUsernames: (usernames: string[]) => Promise<{ + success: boolean + data?: string[] + error?: string + }> + getExcludeCandidates: () => Promise<{ + success: boolean + data?: Array<{ + username: string + displayName: string + avatarUrl?: string + wechatId?: string + }> + error?: string + }> onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void } cache: { @@ -199,6 +219,10 @@ export interface ElectronAPI { username: string displayName: string avatarUrl?: string + nickname?: string + alias?: string + remark?: string + groupNickname?: string }> error?: string }> @@ -317,6 +341,57 @@ export interface ElectronAPI { }> onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void } + dualReport: { + generateReport: (payload: { friendUsername: string; year: number }) => Promise<{ + success: boolean + data?: { + year: number + selfName: string + friendUsername: string + friendName: string + firstChat: { + createTime: number + createTimeStr: string + content: string + isSentByMe: boolean + senderUsername?: string + } | null + firstChatMessages?: Array<{ + content: string + isSentByMe: boolean + createTime: number + createTimeStr: string + }> + yearFirstChat?: { + createTime: number + createTimeStr: string + content: string + isSentByMe: boolean + friendName: string + firstThreeMessages: Array<{ + content: string + isSentByMe: boolean + createTime: number + createTimeStr: string + }> + } | null + stats: { + totalMessages: number + totalWords: number + imageCount: number + voiceCount: number + emojiCount: number + myTopEmojiMd5?: string + friendTopEmojiMd5?: string + myTopEmojiUrl?: string + friendTopEmojiUrl?: string + } + topPhrases: Array<{ phrase: string; count: number }> + } + error?: string + }> + onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void + } export: { exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{ success: boolean diff --git a/vite.config.ts b/vite.config.ts index 3e094ee..5fafe10 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -57,6 +57,24 @@ export default defineConfig({ } } }, + { + entry: 'electron/dualReportWorker.ts', + vite: { + build: { + outDir: 'dist-electron', + rollupOptions: { + external: [ + 'koffi', + 'fsevents' + ], + output: { + entryFileNames: 'dualReportWorker.js', + inlineDynamicImports: true + } + } + } + } + }, { entry: 'electron/imageSearchWorker.ts', vite: {