mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
新增排除好友
This commit is contained in:
@@ -845,6 +845,18 @@ function registerIpcHandlers() {
|
|||||||
return analyticsService.getTimeDistribution()
|
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 () => {
|
ipcMain.handle('cache:clearAnalytics', async () => {
|
||||||
return analyticsService.clearCache()
|
return analyticsService.clearCache()
|
||||||
|
|||||||
@@ -162,9 +162,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// 数据分析
|
// 数据分析
|
||||||
analytics: {
|
analytics: {
|
||||||
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
|
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
||||||
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
|
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
|
||||||
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
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) => {
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||||
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
|
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('analytics:progress')
|
return () => ipcRenderer.removeAllListeners('analytics:progress')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { wcdbService } from './wcdbService'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { readFile, writeFile, rm } from 'fs/promises'
|
import { readFile, writeFile, rm } from 'fs/promises'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
export interface ChatStatistics {
|
export interface ChatStatistics {
|
||||||
totalMessages: number
|
totalMessages: number
|
||||||
@@ -46,6 +47,26 @@ class AnalyticsService {
|
|||||||
this.configService = new ConfigService()
|
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<string> {
|
||||||
|
return new Set(this.getExcludedUsernamesList())
|
||||||
|
}
|
||||||
|
|
||||||
private cleanAccountDirName(name: string): string {
|
private cleanAccountDirName(name: string): string {
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
if (!trimmed) return trimmed
|
if (!trimmed) return trimmed
|
||||||
@@ -97,13 +118,15 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getPrivateSessions(
|
private async getPrivateSessions(
|
||||||
cleanedWxid: string
|
cleanedWxid: string,
|
||||||
|
excludedUsernames?: Set<string>
|
||||||
): Promise<{ usernames: string[]; numericIds: string[] }> {
|
): Promise<{ usernames: string[]; numericIds: string[] }> {
|
||||||
const sessionResult = await wcdbService.getSessions()
|
const sessionResult = await wcdbService.getSessions()
|
||||||
if (!sessionResult.success || !sessionResult.sessions) {
|
if (!sessionResult.success || !sessionResult.sessions) {
|
||||||
return { usernames: [], numericIds: [] }
|
return { usernames: [], numericIds: [] }
|
||||||
}
|
}
|
||||||
const rows = sessionResult.sessions as Record<string, any>[]
|
const rows = sessionResult.sessions as Record<string, any>[]
|
||||||
|
const excluded = excludedUsernames ?? this.getExcludedUsernamesSet()
|
||||||
|
|
||||||
const sample = rows[0]
|
const sample = rows[0]
|
||||||
void sample
|
void sample
|
||||||
@@ -124,7 +147,11 @@ class AnalyticsService {
|
|||||||
return { username, idValue }
|
return { username, idValue }
|
||||||
})
|
})
|
||||||
const usernames = sessions.map((s) => s.username)
|
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 privateUsernames = privateSessions.map((s) => s.username)
|
||||||
const numericIds = privateSessions
|
const numericIds = privateSessions
|
||||||
.map((s) => s.idValue)
|
.map((s) => s.idValue)
|
||||||
@@ -177,8 +204,12 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
|
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
|
||||||
const sample = sessionIds.slice(0, 5).join(',')
|
if (sessionIds.length === 0) {
|
||||||
return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}`
|
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<any> {
|
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
|
||||||
@@ -369,6 +400,62 @@ class AnalyticsService {
|
|||||||
void results
|
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<string>(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 }> {
|
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface ConfigSchema {
|
|||||||
autoTranscribeVoice: boolean
|
autoTranscribeVoice: boolean
|
||||||
transcribeLanguages: string[]
|
transcribeLanguages: string[]
|
||||||
exportDefaultConcurrency: number
|
exportDefaultConcurrency: number
|
||||||
|
analyticsExcludedUsernames: string[]
|
||||||
|
|
||||||
// 安全相关
|
// 安全相关
|
||||||
authEnabled: boolean
|
authEnabled: boolean
|
||||||
@@ -62,6 +63,7 @@ export class ConfigService {
|
|||||||
autoTranscribeVoice: false,
|
autoTranscribeVoice: false,
|
||||||
transcribeLanguages: ['zh'],
|
transcribeLanguages: ['zh'],
|
||||||
exportDefaultConcurrency: 2,
|
exportDefaultConcurrency: 2,
|
||||||
|
analyticsExcludedUsernames: [],
|
||||||
|
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authPassword: '',
|
authPassword: '',
|
||||||
|
|||||||
@@ -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 {
|
@keyframes spin {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
@@ -292,4 +310,185 @@
|
|||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排除好友弹窗
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,50 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
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 ReactECharts from 'echarts-for-react'
|
||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
import './AnalyticsPage.scss'
|
import './AnalyticsPage.scss'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
|
|
||||||
|
interface ExcludeCandidate {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeUsername = (value: string) => value.trim().toLowerCase()
|
||||||
|
|
||||||
function AnalyticsPage() {
|
function AnalyticsPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [loadingStatus, setLoadingStatus] = useState('')
|
const [loadingStatus, setLoadingStatus] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
|
const [isExcludeDialogOpen, setIsExcludeDialogOpen] = useState(false)
|
||||||
|
const [excludeCandidates, setExcludeCandidates] = useState<ExcludeCandidate[]>([])
|
||||||
|
const [excludeQuery, setExcludeQuery] = useState('')
|
||||||
|
const [excludeLoading, setExcludeLoading] = useState(false)
|
||||||
|
const [excludeError, setExcludeError] = useState<string | null>(null)
|
||||||
|
const [excludedUsernames, setExcludedUsernames] = useState<Set<string>>(new Set())
|
||||||
|
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const themeMode = useThemeStore((state) => state.themeMode)
|
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) => {
|
const loadData = useCallback(async (forceRefresh = false) => {
|
||||||
if (isLoaded && !forceRefresh) return
|
if (isLoaded && !forceRefresh) return
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -65,14 +95,88 @@ function AnalyticsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
|
loadExcludedUsernames()
|
||||||
loadData(true)
|
loadData(true)
|
||||||
}
|
}
|
||||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
}, [loadData])
|
}, [loadData, loadExcludedUsernames])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadExcludedUsernames()
|
||||||
|
}, [loadExcludedUsernames])
|
||||||
|
|
||||||
const handleRefresh = () => loadData(true)
|
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) => {
|
const formatDate = (timestamp: number | null) => {
|
||||||
if (!timestamp) return '-'
|
if (!timestamp) return '-'
|
||||||
const date = new Date(timestamp * 1000)
|
const date = new Date(timestamp * 1000)
|
||||||
@@ -247,10 +351,16 @@ function AnalyticsPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>私聊分析</h1>
|
<h1>私聊分析</h1>
|
||||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
<div className="header-actions">
|
||||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||||
{isLoading ? '刷新中...' : '刷新'}
|
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||||
</button>
|
{isLoading ? '刷新中...' : '刷新'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={openExcludeDialog}>
|
||||||
|
<UserMinus size={16} />
|
||||||
|
排除好友{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-scroll">
|
<div className="page-scroll">
|
||||||
<section className="page-section">
|
<section className="page-section">
|
||||||
@@ -316,6 +426,83 @@ function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
{isExcludeDialogOpen && (
|
||||||
|
<div className="exclude-modal-overlay" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||||
|
<div className="exclude-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="exclude-modal-header">
|
||||||
|
<h3>选择不统计的好友</h3>
|
||||||
|
<button className="modal-close" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="exclude-modal-search">
|
||||||
|
<Search size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索好友"
|
||||||
|
value={excludeQuery}
|
||||||
|
onChange={e => setExcludeQuery(e.target.value)}
|
||||||
|
disabled={excludeLoading}
|
||||||
|
/>
|
||||||
|
{excludeQuery && (
|
||||||
|
<button className="clear-search" onClick={() => setExcludeQuery('')}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="exclude-modal-body">
|
||||||
|
{excludeLoading && (
|
||||||
|
<div className="exclude-loading">
|
||||||
|
<Loader2 size={20} className="spin" />
|
||||||
|
<span>正在加载好友列表...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!excludeLoading && excludeError && (
|
||||||
|
<div className="exclude-error">{excludeError}</div>
|
||||||
|
)}
|
||||||
|
{!excludeLoading && !excludeError && (
|
||||||
|
<div className="exclude-list">
|
||||||
|
{visibleExcludeCandidates.map((candidate) => {
|
||||||
|
const isChecked = draftExcluded.has(normalizeUsername(candidate.username))
|
||||||
|
return (
|
||||||
|
<label key={candidate.username} className={`exclude-item ${isChecked ? 'active' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => toggleExcluded(candidate.username)}
|
||||||
|
/>
|
||||||
|
<div className="exclude-avatar">
|
||||||
|
<Avatar src={candidate.avatarUrl} name={candidate.displayName} size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="exclude-info">
|
||||||
|
<span className="exclude-name">{candidate.displayName}</span>
|
||||||
|
<span className="exclude-username">{candidate.username}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{visibleExcludeCandidates.length === 0 && (
|
||||||
|
<div className="exclude-empty">
|
||||||
|
{excludeQuery.trim() ? '未找到匹配好友' : '暂无可选好友'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="exclude-modal-footer">
|
||||||
|
<span className="exclude-count">已排除 {draftExcluded.size} 人</span>
|
||||||
|
<div className="exclude-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleApplyExcluded} disabled={excludeLoading}>
|
||||||
|
应用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/types/electron.d.ts
vendored
19
src/types/electron.d.ts
vendored
@@ -175,6 +175,25 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
error?: string
|
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
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||||
}
|
}
|
||||||
cache: {
|
cache: {
|
||||||
|
|||||||
Reference in New Issue
Block a user