From cffeeb26ec48c6c8ea1131afc6e585a58b681b1e Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 31 Jan 2026 23:44:16 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8E=92=E9=99=A4?= =?UTF-8?q?=E5=A5=BD=E5=8F=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 12 ++ electron/preload.ts | 5 +- electron/services/analyticsService.ts | 95 +++++++++++- electron/services/config.ts | 2 + src/pages/AnalyticsPage.scss | 201 +++++++++++++++++++++++++- src/pages/AnalyticsPage.tsx | 201 +++++++++++++++++++++++++- src/types/electron.d.ts | 19 +++ 7 files changed, 522 insertions(+), 13 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index f152dc3..a9f9419 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() diff --git a/electron/preload.ts b/electron/preload.ts index 7d6d5c3..7682a54 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') diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index d52508a..1153cfb 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,26 @@ 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 cleanAccountDirName(name: string): string { const trimmed = name.trim() if (!trimmed) return trimmed @@ -97,13 +118,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 +147,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 +204,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 +400,62 @@ 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 }>; 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] = await Promise.all([ + wcdbService.getDisplayNames(usernameList), + wcdbService.getAvatarUrls(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 + return { username, displayName, avatarUrl } + }) + + 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/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/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..41cebba 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -1,20 +1,50 @@ 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 +} + +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 +95,88 @@ 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 haystack = `${candidate.displayName} ${candidate.username}`.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 +351,16 @@ function AnalyticsPage() { <>

私聊分析

