From 2b5bb3439207782fdbeb115c859cc88a8dff702c Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 8 Feb 2026 22:41:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=8C=E4=BA=BA=E5=B9=B4?= =?UTF-8?q?=E5=BA=A6=E6=8A=A5=E5=91=8A=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- electron/main.ts | 4 +- electron/preload.ts | 3 +- electron/services/analyticsService.ts | 17 +++-- electron/services/dualReportService.ts | 20 ++++++ src/pages/DualReportPage.tsx | 33 +++++----- src/pages/DualReportWindow.scss | 44 ++++++++++++- src/pages/DualReportWindow.tsx | 90 +++++++++++++++++++++----- src/types/electron.d.ts | 12 +++- 9 files changed, 183 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 8b7210e..c424a77 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,5 @@ wcdb/ *info 概述.md chatlab-format.md -*.bak \ No newline at end of file +*.bak +AGENTS.md \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index ebfd428..1e0de48 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -985,8 +985,8 @@ function registerIpcHandlers() { return analyticsService.getOverallStatistics(force) }) - ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => { - return analyticsService.getContactRankings(limit) + ipcMain.handle('analytics:getContactRankings', async (_, limit?: number, beginTimestamp?: number, endTimestamp?: number) => { + return analyticsService.getContactRankings(limit, beginTimestamp, endTimestamp) }) ipcMain.handle('analytics:getTimeDistribution', async () => { diff --git a/electron/preload.ts b/electron/preload.ts index b6a8559..29d3829 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -189,7 +189,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 数据分析 analytics: { getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force), - getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit), + getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) => + ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp), getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'), getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'), setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames), diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 8c04476..80a7169 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -31,6 +31,7 @@ export interface ContactRanking { username: string displayName: string avatarUrl?: string + wechatId?: string messageCount: number sentCount: number receivedCount: number @@ -576,7 +577,11 @@ class AnalyticsService { } } - async getContactRankings(limit: number = 20): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> { + async getContactRankings( + limit: number = 20, + beginTimestamp: number = 0, + endTimestamp: number = 0 + ): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> { try { const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } @@ -586,7 +591,7 @@ class AnalyticsService { return { success: false, error: '未找到消息会话' } } - const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0) + const result = await this.getAggregateWithFallback(sessionInfo.usernames, beginTimestamp, endTimestamp) if (!result.success || !result.data) { return { success: false, error: result.error || '聚合统计失败' } } @@ -594,9 +599,10 @@ class AnalyticsService { const d = result.data const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap) const usernames = Object.keys(sessions) - const [displayNames, avatarUrls] = await Promise.all([ + const [displayNames, avatarUrls, aliasMap] = await Promise.all([ wcdbService.getDisplayNames(usernames), - wcdbService.getAvatarUrls(usernames) + wcdbService.getAvatarUrls(usernames), + this.getAliasMap(usernames) ]) const rankings: ContactRanking[] = usernames @@ -608,10 +614,13 @@ class AnalyticsService { const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined + const alias = aliasMap[username] || '' + const wechatId = alias || (!username.startsWith('wxid_') ? username : '') return { username, displayName, avatarUrl, + wechatId, messageCount: stat.total, sentCount: stat.sent, receivedCount: stat.received, diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts index e395412..0ee280b 100644 --- a/electron/services/dualReportService.ts +++ b/electron/services/dualReportService.ts @@ -26,13 +26,17 @@ export interface DualReportStats { friendTopEmojiMd5?: string myTopEmojiUrl?: string friendTopEmojiUrl?: string + myTopEmojiCount?: number + friendTopEmojiCount?: number } export interface DualReportData { year: number selfName: string + selfAvatarUrl?: string friendUsername: string friendName: string + friendAvatarUrl?: string firstChat: DualReportFirstChat | null firstChatMessages?: DualReportMessage[] yearFirstChat?: { @@ -276,6 +280,18 @@ class DualReportService { if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) { myName = await this.getDisplayName(cleanedWxid, rawWxid) } + const avatarCandidates = Array.from(new Set([ + friendUsername, + rawWxid, + cleanedWxid + ].filter(Boolean) as string[])) + let selfAvatarUrl: string | undefined + let friendAvatarUrl: string | undefined + const avatarResult = await wcdbService.getAvatarUrls(avatarCandidates) + if (avatarResult.success && avatarResult.map) { + selfAvatarUrl = avatarResult.map[rawWxid] || avatarResult.map[cleanedWxid] + friendAvatarUrl = avatarResult.map[friendUsername] + } this.reportProgress('获取首条聊天记录...', 15, onProgress) const firstRows = await this.getFirstMessages(friendUsername, 3, 0, 0) @@ -391,6 +407,8 @@ class DualReportService { stats.myTopEmojiUrl = myTopEmojiUrl stats.friendTopEmojiMd5 = friendTopEmojiMd5 stats.friendTopEmojiUrl = friendTopEmojiUrl + if (myTopCount >= 0) stats.myTopEmojiCount = myTopCount + if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount const topPhrases = (cppData.phrases || []).map((p: any) => ({ phrase: p.phrase, @@ -401,8 +419,10 @@ class DualReportService { const reportData: DualReportData = { year: reportYear, selfName: myName, + selfAvatarUrl, friendUsername, friendName, + friendAvatarUrl, firstChat, firstChatMessages, yearFirstChat, diff --git a/src/pages/DualReportPage.tsx b/src/pages/DualReportPage.tsx index 3516589..a8398fc 100644 --- a/src/pages/DualReportPage.tsx +++ b/src/pages/DualReportPage.tsx @@ -7,6 +7,7 @@ interface ContactRanking { username: string displayName: string avatarUrl?: string + wechatId?: string messageCount: number sentCount: number receivedCount: number @@ -15,28 +16,29 @@ interface ContactRanking { function DualReportPage() { const navigate = useNavigate() - const [year, setYear] = useState(0) + const [year] = useState(() => { + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') + const yearParam = params.get('year') + const parsedYear = yearParam ? parseInt(yearParam, 10) : 0 + return Number.isNaN(parsedYear) ? 0 : parsedYear + }) const [rankings, setRankings] = useState([]) const [isLoading, setIsLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [keyword, setKeyword] = useState('') useEffect(() => { - const params = new URLSearchParams(window.location.hash.split('?')[1] || '') - const yearParam = params.get('year') - const parsedYear = yearParam ? parseInt(yearParam, 10) : 0 - setYear(Number.isNaN(parsedYear) ? 0 : parsedYear) - }, []) + void loadRankings(year) + }, [year]) - useEffect(() => { - loadRankings() - }, []) - - const loadRankings = async () => { + const loadRankings = async (reportYear: number) => { setIsLoading(true) setLoadError(null) try { - const result = await window.electronAPI.analytics.getContactRankings(200) + const isAllTime = reportYear <= 0 + const beginTimestamp = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000) + const endTimestamp = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000) + const result = await window.electronAPI.analytics.getContactRankings(200, beginTimestamp, endTimestamp) if (result.success && result.data) { setRankings(result.data) } else { @@ -55,7 +57,8 @@ function DualReportPage() { if (!keyword.trim()) return rankings const q = keyword.trim().toLowerCase() return rankings.filter((item) => { - return item.displayName.toLowerCase().includes(q) || item.username.toLowerCase().includes(q) + const wechatId = (item.wechatId || '').toLowerCase() + return item.displayName.toLowerCase().includes(q) || wechatId.includes(q) }) }, [rankings, keyword]) @@ -99,7 +102,7 @@ function DualReportPage() { setKeyword(e.target.value)} - placeholder="搜索好友(昵称/备注/wxid)" + placeholder="搜索好友(昵称/微信号)" /> @@ -119,7 +122,7 @@ function DualReportPage() {
{item.displayName}
-
{item.username}
+
{item.wechatId || '\u672A\u8bbe\u7f6e\u5fae\u4fe1\u53f7'}
{item.messageCount.toLocaleString()} 条
diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 574068e..d408458 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -296,6 +296,13 @@ font-size: 14px; border: 2px solid var(--ar-card-bg); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + + img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + } } .count { @@ -303,6 +310,13 @@ font-weight: 600; color: var(--ar-text-sub); } + + .percent { + font-size: 12px; + color: var(--ar-text-main); + opacity: 0.85; + font-weight: 600; + } } .initiative-progress { @@ -347,7 +361,7 @@ // --- New Response Speed Section (Grid + Icons) --- .response-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 24px; padding: 0 10px; @@ -391,6 +405,11 @@ color: var(--ar-accent); } + &.sample .icon-box { + background: rgba(16, 174, 255, 0.08); + color: #10AEFF; + } + .label { font-size: 13px; color: var(--ar-text-sub); @@ -412,6 +431,14 @@ } } + .response-note { + margin-top: 14px; + max-width: none; + text-align: center; + font-size: 14px; + color: var(--ar-text-sub); + } + // --- New Streak Section (Flame) --- .streak-container { @@ -473,4 +500,17 @@ border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.05)); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03); } -} \ No newline at end of file + + .emoji-count { + font-size: 12px; + color: var(--ar-text-sub); + opacity: 0.85; + } + + @media (max-width: 960px) { + .response-grid { + grid-template-columns: 1fr; + padding: 0; + } + } +} diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 087be97..681899f 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState, type CSSProperties } from 'react' -import { Clock, Zap, MessageSquare, Type, Image as ImageIcon, Mic, Smile } from 'lucide-react' +import { useEffect, useState } from 'react' +import { Clock, Zap, MessageCircle, MessageSquare, Type, Image as ImageIcon, Mic, Smile } from 'lucide-react' import ReportHeatmap from '../components/ReportHeatmap' import ReportWordCloud from '../components/ReportWordCloud' import './AnnualReportWindow.scss' @@ -15,8 +15,10 @@ interface DualReportMessage { interface DualReportData { year: number selfName: string + selfAvatarUrl?: string friendUsername: string friendName: string + friendAvatarUrl?: string firstChat: { createTime: number createTimeStr: string @@ -43,6 +45,8 @@ interface DualReportData { friendTopEmojiMd5?: string myTopEmojiUrl?: string friendTopEmojiUrl?: string + myTopEmojiCount?: number + friendTopEmojiCount?: number } topPhrases: Array<{ phrase: string; count: number }> heatmap?: number[][] @@ -108,6 +112,8 @@ function DualReportWindow() { useEffect(() => { const loadEmojis = async () => { if (!reportData) return + setMyEmojiUrl(null) + setFriendEmojiUrl(null) const stats = reportData.stats if (stats.myTopEmojiUrl) { const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5) @@ -178,6 +184,9 @@ function DualReportWindow() { : null const yearFirstChat = reportData.yearFirstChat const stats = reportData.stats + const initiativeTotal = (reportData.initiative?.initiated || 0) + (reportData.initiative?.received || 0) + const initiatedPercent = initiativeTotal > 0 ? (reportData.initiative!.initiated / initiativeTotal) * 100 : 0 + const receivedPercent = initiativeTotal > 0 ? (reportData.initiative!.received / initiativeTotal) * 100 : 0 const statItems = [ { label: '总消息数', value: stats.totalMessages, icon: MessageSquare, color: '#07C160' }, { label: '总字数', value: stats.totalWords, icon: Type, color: '#10AEFF' }, @@ -247,6 +256,30 @@ function DualReportWindow() { return `${year}/${month}/${day} ${hour}:${minute}` } + const getMostActiveTime = (data: number[][]) => { + let maxHour = 0 + let maxWeekday = 0 + let maxVal = -1 + data.forEach((row, weekday) => { + row.forEach((value, hour) => { + if (value > maxVal) { + maxVal = value + maxHour = hour + maxWeekday = weekday + } + }) + }) + const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] + return { + weekday: weekdayNames[maxWeekday] || '周一', + hour: maxHour, + value: Math.max(0, maxVal) + } + } + + const mostActive = reportData.heatmap ? getMostActiveTime(reportData.heatmap) : null + const responseAvgMinutes = reportData.response ? Math.max(0, Math.round(reportData.response.avg / 60)) : 0 + return (
@@ -344,6 +377,11 @@ function DualReportWindow() {
聊天习惯

作息规律

+ {mostActive && ( +

+ {'\u5728'} {mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00 {'\u6700\u6d3b\u8dc3\uff08'}{mostActive.value}{'\u6761\uff09'} +

+ )}
)} @@ -358,22 +396,28 @@ function DualReportWindow() {
-
-
{reportData.initiative.initiated}次
+
+ {reportData.selfAvatarUrl ? me-avatar : '\u6211'} +
+
{reportData.initiative.initiated}{'\u6b21'}
+
{initiatedPercent.toFixed(1)}%
-
{reportData.friendName.substring(0, 1)}
-
{reportData.initiative.received}次
+
+ {reportData.friendAvatarUrl ? friend-avatar : reportData.friendName.substring(0, 1)} +
+
{reportData.initiative.received}{'\u6b21'}
+
{receivedPercent.toFixed(1)}%
@@ -383,33 +427,43 @@ function DualReportWindow() { {reportData.response && (
回复速度
-

秒回是并在乎

+

{'\u79d2\u56de\uff0c\u662f\u56e0\u4e3a\u5728\u4e4e'}

-
平均回复
-
{Math.round(reportData.response.avg / 60)}
+
{'\u5e73\u5747\u56de\u590d'}
+
{Math.round(reportData.response.avg / 60)}{'\u5206'}
-
最快回复
-
{reportData.response.fastest}
+
{'\u6700\u5feb\u56de\u590d'}
+
{reportData.response.fastest}{'\u79d2'}
+
+
+
+ +
+
{'\u7edf\u8ba1\u6837\u672c'}
+
{reportData.response.count}{'\u6b21'}
+

+ {`\u5171\u7edf\u8ba1 ${reportData.response.count} \u6b21\u6709\u6548\u56de\u590d\uff0c\u5e73\u5747\u7ea6 ${responseAvgMinutes} \u5206\u949f\uff0c\u6700\u5feb ${reportData.response.fastest} \u79d2\u3002`} +

)} {reportData.streak && (
-
聊天火花
-

最长连续聊天

+
{'\u804a\u5929\u706b\u82b1'}
+

{'\u6700\u957f\u8fde\u7eed\u804a\u5929'}

-
🔥
-
{reportData.streak.days}
+
{'\uD83D\uDD25'}
+
{reportData.streak.days}{'\u5929'}
{reportData.streak.startDate} ~ {reportData.streak.endDate}
@@ -461,6 +515,7 @@ function DualReportWindow() { ) : (
{stats.myTopEmojiMd5 || '暂无'}
)} +
{stats.myTopEmojiCount ? `${stats.myTopEmojiCount}\u6b21` : '\u6682\u65e0\u7edf\u8ba1'}
{reportData.friendName}常用的表情
@@ -469,6 +524,7 @@ function DualReportWindow() { ) : (
{stats.friendTopEmojiMd5 || '暂无'}
)} +
{stats.friendTopEmojiCount ? `${stats.friendTopEmojiCount}\u6b21` : '\u6682\u65e0\u7edf\u8ba1'}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e66004c..ad0daf3 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -163,12 +163,13 @@ export interface ElectronAPI { } error?: string }> - getContactRankings: (limit?: number) => Promise<{ + getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) => Promise<{ success: boolean data?: Array<{ username: string displayName: string avatarUrl?: string + wechatId?: string messageCount: number sentCount: number receivedCount: number @@ -357,8 +358,10 @@ export interface ElectronAPI { data?: { year: number selfName: string + selfAvatarUrl?: string friendUsername: string friendName: string + friendAvatarUrl?: string firstChat: { createTime: number createTimeStr: string @@ -395,8 +398,15 @@ export interface ElectronAPI { friendTopEmojiMd5?: string myTopEmojiUrl?: string friendTopEmojiUrl?: string + myTopEmojiCount?: number + friendTopEmojiCount?: number } topPhrases: Array<{ phrase: string; count: number }> + heatmap?: number[][] + initiative?: { initiated: number; received: number } + response?: { avg: number; fastest: number; count: number } + monthly?: Record + streak?: { days: number; startDate: string; endDate: string } } error?: string }>