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..607872b 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -397,8 +397,10 @@ 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) let totalMessages = 0 const contactStats = new Map() @@ -902,7 +904,7 @@ class AnnualReportService { .map(([phrase, count]) => ({ phrase, count })) const reportData: AnnualReportData = { - year, + year: reportYear, totalMessages, totalFriends: contactStats.size, coreFriends, 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..3609f00 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -260,7 +260,7 @@ class ExportService { } // 清理昵称:去除前后空白和特殊字符 - nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '') + nickname = this.normalizeGroupNickname(nickname) // 只保存有效的群昵称(长度 > 0 且 < 50) if (nickname && nickname.length > 0 && nickname.length < 50) { @@ -432,6 +432,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 + } + /** * 根据用户偏好获取显示名称 */ @@ -2034,7 +2043,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 +2089,7 @@ class ExportService { ? sessionContact.contact.remark : '' const sessionGroupNickname = isGroup - ? (groupNicknamesMap.get(sessionId.toLowerCase()) || '') + ? this.normalizeGroupNickname(groupNicknamesMap.get(sessionId.toLowerCase()) || '') : '' // 使用用户偏好的显示名称 @@ -2447,7 +2456,7 @@ class ExportService { // 获取群昵称 (仅群聊且完整列模式) if (isGroup && !useCompactColumns && senderWxid) { - senderGroupNickname = groupNicknamesMap.get(senderWxid.toLowerCase()) || '' + senderGroupNickname = this.normalizeGroupNickname(groupNicknamesMap.get(senderWxid.toLowerCase()) || '') } 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/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.tsx b/src/pages/AnnualReportWindow.tsx index bf105f3..8def63d 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -282,7 +282,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 +338,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 [] @@ -595,7 +601,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 +665,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 })) }) @@ -737,6 +745,10 @@ function AnnualReportWindow() { 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 +839,7 @@ function AnnualReportWindow() { {/* 封面 */}
WEFLOW · ANNUAL REPORT
-

{year}年
微信聊天报告

+

{yearTitle}
微信聊天报告


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

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

{year}年月度好友

+

{monthlyTitle}

根据12个月的聊天习惯

{monthlyTopFriends.map((m, i) => ( @@ -1016,7 +1028,7 @@ function AnnualReportWindow() { {topPhrases && topPhrases.length > 0 && (
年度常用语
-

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

+

{phrasesTitle}

这一年,你说得最多的是:
@@ -1085,7 +1097,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/types/electron.d.ts b/src/types/electron.d.ts index 892f430..68e2cf5 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: { @@ -317,6 +337,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: {