- +
+ + +
@@ -316,6 +426,83 @@ 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)) + return ( + + ) + })} + {visibleExcludeCandidates.length === 0 && ( +
+ {excludeQuery.trim() ? '未找到匹配好友' : '暂无可选好友'} +
+ )} +
+ )} +
+
+ 已排除 {draftExcluded.size} 人 +
+ + +
+
+
+
+ )} ) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 892f430..1f5f8eb 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -175,6 +175,25 @@ 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 + }> + error?: string + }> onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void } cache: { From 65365107f5bf64771e02d20989fc5343d7802cd4 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 1 Feb 2026 00:07:38 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BE=A4=E6=98=B5?= =?UTF-8?q?=E7=A7=B0=E8=AF=BB=E5=8F=96=E9=94=99=E8=AF=AF=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + electron/services/analyticsService.ts | 43 ++++++++++++++++++++++++--- electron/services/exportService.ts | 17 ++++++++--- package-lock.json | 4 +-- package.json | 2 +- src/pages/AnalyticsPage.tsx | 7 +++-- src/types/electron.d.ts | 1 + 7 files changed, 62 insertions(+), 13 deletions(-) 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/services/analyticsService.ts b/electron/services/analyticsService.ts index 1153cfb..e9d965e 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -67,6 +67,38 @@ class AnalyticsService { 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 @@ -419,7 +451,7 @@ class AnalyticsService { } } - async getExcludeCandidates(): Promise<{ success: boolean; data?: Array<{ username: string; displayName: string; avatarUrl?: string }>; error?: string }> { + 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 } @@ -435,9 +467,10 @@ class AnalyticsService { } const usernameList = Array.from(usernames) - const [displayNames, avatarUrls] = await Promise.all([ + const [displayNames, avatarUrls, aliasMap] = await Promise.all([ wcdbService.getDisplayNames(usernameList), - wcdbService.getAvatarUrls(usernameList) + wcdbService.getAvatarUrls(usernameList), + this.getAliasMap(usernameList) ]) const entries = usernameList.map((username) => { @@ -447,7 +480,9 @@ class AnalyticsService { const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined - return { username, displayName, avatarUrl } + const alias = aliasMap[username] + const wechatId = alias || (!username.startsWith('wxid_') ? username : '') + return { username, displayName, avatarUrl, wechatId } }) return { success: true, data: entries } 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/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 41cebba..9e56515 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -11,6 +11,7 @@ interface ExcludeCandidate { username: string displayName: string avatarUrl?: string + wechatId?: string } const normalizeUsername = (value: string) => value.trim().toLowerCase() @@ -167,7 +168,8 @@ function AnalyticsPage() { .filter((candidate) => { const query = excludeQuery.trim().toLowerCase() if (!query) return true - const haystack = `${candidate.displayName} ${candidate.username}`.toLowerCase() + const wechatId = candidate.wechatId || '' + const haystack = `${candidate.displayName} ${candidate.username} ${wechatId}`.toLowerCase() return haystack.includes(query) }) .sort((a, b) => { @@ -464,6 +466,7 @@ function AnalyticsPage() {
{visibleExcludeCandidates.map((candidate) => { const isChecked = draftExcluded.has(normalizeUsername(candidate.username)) + const wechatId = candidate.wechatId?.trim() || candidate.username return ( ) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 1f5f8eb..4e30d2a 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -191,6 +191,7 @@ export interface ElectronAPI { username: string displayName: string avatarUrl?: string + wechatId?: string }> error?: string }> From 53f0e299e0c8258063bf9c2e3bc9929ecdd49b45 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 1 Feb 2026 00:30:54 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E5=B9=B4=E5=BA=A6=E6=8A=A5=E5=91=8Aui?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/AnnualReportPage.scss | 71 +++++++++++++++++++ src/pages/AnnualReportPage.tsx | 121 ++++++++++++++++++++++++-------- 2 files changed, 161 insertions(+), 31 deletions(-) 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..304c9b1 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) @@ -23,6 +26,7 @@ function AnnualReportPage() { if (result.success && result.data && result.data.length > 0) { setAvailableYears(result.data) setSelectedYear(result.data[0]) + setSelectedPairYear(result.data[0]) } else if (!result.success) { setLoadError(result.error || '加载年度数据失败') } @@ -35,7 +39,7 @@ function AnnualReportPage() { } const handleGenerateReport = async () => { - if (!selectedYear) return + if (!selectedYear || selectedYear === 'all') return setIsGenerating(true) try { navigate(`/annual-report/view?year=${selectedYear}`) @@ -67,42 +71,97 @@ 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' ? '时间' : '年'} +
+ ))} +
+ + + {selectedYear === 'all' ? ( +

全部时间报告功能准备中

+ ) : null} + + +
+
+
+

双人年度报告

+

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

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

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

+
+
) } From 5413d7e2c876ed7b2363084b5d86a0227eda8afd Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 1 Feb 2026 01:13:17 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E5=8F=8C=E4=BA=BA=E5=B9=B4=E5=BA=A6?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=90=8E=E7=AB=AF=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/dualReportWorker.ts | 45 +++ electron/main.ts | 67 ++++ electron/preload.ts | 8 + electron/services/annualReportService.ts | 8 +- electron/services/dualReportService.ts | 445 +++++++++++++++++++++++ src/App.tsx | 4 + src/pages/AnnualReportPage.tsx | 24 +- src/pages/AnnualReportWindow.tsx | 28 +- src/pages/DualReportPage.scss | 171 +++++++++ src/pages/DualReportPage.tsx | 138 +++++++ src/pages/DualReportWindow.scss | 220 +++++++++++ src/pages/DualReportWindow.tsx | 366 +++++++++++++++++++ src/types/electron.d.ts | 49 +++ vite.config.ts | 18 + 14 files changed, 1572 insertions(+), 19 deletions(-) create mode 100644 electron/dualReportWorker.ts create mode 100644 electron/services/dualReportService.ts create mode 100644 src/pages/DualReportPage.scss create mode 100644 src/pages/DualReportPage.tsx create mode 100644 src/pages/DualReportWindow.scss create mode 100644 src/pages/DualReportWindow.tsx 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: { From f40f885af3d2f11330531238e4517381200aeb85 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 1 Feb 2026 01:26:43 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E5=90=8C=E6=AD=A5ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/dualReportService.ts | 56 ++-- src/pages/DualReportWindow.scss | 338 +++++++++---------------- src/pages/DualReportWindow.tsx | 267 ++++++++++--------- src/types/electron.d.ts | 12 +- 4 files changed, 302 insertions(+), 371 deletions(-) diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts index 6764bff..1b2f8f9 100644 --- a/electron/services/dualReportService.ts +++ b/electron/services/dualReportService.ts @@ -16,7 +16,7 @@ export interface DualReportFirstChat { senderUsername?: string } -export interface DualReportYearlyStats { +export interface DualReportStats { totalMessages: number totalWords: number imageCount: number @@ -28,19 +28,13 @@ export interface DualReportYearlyStats { friendTopEmojiUrl?: string } -export interface DualReportWordCloud { - words: Array<{ phrase: string; count: number }> - totalWords: number - totalMessages: number -} - export interface DualReportData { year: number - myName: string + selfName: string friendUsername: string friendName: string firstChat: DualReportFirstChat | null - thisYearFirstChat?: { + yearFirstChat?: { createTime: number createTimeStr: string content: string @@ -48,8 +42,8 @@ export interface DualReportData { friendName: string firstThreeMessages: DualReportMessage[] } | null - yearlyStats: DualReportYearlyStats - wordCloud: DualReportWordCloud + stats: DualReportStats + topPhrases: Array<{ phrase: string; count: number }> } class DualReportService { @@ -272,7 +266,7 @@ class DualReportService { } } - let thisYearFirstChat: DualReportData['thisYearFirstChat'] = null + let yearFirstChat: DualReportData['yearFirstChat'] = null if (!isAllTime) { this.reportProgress('获取今年首次聊天...', 20, onProgress) const firstYearRows = await this.getFirstMessages(friendUsername, 3, startTime, endTime) @@ -289,7 +283,7 @@ class DualReportService { createTimeStr: this.formatDateTime(msgTime) } }) - thisYearFirstChat = { + yearFirstChat = { createTime, createTimeStr: this.formatDateTime(createTime), content: String(this.decodeMessageContent(firstRow.message_content, firstRow.compress_content) || ''), @@ -301,7 +295,7 @@ class DualReportService { } this.reportProgress('统计聊天数据...', 30, onProgress) - const yearlyStats: DualReportYearlyStats = { + const stats: DualReportStats = { totalMessages: 0, totalWords: 0, imageCount: 0, @@ -334,12 +328,12 @@ class DualReportService { 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 + stats.totalMessages += 1 - if (localType === 3) yearlyStats.imageCount += 1 - if (localType === 34) yearlyStats.voiceCount += 1 + if (localType === 3) stats.imageCount += 1 + if (localType === 34) stats.voiceCount += 1 if (localType === 47) { - yearlyStats.emojiCount += 1 + stats.emojiCount += 1 const content = this.decodeMessageContent(row.message_content, row.compress_content) const md5 = this.extractEmojiMd5(content) const url = this.extractEmojiUrl(content) @@ -357,7 +351,7 @@ class DualReportService { 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 + stats.totalWords += text.replace(/\s+/g, '').length const normalized = text.replace(/\s+/g, ' ').trim() if (normalized.length >= 2 && normalized.length <= 50 && @@ -405,33 +399,27 @@ class DualReportService { 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 + 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 wordCloudWords = Array.from(wordCountMap.entries()) + 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 wordCloud: DualReportWordCloud = { - words: wordCloudWords, - totalWords: yearlyStats.totalWords, - totalMessages: yearlyStats.totalMessages - } - const reportData: DualReportData = { year: reportYear, - myName, + selfName: myName, friendUsername, friendName, firstChat, - thisYearFirstChat, - yearlyStats, - wordCloud + yearFirstChat, + stats, + topPhrases } this.reportProgress('双人报告生成完成', 100, onProgress) diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 2b0d19a..14d6e6c 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -1,220 +1,130 @@ -.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; +.annual-report-window.dual-report-window { + .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); + } } - .stat-label { + .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); + } + } + + .dual-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; + margin: 20px 0 24px; + } + + .dual-stat-card { + background: var(--ar-card-bg); + border-radius: 16px; + padding: 18px; + text-align: center; + } + + .emoji-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + } + + .emoji-card { + border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08)); + border-radius: 16px; + padding: 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(--text-tertiary); - margin-top: 4px; + 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; } } - -.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 index 2112f88..883acc3 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type CSSProperties } from 'react' -import { Loader2 } from 'lucide-react' +import './AnnualReportWindow.scss' import './DualReportWindow.scss' interface DualReportMessage { @@ -11,7 +11,7 @@ interface DualReportMessage { interface DualReportData { year: number - myName: string + selfName: string friendUsername: string friendName: string firstChat: { @@ -21,7 +21,7 @@ interface DualReportData { isSentByMe: boolean senderUsername?: string } | null - thisYearFirstChat?: { + yearFirstChat?: { createTime: number createTimeStr: string content: string @@ -29,7 +29,7 @@ interface DualReportData { friendName: string firstThreeMessages: DualReportMessage[] } | null - yearlyStats: { + stats: { totalMessages: number totalWords: number imageCount: number @@ -40,19 +40,16 @@ interface DualReportData { myTopEmojiUrl?: string friendTopEmojiUrl?: string } - wordCloud: { - words: Array<{ phrase: string; count: number }> - totalWords: number - totalMessages: number - } + topPhrases: Array<{ phrase: string; count: 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 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) => { @@ -205,7 +202,7 @@ function DualReportWindow() { useEffect(() => { const loadEmojis = async () => { if (!reportData) return - const stats = reportData.yearlyStats + const stats = reportData.stats if (stats.myTopEmojiUrl) { const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5) if (res.success && res.localPath) { @@ -224,25 +221,35 @@ function DualReportWindow() { if (isLoading) { return ( -
- -
{loadingProgress}%
-
{loadingStage}
+
+
+ + + + + {loadingProgress}% +
+

{loadingStage}

+

进行中

) } if (error) { return ( -
-

生成报告失败:{error}

+
+

生成报告失败: {error}

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

暂无数据

) @@ -253,112 +260,142 @@ function DualReportWindow() { const daysSince = firstChat ? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000)) : null - const thisYearFirstChat = reportData.thisYearFirstChat - const stats = reportData.yearlyStats + const yearFirstChat = reportData.yearFirstChat + const stats = reportData.stats return ( -
-
-
DUAL REPORT
-

{reportData.myName} & {reportData.friendName}

-

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

-
+
+
-
-
首次聊天
- {firstChat ? ( -
-
- 第一次聊天时间 - {firstChat.createTimeStr} -
-
- 距今天数 - {daysSince} 天 -
-
- 首条消息 - {firstChat.content || '(空)'} -
-
- ) : ( -
暂无首条消息
- )} -
+
+
+
+
+
+
+
- {thisYearFirstChat ? ( -
-
今年首次聊天
-
-
- 首次时间 - {thisYearFirstChat.createTimeStr} +
+
+
+
WEFLOW · DUAL REPORT
+

{yearTitle}
双人聊天报告

+
+
+ {reportData.selfName} + & + {reportData.friendName}
-
- 发起者 - {thisYearFirstChat.isSentByMe ? reportData.myName : reportData.friendName} -
-
- {thisYearFirstChat.firstThreeMessages.map((msg, idx) => ( -
-
{msg.isSentByMe ? reportData.myName : reportData.friendName} · {msg.createTimeStr}
-
{msg.content || '(空)'}
+

每一次对话都值得被珍藏

+
+ +
+
首次聊天
+

故事的开始

+ {firstChat ? ( +
+
+
第一次聊天时间
+
{firstChat.createTimeStr}
- ))} +
+
距今天数
+
{daysSince} 天
+
+
+
首条消息
+
{firstChat.content || '(空)'}
+
+
+ ) : ( +

暂无首条消息

+ )} +
+ + {yearFirstChat ? ( +
+
今年首次聊天
+

新一年的开场

+
+
+
首次时间
+
{yearFirstChat.createTimeStr}
+
+
+
发起者
+
{yearFirstChat.isSentByMe ? reportData.selfName : reportData.friendName}
+
+
+
+ {yearFirstChat.firstThreeMessages.map((msg, idx) => ( +
+
{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {msg.createTimeStr}
+
{msg.content || '(空)'}
+
+ ))} +
+
+ ) : null} + +
+
常用语
+

{yearTitle}常用语

+ +
+ +
+
年度统计
+

{yearTitle}数据概览

+
+
+
{stats.totalMessages.toLocaleString()}
+
总消息数
+
+
+
{stats.totalWords.toLocaleString()}
+
总字数
+
+
+
{stats.imageCount.toLocaleString()}
+
图片
+
+
+
{stats.voiceCount.toLocaleString()}
+
语音
+
+
+
{stats.emojiCount.toLocaleString()}
+
表情
+
-
-
- ) : null} -
-
{yearTitle}常用语
- -
+
+
+
我常用的表情
+ {myEmojiUrl ? ( + my-emoji + ) : ( +
{stats.myTopEmojiMd5 || '暂无'}
+ )} +
+
+
{reportData.friendName}常用的表情
+ {friendEmojiUrl ? ( + friend-emoji + ) : ( +
{stats.friendTopEmojiMd5 || '暂无'}
+ )} +
+
+
-
-
{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 || '暂无'}
- )} -
-
-
+
) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index bfa4e6d..b67b8f8 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -342,7 +342,7 @@ export interface ElectronAPI { success: boolean data?: { year: number - myName: string + selfName: string friendUsername: string friendName: string firstChat: { @@ -352,7 +352,7 @@ export interface ElectronAPI { isSentByMe: boolean senderUsername?: string } | null - thisYearFirstChat?: { + yearFirstChat?: { createTime: number createTimeStr: string content: string @@ -365,7 +365,7 @@ export interface ElectronAPI { createTimeStr: string }> } | null - yearlyStats: { + stats: { totalMessages: number totalWords: number imageCount: number @@ -376,11 +376,7 @@ export interface ElectronAPI { myTopEmojiUrl?: string friendTopEmojiUrl?: string } - wordCloud: { - words: Array<{ phrase: string; count: number }> - totalWords: number - totalMessages: number - } + topPhrases: Array<{ phrase: string; count: number }> } error?: string }> From ddbb0c3b2621ae277c31d77cb2cd1b50c520e834 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 1 Feb 2026 02:26:00 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E4=BC=98=E5=8C=96ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/dualReportService.ts | 33 +++++- src/pages/AnnualReportPage.tsx | 4 +- src/pages/DualReportWindow.scss | 139 +++++++++++++++++++++-- src/pages/DualReportWindow.tsx | 149 ++++++++++++++++++------- src/types/electron.d.ts | 6 + 5 files changed, 276 insertions(+), 55 deletions(-) diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts index 1b2f8f9..3d9a857 100644 --- a/electron/services/dualReportService.ts +++ b/electron/services/dualReportService.ts @@ -34,6 +34,7 @@ export interface DualReportData { friendUsername: string friendName: string firstChat: DualReportFirstChat | null + firstChatMessages?: DualReportMessage[] yearFirstChat?: { createTime: number createTimeStr: string @@ -210,12 +211,23 @@ class DualReportService { beginTimestamp: number, endTimestamp: number ): Promise { - const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, beginTimestamp, endTimestamp) + 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 batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) - if (!batch.success || !batch.rows) return [] - return batch.rows.slice(0, limit) + 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) } @@ -251,7 +263,7 @@ class DualReportService { } this.reportProgress('获取首条聊天记录...', 15, onProgress) - const firstRows = await this.getFirstMessages(friendUsername, 1, 0, 0) + const firstRows = await this.getFirstMessages(friendUsername, 3, 0, 0) let firstChat: DualReportFirstChat | null = null if (firstRows.length > 0) { const row = firstRows[0] @@ -265,6 +277,16 @@ class DualReportService { 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) { @@ -417,6 +439,7 @@ class DualReportService { friendUsername, friendName, firstChat, + firstChatMessages, yearFirstChat, stats, topPhrases diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx index 7bd8b10..d0aa943 100644 --- a/src/pages/AnnualReportPage.tsx +++ b/src/pages/AnnualReportPage.tsx @@ -25,8 +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]) - setSelectedPairYear(result.data[0]) + setSelectedYear((prev) => prev ?? result.data[0]) + setSelectedPairYear((prev) => prev ?? result.data[0]) } else if (!result.success) { setLoadError(result.error || '加载年度数据失败') } diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 14d6e6c..646b9ab 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -1,4 +1,13 @@ .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; @@ -71,30 +80,144 @@ } } + .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(auto-fit, minmax(160px, 1fr)); - gap: 16px; - margin: 20px 0 24px; + 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: 16px; - padding: 18px; + 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(auto-fit, minmax(220px, 1fr)); - gap: 16px; + 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: 16px; + padding: 18px 16px; display: flex; flex-direction: column; gap: 10px; diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 883acc3..8ba3f92 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -21,6 +21,7 @@ interface DualReportData { isSentByMe: boolean senderUsername?: string } | null + firstChatMessages?: DualReportMessage[] yearFirstChat?: { createTime: number createTimeStr: string @@ -257,11 +258,72 @@ function DualReportWindow() { 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"> @@ -279,7 +341,7 @@ function DualReportWindow() { <div className="report-container"> <section className="section"> <div className="label-text">WEFLOW · DUAL REPORT</div> - <h1 className="hero-title">{yearTitle}<br />双人聊天报告</h1> + <h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1> <hr className="divider" /> <div className="dual-names"> <span>{reportData.selfName}</span> @@ -293,20 +355,33 @@ function DualReportWindow() { <div className="label-text">首次聊天</div> <h2 className="hero-title">故事的开始</h2> {firstChat ? ( - <div className="dual-info-grid"> - <div className="dual-info-card"> - <div className="info-label">第一次聊天时间</div> - <div className="info-value">{firstChat.createTimeStr}</div> + <> + <div className="dual-info-grid"> + <div className="dual-info-card"> + <div className="info-label">第一次聊天时间</div> + <div className="info-value">{formatFullDate(firstChat.createTime)}</div> + </div> + <div className="dual-info-card"> + <div className="info-label">距今天数</div> + <div className="info-value">{daysSince} 天</div> + </div> </div> - <div className="dual-info-card"> - <div className="info-label">距今天数</div> - <div className="info-value">{daysSince} 天</div> - </div> - <div className="dual-info-card full"> - <div className="info-label">首条消息</div> - <div className="info-value">{firstChat.content || '(空)'}</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> )} @@ -314,12 +389,14 @@ function DualReportWindow() { {yearFirstChat ? ( <section className="section"> - <div className="label-text">今年首次聊天</div> - <h2 className="hero-title">新一年的开场</h2> + <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">{yearFirstChat.createTimeStr}</div> + <div className="info-label">第一段对话时间</div> + <div className="info-value">{formatFullDate(yearFirstChat.createTime)}</div> </div> <div className="dual-info-card"> <div className="info-label">发起者</div> @@ -329,8 +406,10 @@ function DualReportWindow() { <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} · {msg.createTimeStr}</div> - <div className="message-content">{msg.content || '(空)'}</div> + <div className="message-meta"> + {msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)} + </div> + <div className="message-content">{formatMessageContent(msg.content)}</div> </div> ))} </div> @@ -347,26 +426,16 @@ function DualReportWindow() { <div className="label-text">年度统计</div> <h2 className="hero-title">{yearTitle}数据概览</h2> <div className="dual-stat-grid"> - <div className="dual-stat-card"> - <div className="stat-num">{stats.totalMessages.toLocaleString()}</div> - <div className="stat-unit">总消息数</div> - </div> - <div className="dual-stat-card"> - <div className="stat-num">{stats.totalWords.toLocaleString()}</div> - <div className="stat-unit">总字数</div> - </div> - <div className="dual-stat-card"> - <div className="stat-num">{stats.imageCount.toLocaleString()}</div> - <div className="stat-unit">图片</div> - </div> - <div className="dual-stat-card"> - <div className="stat-num">{stats.voiceCount.toLocaleString()}</div> - <div className="stat-unit">语音</div> - </div> - <div className="dual-stat-card"> - <div className="stat-num">{stats.emojiCount.toLocaleString()}</div> - <div className="stat-unit">表情</div> - </div> + {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"> diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index b67b8f8..68e2cf5 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -352,6 +352,12 @@ export interface ElectronAPI { isSentByMe: boolean senderUsername?: string } | null + firstChatMessages?: Array<{ + content: string + isSentByMe: boolean + createTime: number + createTimeStr: string + }> yearFirstChat?: { createTime: number createTimeStr: string