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 a9f9419..81e0c5d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1029,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 7682a54..2c259eb 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -202,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/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/dualReportService.ts b/electron/services/dualReportService.ts new file mode 100644 index 0000000..6764bff --- /dev/null +++ b/electron/services/dualReportService.ts @@ -0,0 +1,445 @@ +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 DualReportYearlyStats { + totalMessages: number + totalWords: number + imageCount: number + voiceCount: number + emojiCount: number + myTopEmojiMd5?: string + friendTopEmojiMd5?: string + myTopEmojiUrl?: string + friendTopEmojiUrl?: string +} + +export interface DualReportWordCloud { + words: Array<{ phrase: string; count: number }> + totalWords: number + totalMessages: number +} + +export interface DualReportData { + year: number + myName: string + friendUsername: string + friendName: string + firstChat: DualReportFirstChat | null + thisYearFirstChat?: { + createTime: number + createTimeStr: string + content: string + isSentByMe: boolean + friendName: string + firstThreeMessages: DualReportMessage[] + } | null + yearlyStats: DualReportYearlyStats + wordCloud: DualReportWordCloud +} + +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 cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, beginTimestamp, endTimestamp) + if (!cursorResult.success || !cursorResult.cursor) return [] + try { + const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) + if (!batch.success || !batch.rows) return [] + return batch.rows.slice(0, limit) + } 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, 1, 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 + } + } + + let thisYearFirstChat: DualReportData['thisYearFirstChat'] = 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) + } + }) + thisYearFirstChat = { + 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 yearlyStats: DualReportYearlyStats = { + 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) + yearlyStats.totalMessages += 1 + + if (localType === 3) yearlyStats.imageCount += 1 + if (localType === 34) yearlyStats.voiceCount += 1 + if (localType === 47) { + yearlyStats.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) { + yearlyStats.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) + + yearlyStats.myTopEmojiMd5 = myTopEmojiMd5 + yearlyStats.friendTopEmojiMd5 = friendTopEmojiMd5 + yearlyStats.myTopEmojiUrl = myTopEmojiMd5 ? myEmojiUrlMap.get(myTopEmojiMd5) : undefined + yearlyStats.friendTopEmojiUrl = friendTopEmojiMd5 ? friendEmojiUrlMap.get(friendTopEmojiMd5) : undefined + + this.reportProgress('生成常用语词云...', 85, onProgress) + const wordCloudWords = Array.from(wordCountMap.entries()) + .filter(([_, count]) => count >= 2) + .sort((a, b) => b[1] - a[1]) + .slice(0, 50) + .map(([phrase, count]) => ({ phrase, count })) + + const wordCloud: DualReportWordCloud = { + words: wordCloudWords, + totalWords: yearlyStats.totalWords, + totalMessages: yearlyStats.totalMessages + } + + const reportData: DualReportData = { + year: reportYear, + myName, + friendUsername, + friendName, + firstChat, + thisYearFirstChat, + yearlyStats, + wordCloud + } + + 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/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/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx index 304c9b1..7bd8b10 100644 --- a/src/pages/AnnualReportPage.tsx +++ b/src/pages/AnnualReportPage.tsx @@ -39,10 +39,11 @@ function AnnualReportPage() { } const handleGenerateReport = async () => { - if (!selectedYear || selectedYear === 'all') 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 { @@ -50,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 (
@@ -111,7 +118,7 @@ function AnnualReportPage() { - {selectedYear === 'all' ? ( -

全部时间报告功能准备中

- ) : null}
@@ -155,11 +159,15 @@ function AnnualReportPage() { ))}
- -

双人年度报告入口已留出,功能在开发中

+

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

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..2b0d19a --- /dev/null +++ b/src/pages/DualReportWindow.scss @@ -0,0 +1,220 @@ +.dual-report-window { + color: var(--text-primary); + padding: 32px 24px 60px; + background: var(--bg-primary); +} + +.dual-report-window.loading, +.dual-report-window.error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: 12px; + color: var(--text-tertiary); +} + +.dual-section { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 24px; + margin: 16px auto; + max-width: 900px; +} + +.dual-section.cover { + text-align: center; + background: linear-gradient(135deg, color-mix(in srgb, var(--primary) 10%, transparent) 0%, var(--card-bg) 100%); + + .label { + font-size: 12px; + letter-spacing: 2px; + color: var(--text-tertiary); + margin-bottom: 12px; + } + + h1 { + margin: 0 0 12px; + font-size: 36px; + } + + p { + margin: 0; + color: var(--text-secondary); + } +} + +.section-title { + font-size: 18px; + font-weight: 700; + margin-bottom: 16px; +} + +.info-card { + display: flex; + flex-direction: column; + gap: 12px; +} + +.info-row { + display: flex; + justify-content: space-between; + gap: 16px; + font-size: 14px; +} + +.info-label { + color: var(--text-tertiary); +} + +.info-value { + color: var(--text-primary); + font-weight: 600; +} + +.info-empty { + color: var(--text-tertiary); +} + +.message-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 8px; +} + +.message-item { + padding: 10px 12px; + border-radius: 12px; + background: color-mix(in srgb, var(--primary) 6%, transparent); + + &.received { + background: color-mix(in srgb, var(--border-color) 35%, transparent); + } +} + +.message-meta { + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 6px; +} + +.message-content { + font-size: 14px; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.stat-card { + background: color-mix(in srgb, var(--primary) 6%, transparent); + border-radius: 12px; + padding: 14px; + text-align: center; + + .stat-value { + font-size: 20px; + font-weight: 700; + } + + .stat-label { + font-size: 12px; + color: var(--text-tertiary); + margin-top: 4px; + } +} + +.emoji-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} + +.emoji-card { + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + justify-content: center; + + img { + width: 64px; + height: 64px; + object-fit: contain; + } +} + +.emoji-title { + font-size: 12px; + color: var(--text-tertiary); +} + +.emoji-placeholder { + font-size: 12px; + color: var(--text-secondary); + word-break: break-all; + text-align: center; +} + +.word-cloud-wrapper { + position: relative; + width: 100%; + padding-top: 80%; + background: color-mix(in srgb, var(--primary) 4%, transparent); + border-radius: 18px; + overflow: hidden; +} + +.word-cloud-inner { + position: absolute; + inset: 0; +} + +.word-tag { + position: absolute; + font-weight: 600; + color: var(--text-primary); + transform: translate(-50%, -50%); + opacity: 0; + animation: fadeUp 0.8s ease forwards; +} + +.word-cloud-empty { + color: var(--text-tertiary); + font-size: 14px; + text-align: center; + padding: 40px 0; +} + +.progress { + font-size: 20px; + font-weight: 700; +} + +.stage { + font-size: 12px; + color: var(--text-tertiary); +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes fadeUp { + from { opacity: 0; transform: translate(-50%, -50%) translateY(10px); } + to { opacity: var(--final-opacity, 1); transform: translate(-50%, -50%) translateY(0); } +} diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx new file mode 100644 index 0000000..2112f88 --- /dev/null +++ b/src/pages/DualReportWindow.tsx @@ -0,0 +1,366 @@ +import { useEffect, useState, type CSSProperties } from 'react' +import { Loader2 } from 'lucide-react' +import './DualReportWindow.scss' + +interface DualReportMessage { + content: string + isSentByMe: boolean + createTime: number + createTimeStr: string +} + +interface DualReportData { + year: number + myName: string + friendUsername: string + friendName: string + firstChat: { + createTime: number + createTimeStr: string + content: string + isSentByMe: boolean + senderUsername?: string + } | null + thisYearFirstChat?: { + createTime: number + createTimeStr: string + content: string + isSentByMe: boolean + friendName: string + firstThreeMessages: DualReportMessage[] + } | null + yearlyStats: { + totalMessages: number + totalWords: number + imageCount: number + voiceCount: number + emojiCount: number + myTopEmojiMd5?: string + friendTopEmojiMd5?: string + myTopEmojiUrl?: string + friendTopEmojiUrl?: string + } + wordCloud: { + words: Array<{ phrase: string; count: number }> + totalWords: number + totalMessages: number + } +} + +const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => { + if (!words || words.length === 0) { + return
暂无高频语句
+ } + const maxCount = words.length > 0 ? words[0].count : 1 + const topWords = words.slice(0, 32) + const baseSize = 520 + + const seededRandom = (seed: number) => { + const x = Math.sin(seed) * 10000 + return x - Math.floor(x) + } + + const placedItems: { x: number; y: number; w: number; h: number }[] = [] + + const canPlace = (x: number, y: number, w: number, h: number): boolean => { + const halfW = w / 2 + const halfH = h / 2 + const dx = x - 50 + const dy = y - 50 + const dist = Math.sqrt(dx * dx + dy * dy) + const maxR = 49 - Math.max(halfW, halfH) + if (dist > maxR) return false + + const pad = 1.8 + for (const p of placedItems) { + if ((x - halfW - pad) < (p.x + p.w / 2) && + (x + halfW + pad) > (p.x - p.w / 2) && + (y - halfH - pad) < (p.y + p.h / 2) && + (y + halfH + pad) > (p.y - p.h / 2)) { + return false + } + } + return true + } + + const wordItems = topWords.map((item, i) => { + const ratio = item.count / maxCount + const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20) + const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65)) + const delay = (i * 0.04).toFixed(2) + + const charCount = Math.max(1, item.phrase.length) + const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase) + const hasLatin = /[A-Za-z0-9]/.test(item.phrase) + const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6 + const widthPx = fontSize * (charCount * widthFactor) + const heightPx = fontSize * 1.1 + const widthPct = (widthPx / baseSize) * 100 + const heightPct = (heightPx / baseSize) * 100 + + let x = 50, y = 50 + let placedOk = false + const tries = i === 0 ? 1 : 420 + + for (let t = 0; t < tries; t++) { + if (i === 0) { + x = 50 + y = 50 + } else { + const idx = i + t * 0.28 + const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6) + const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35 + x = 50 + radius * Math.cos(angle) + y = 50 + radius * Math.sin(angle) + } + if (canPlace(x, y, widthPct, heightPct)) { + placedOk = true + break + } + } + + if (!placedOk) return null + placedItems.push({ x, y, w: widthPct, h: heightPct }) + + return ( + + {item.phrase} + + ) + }).filter(Boolean) + + return ( +
+
+ {wordItems} +
+
+ ) +} + +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.yearlyStats + 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 daysSince = firstChat + ? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000)) + : null + const thisYearFirstChat = reportData.thisYearFirstChat + const stats = reportData.yearlyStats + + return ( +
+
+
DUAL REPORT
+

{reportData.myName} & {reportData.friendName}

+

让我们一起回顾这段独一无二的对话

+
+ +
+
首次聊天
+ {firstChat ? ( +
+
+ 第一次聊天时间 + {firstChat.createTimeStr} +
+
+ 距今天数 + {daysSince} 天 +
+
+ 首条消息 + {firstChat.content || '(空)'} +
+
+ ) : ( +
暂无首条消息
+ )} +
+ + {thisYearFirstChat ? ( +
+
今年首次聊天
+
+
+ 首次时间 + {thisYearFirstChat.createTimeStr} +
+
+ 发起者 + {thisYearFirstChat.isSentByMe ? reportData.myName : reportData.friendName} +
+
+ {thisYearFirstChat.firstThreeMessages.map((msg, idx) => ( +
+
{msg.isSentByMe ? reportData.myName : reportData.friendName} · {msg.createTimeStr}
+
{msg.content || '(空)'}
+
+ ))} +
+
+
+ ) : null} + +
+
{yearTitle}常用语
+ +
+ +
+
{yearTitle}统计
+
+
+
{stats.totalMessages.toLocaleString()}
+
总消息数
+
+
+
{stats.totalWords.toLocaleString()}
+
总字数
+
+
+
{stats.imageCount.toLocaleString()}
+
图片
+
+
+
{stats.voiceCount.toLocaleString()}
+
语音
+
+
+
{stats.emojiCount.toLocaleString()}
+
表情
+
+
+ +
+
+
我常用的表情
+ {myEmojiUrl ? ( + my-emoji + ) : ( +
{stats.myTopEmojiMd5 || '暂无'}
+ )} +
+
+
{reportData.friendName}常用的表情
+ {friendEmojiUrl ? ( + friend-emoji + ) : ( +
{stats.friendTopEmojiMd5 || '暂无'}
+ )} +
+
+
+
+ ) +} + +export default DualReportWindow diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 4e30d2a..bfa4e6d 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -337,6 +337,55 @@ 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 + myName: string + friendUsername: string + friendName: string + firstChat: { + createTime: number + createTimeStr: string + content: string + isSentByMe: boolean + senderUsername?: string + } | null + thisYearFirstChat?: { + createTime: number + createTimeStr: string + content: string + isSentByMe: boolean + friendName: string + firstThreeMessages: Array<{ + content: string + isSentByMe: boolean + createTime: number + createTimeStr: string + }> + } | null + yearlyStats: { + totalMessages: number + totalWords: number + imageCount: number + voiceCount: number + emojiCount: number + myTopEmojiMd5?: string + friendTopEmojiMd5?: string + myTopEmojiUrl?: string + friendTopEmojiUrl?: string + } + wordCloud: { + words: Array<{ phrase: string; count: number }> + totalWords: number + totalMessages: 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: {