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: {