diff --git a/electron/main.ts b/electron/main.ts index 0631d85..c8c1cdb 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1024,8 +1024,12 @@ function registerIpcHandlers() { return chatService.getSessionDetailExtra(sessionId) }) - ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[]) => { - return chatService.getExportSessionStats(sessionIds) + ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[], options?: { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + }) => { + return chatService.getExportSessionStats(sessionIds, options) }) ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => { @@ -1394,6 +1398,10 @@ function registerIpcHandlers() { return groupAnalyticsService.getGroupMembers(chatroomId) }) + ipcMain.handle('groupAnalytics:getGroupMembersPanelData', async (_, chatroomId: string, forceRefresh?: boolean) => { + return groupAnalyticsService.getGroupMembersPanelData(chatroomId, forceRefresh) + }) + ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => { return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime) }) diff --git a/electron/preload.ts b/electron/preload.ts index 40f5acc..0b52a1b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -157,7 +157,10 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId), getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), - getExportSessionStats: (sessionIds: string[]) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds), + getExportSessionStats: ( + sessionIds: string[], + options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean } + ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), @@ -234,6 +237,8 @@ contextBridge.exposeInMainWorld('electronAPI', { groupAnalytics: { getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'), getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId), + getGroupMembersPanelData: (chatroomId: string, forceRefresh?: boolean) => + ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, forceRefresh), getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime), getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime), getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index d8cfd1c..58b0e78 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -12,6 +12,7 @@ import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { MessageCacheService } from './messageCacheService' import { ContactCacheService, ContactCacheEntry } from './contactCacheService' +import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService' import { voiceTranscribeService } from './voiceTranscribeService' import { LRUCache } from '../utils/LRUCache.js' @@ -152,6 +153,19 @@ interface ExportSessionStats { groupMutualFriends?: number } +interface ExportSessionStatsOptions { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean +} + +interface ExportSessionStatsCacheMeta { + updatedAt: number + stale: boolean + includeRelations: boolean + source: 'memory' | 'disk' | 'fresh' +} + interface ExportTabCounts { private: number group: number @@ -194,6 +208,7 @@ class ChatService { private hardlinkCache = new Map() private readonly contactCacheService: ContactCacheService private readonly messageCacheService: MessageCacheService + private readonly sessionStatsCacheService: SessionStatsCacheService private voiceWavCache: LRUCache private voiceTranscriptCache: LRUCache private voiceTranscriptPending = new Map>() @@ -225,6 +240,13 @@ class ChatService { private readonly sessionDetailExtraCacheTtlMs = 5 * 60 * 1000 private sessionStatusCache = new Map() private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000 + private sessionStatsCacheScope = '' + private sessionStatsMemoryCache = new Map() + private sessionStatsPendingBasic = new Map>() + private sessionStatsPendingFull = new Map>() + private allGroupSessionIdsCache: { ids: string[]; updatedAt: number } | null = null + private readonly sessionStatsCacheTtlMs = 10 * 60 * 1000 + private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000 constructor() { this.configService = new ConfigService() @@ -232,6 +254,7 @@ class ChatService { const persisted = this.contactCacheService.getAllEntries() this.avatarCache = new Map(Object.entries(persisted)) this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath()) + this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath()) // 初始化LRU缓存,限制大小防止内存泄漏 this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries) this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录 @@ -319,6 +342,7 @@ class ChatService { // 使用 C++ DLL 内部的文件监控 (ReadDirectoryChangesW) // 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更 wcdbService.setMonitor((type, json) => { + this.handleSessionStatsMonitorChange(type, json) // 广播给所有渲染进程窗口 BrowserWindow.getAllWindows().forEach((win) => { if (!win.isDestroyed()) { @@ -1558,13 +1582,212 @@ class ChatService { const dbPath = String(this.configService.get('dbPath') || '') const myWxid = String(this.configService.get('myWxid') || '') const scope = `${dbPath}::${myWxid}` - if (scope === this.sessionMessageCountCacheScope) return + if (scope === this.sessionMessageCountCacheScope) { + this.refreshSessionStatsCacheScope(scope) + return + } this.sessionMessageCountCacheScope = scope this.sessionMessageCountCache.clear() this.sessionMessageCountHintCache.clear() this.sessionDetailFastCache.clear() this.sessionDetailExtraCache.clear() this.sessionStatusCache.clear() + this.refreshSessionStatsCacheScope(scope) + } + + private refreshSessionStatsCacheScope(scope: string): void { + if (scope === this.sessionStatsCacheScope) return + this.sessionStatsCacheScope = scope + this.sessionStatsMemoryCache.clear() + this.sessionStatsPendingBasic.clear() + this.sessionStatsPendingFull.clear() + this.allGroupSessionIdsCache = null + } + + private buildScopedSessionStatsKey(sessionId: string): string { + return `${this.sessionStatsCacheScope}::${sessionId}` + } + + private toSessionStatsCacheStats(stats: ExportSessionStats): SessionStatsCacheStats { + const normalized: SessionStatsCacheStats = { + totalMessages: Number.isFinite(stats.totalMessages) ? Math.max(0, Math.floor(stats.totalMessages)) : 0, + voiceMessages: Number.isFinite(stats.voiceMessages) ? Math.max(0, Math.floor(stats.voiceMessages)) : 0, + imageMessages: Number.isFinite(stats.imageMessages) ? Math.max(0, Math.floor(stats.imageMessages)) : 0, + videoMessages: Number.isFinite(stats.videoMessages) ? Math.max(0, Math.floor(stats.videoMessages)) : 0, + emojiMessages: Number.isFinite(stats.emojiMessages) ? Math.max(0, Math.floor(stats.emojiMessages)) : 0 + } + + if (Number.isFinite(stats.firstTimestamp)) normalized.firstTimestamp = Math.max(0, Math.floor(stats.firstTimestamp as number)) + if (Number.isFinite(stats.lastTimestamp)) normalized.lastTimestamp = Math.max(0, Math.floor(stats.lastTimestamp as number)) + if (Number.isFinite(stats.privateMutualGroups)) normalized.privateMutualGroups = Math.max(0, Math.floor(stats.privateMutualGroups as number)) + if (Number.isFinite(stats.groupMemberCount)) normalized.groupMemberCount = Math.max(0, Math.floor(stats.groupMemberCount as number)) + if (Number.isFinite(stats.groupMyMessages)) normalized.groupMyMessages = Math.max(0, Math.floor(stats.groupMyMessages as number)) + if (Number.isFinite(stats.groupActiveSpeakers)) normalized.groupActiveSpeakers = Math.max(0, Math.floor(stats.groupActiveSpeakers as number)) + if (Number.isFinite(stats.groupMutualFriends)) normalized.groupMutualFriends = Math.max(0, Math.floor(stats.groupMutualFriends as number)) + + return normalized + } + + private fromSessionStatsCacheStats(stats: SessionStatsCacheStats): ExportSessionStats { + return { + totalMessages: stats.totalMessages, + voiceMessages: stats.voiceMessages, + imageMessages: stats.imageMessages, + videoMessages: stats.videoMessages, + emojiMessages: stats.emojiMessages, + firstTimestamp: stats.firstTimestamp, + lastTimestamp: stats.lastTimestamp, + privateMutualGroups: stats.privateMutualGroups, + groupMemberCount: stats.groupMemberCount, + groupMyMessages: stats.groupMyMessages, + groupActiveSpeakers: stats.groupActiveSpeakers, + groupMutualFriends: stats.groupMutualFriends + } + } + + private supportsRequestedRelation(entry: SessionStatsCacheEntry, includeRelations: boolean): boolean { + if (!includeRelations) return true + return entry.includeRelations + } + + private getSessionStatsCacheEntry(sessionId: string): { entry: SessionStatsCacheEntry; source: 'memory' | 'disk' } | null { + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + const inMemory = this.sessionStatsMemoryCache.get(scopedKey) + if (inMemory) { + return { entry: inMemory, source: 'memory' } + } + + const persisted = this.sessionStatsCacheService.get(this.sessionStatsCacheScope, sessionId) + if (!persisted) return null + this.sessionStatsMemoryCache.set(scopedKey, persisted) + return { entry: persisted, source: 'disk' } + } + + private setSessionStatsCacheEntry(sessionId: string, stats: ExportSessionStats, includeRelations: boolean): number { + const updatedAt = Date.now() + const entry: SessionStatsCacheEntry = { + updatedAt, + includeRelations, + stats: this.toSessionStatsCacheStats(stats) + } + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + this.sessionStatsMemoryCache.set(scopedKey, entry) + this.sessionStatsCacheService.set(this.sessionStatsCacheScope, sessionId, entry) + return updatedAt + } + + private deleteSessionStatsCacheEntry(sessionId: string): void { + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + this.sessionStatsMemoryCache.delete(scopedKey) + this.sessionStatsPendingBasic.delete(scopedKey) + this.sessionStatsPendingFull.delete(scopedKey) + this.sessionStatsCacheService.delete(this.sessionStatsCacheScope, sessionId) + } + + private clearSessionStatsCacheForScope(): void { + this.sessionStatsMemoryCache.clear() + this.sessionStatsPendingBasic.clear() + this.sessionStatsPendingFull.clear() + this.allGroupSessionIdsCache = null + this.sessionStatsCacheService.clearScope(this.sessionStatsCacheScope) + } + + private collectSessionIdsFromPayload(payload: unknown): Set { + const ids = new Set() + const walk = (value: unknown, keyHint?: string) => { + if (Array.isArray(value)) { + for (const item of value) walk(item, keyHint) + return + } + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value as Record)) { + walk(v, k) + } + return + } + if (typeof value !== 'string') return + const normalized = value.trim() + if (!normalized) return + const lowerKey = String(keyHint || '').toLowerCase() + const keyLooksLikeSession = ( + lowerKey.includes('session') || + lowerKey.includes('talker') || + lowerKey.includes('username') || + lowerKey.includes('chatroom') + ) + if (!keyLooksLikeSession && !normalized.includes('@chatroom')) { + return + } + ids.add(normalized) + } + walk(payload) + return ids + } + + private handleSessionStatsMonitorChange(type: string, json: string): void { + this.refreshSessionMessageCountCacheScope() + if (!this.sessionStatsCacheScope) return + + const normalizedType = String(type || '').toLowerCase() + const maybeJson = String(json || '').trim() + let ids = new Set() + if (maybeJson) { + try { + ids = this.collectSessionIdsFromPayload(JSON.parse(maybeJson)) + } catch { + ids = this.collectSessionIdsFromPayload(maybeJson) + } + } + + if (ids.size > 0) { + ids.forEach((sessionId) => this.deleteSessionStatsCacheEntry(sessionId)) + if (Array.from(ids).some((id) => id.includes('@chatroom'))) { + this.allGroupSessionIdsCache = null + } + return + } + + // 无法定位具体会话时,保守地仅在消息/群成员相关变更时清空当前 scope,避免展示过旧统计。 + if ( + normalizedType.includes('message') || + normalizedType.includes('session') || + normalizedType.includes('group') || + normalizedType.includes('member') || + normalizedType.includes('contact') + ) { + this.clearSessionStatsCacheForScope() + } + } + + private async listAllGroupSessionIds(): Promise { + const now = Date.now() + if ( + this.allGroupSessionIdsCache && + now - this.allGroupSessionIdsCache.updatedAt <= this.allGroupSessionIdsCacheTtlMs + ) { + return this.allGroupSessionIdsCache.ids + } + + const result = await wcdbService.getSessions() + if (!result.success || !Array.isArray(result.sessions)) { + return [] + } + + const ids = new Set() + for (const rowAny of result.sessions) { + const row = rowAny as Record + const usernameRaw = row.username ?? row.userName ?? row.talker ?? row.sessionId + const username = String(usernameRaw || '').trim() + if (!username || !username.endsWith('@chatroom')) continue + ids.add(username) + } + + const list = Array.from(ids) + this.allGroupSessionIdsCache = { + ids: list, + updatedAt: now + } + return list } private async collectSessionExportStats( @@ -1715,6 +1938,95 @@ class ChatService { return { privateMutualGroupMap, groupMutualFriendMap } } + private buildEmptyExportSessionStats(sessionId: string, includeRelations: boolean): ExportSessionStats { + const isGroup = sessionId.endsWith('@chatroom') + const stats: ExportSessionStats = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0 + } + if (isGroup) { + stats.groupMyMessages = 0 + stats.groupActiveSpeakers = 0 + stats.groupMemberCount = 0 + if (includeRelations) { + stats.groupMutualFriends = 0 + } + } else if (includeRelations) { + stats.privateMutualGroups = 0 + } + return stats + } + + private async computeSessionExportStats( + sessionId: string, + selfIdentitySet: Set, + includeRelations: boolean + ): Promise { + const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet) + const isGroup = sessionId.endsWith('@chatroom') + + if (isGroup) { + const memberCountsResult = await wcdbService.getGroupMemberCounts([sessionId]) + const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} + stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' ? Math.max(0, Math.floor(memberCountMap[sessionId])) : 0 + } + + if (includeRelations) { + if (isGroup) { + try { + const { groupMutualFriendMap } = await this.buildGroupRelationStats([sessionId], [], selfIdentitySet) + stats.groupMutualFriends = groupMutualFriendMap[sessionId] || 0 + } catch { + stats.groupMutualFriends = 0 + } + } else { + const allGroups = await this.listAllGroupSessionIds() + if (allGroups.length === 0) { + stats.privateMutualGroups = 0 + } else { + try { + const { privateMutualGroupMap } = await this.buildGroupRelationStats(allGroups, [sessionId], selfIdentitySet) + stats.privateMutualGroups = privateMutualGroupMap[sessionId] || 0 + } catch { + stats.privateMutualGroups = 0 + } + } + } + } + + return stats + } + + private async getOrComputeSessionExportStats( + sessionId: string, + includeRelations: boolean, + selfIdentitySet: Set + ): Promise { + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + + if (!includeRelations) { + const pendingFull = this.sessionStatsPendingFull.get(scopedKey) + if (pendingFull) return pendingFull + const pendingBasic = this.sessionStatsPendingBasic.get(scopedKey) + if (pendingBasic) return pendingBasic + } else { + const pendingFull = this.sessionStatsPendingFull.get(scopedKey) + if (pendingFull) return pendingFull + } + + const targetMap = includeRelations ? this.sessionStatsPendingFull : this.sessionStatsPendingBasic + const pending = this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations) + targetMap.set(scopedKey, pending) + try { + return await pending + } finally { + targetMap.delete(scopedKey) + } + } + /** * HTTP API 复用消息解析逻辑,确保和应用内展示一致。 */ @@ -3600,6 +3912,14 @@ class ChatService { this.voiceTranscriptPending.clear() } + if (includeMessages || includeContacts) { + this.sessionStatsMemoryCache.clear() + this.sessionStatsPendingBasic.clear() + this.sessionStatsPendingFull.clear() + this.allGroupSessionIdsCache = null + this.sessionStatsCacheService.clearAll() + } + for (const state of this.hardlinkCache.values()) { try { state.db?.close() @@ -4036,9 +4356,11 @@ class ChatService { } } - async getExportSessionStats(sessionIds: string[]): Promise<{ + async getExportSessionStats(sessionIds: string[], options: ExportSessionStatsOptions = {}): Promise<{ success: boolean data?: Record + cache?: Record + needsRefresh?: string[] error?: string }> { try { @@ -4046,6 +4368,11 @@ class ChatService { if (!connectResult.success) { return { success: false, error: connectResult.error || '数据库未连接' } } + this.refreshSessionMessageCountCacheScope() + + const includeRelations = options.includeRelations ?? true + const forceRefresh = options.forceRefresh === true + const allowStaleCache = options.allowStaleCache === true const normalizedSessionIds = Array.from( new Set( @@ -4055,83 +4382,76 @@ class ChatService { ) ) if (normalizedSessionIds.length === 0) { - return { success: true, data: {} } + return { success: true, data: {}, cache: {} } } - const myWxid = this.configService.get('myWxid') || '' - const selfIdentitySet = new Set(this.buildIdentityKeys(myWxid)) - const resultMap: Record = {} - await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => { - try { - resultMap[sessionId] = await this.collectSessionExportStats(sessionId, selfIdentitySet) - } catch { - resultMap[sessionId] = { - totalMessages: 0, - voiceMessages: 0, - imageMessages: 0, - videoMessages: 0, - emojiMessages: 0 - } - } - }) + const cacheMeta: Record = {} + const needsRefreshSet = new Set() + const pendingSessionIds: string[] = [] + const now = Date.now() - const groupSessionIds = normalizedSessionIds.filter((id) => id.endsWith('@chatroom')) - const privateSessionIds = normalizedSessionIds.filter((id) => !id.endsWith('@chatroom')) - - for (const privateId of privateSessionIds) { - resultMap[privateId] = { - ...resultMap[privateId], - privateMutualGroups: resultMap[privateId]?.privateMutualGroups ?? 0 - } - } - for (const groupId of groupSessionIds) { - resultMap[groupId] = { - ...resultMap[groupId], - groupMyMessages: resultMap[groupId]?.groupMyMessages ?? 0, - groupActiveSpeakers: resultMap[groupId]?.groupActiveSpeakers ?? 0, - groupMemberCount: resultMap[groupId]?.groupMemberCount ?? 0, - groupMutualFriends: resultMap[groupId]?.groupMutualFriends ?? 0 - } - } - - if (groupSessionIds.length > 0) { - const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds) - const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} - for (const groupId of groupSessionIds) { - resultMap[groupId] = { - ...resultMap[groupId], - groupMemberCount: typeof memberCountMap[groupId] === 'number' ? memberCountMap[groupId] : 0 - } - } - } - - if (groupSessionIds.length > 0) { - try { - const { privateMutualGroupMap, groupMutualFriendMap } = await this.buildGroupRelationStats( - groupSessionIds, - privateSessionIds, - selfIdentitySet - ) - - for (const privateId of privateSessionIds) { - resultMap[privateId] = { - ...resultMap[privateId], - privateMutualGroups: privateMutualGroupMap[privateId] || 0 + for (const sessionId of normalizedSessionIds) { + if (!forceRefresh) { + const cachedResult = this.getSessionStatsCacheEntry(sessionId) + if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { + const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs + if (!stale || allowStaleCache) { + resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) + cacheMeta[sessionId] = { + updatedAt: cachedResult.entry.updatedAt, + stale, + includeRelations: cachedResult.entry.includeRelations, + source: cachedResult.source + } + if (stale) { + needsRefreshSet.add(sessionId) + } + continue } } - for (const groupId of groupSessionIds) { - resultMap[groupId] = { - ...resultMap[groupId], - groupMutualFriends: groupMutualFriendMap[groupId] || 0 - } + if (allowStaleCache) { + needsRefreshSet.add(sessionId) + continue } - } catch { - // 群成员关系统计失败时保留默认值,避免影响主列表展示 } + pendingSessionIds.push(sessionId) } - return { success: true, data: resultMap } + if (pendingSessionIds.length > 0) { + const myWxid = this.configService.get('myWxid') || '' + const selfIdentitySet = new Set(this.buildIdentityKeys(myWxid)) + await this.forEachWithConcurrency(pendingSessionIds, 3, async (sessionId) => { + try { + const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet) + resultMap[sessionId] = stats + const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) + cacheMeta[sessionId] = { + updatedAt, + stale: false, + includeRelations, + source: 'fresh' + } + } catch { + resultMap[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations) + } + }) + } + + const response: { + success: boolean + data?: Record + cache?: Record + needsRefresh?: string[] + } = { + success: true, + data: resultMap, + cache: cacheMeta + } + if (needsRefreshSet.size > 0) { + response.needsRefresh = Array.from(needsRefreshSet) + } + return response } catch (e) { console.error('ChatService: 获取导出会话统计失败:', e) return { success: false, error: String(e) } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 3622651..1ebd075 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -70,6 +70,7 @@ const MESSAGE_TYPE_MAP: Record = { export interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' + contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' dateRange?: { start: number; end: number } | null senderUsername?: string fileNameSuffix?: string @@ -104,6 +105,9 @@ interface MediaExportItem { posterDataUrl?: string } +type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' +type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' + export interface ExportProgress { current: number total: number @@ -159,6 +163,85 @@ class ExportService { return Math.max(1, Math.min(raw, max)) } + private isMediaExportEnabled(options: ExportOptions): boolean { + return options.exportMedia === true && + Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + } + + private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean { + if (!dateRange) return true + const start = Number.isFinite(dateRange.start) ? dateRange.start : 0 + const end = Number.isFinite(dateRange.end) ? dateRange.end : 0 + return start <= 0 && end <= 0 + } + + private shouldUseFastTextCollection(options: ExportOptions): boolean { + // 文本批量导出优先走轻量采集:不做媒体字段预提取,减少 CPU 与内存占用 + return !this.isMediaExportEnabled(options) + } + + private getMediaContentType(options: ExportOptions): MediaContentType | null { + const value = options.contentType + if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji') { + return value + } + return null + } + + private isMediaContentBatchExport(options: ExportOptions): boolean { + return this.getMediaContentType(options) !== null + } + + private getTargetMediaLocalTypes(options: ExportOptions): Set { + const mediaContentType = this.getMediaContentType(options) + if (mediaContentType === 'voice') return new Set([34]) + if (mediaContentType === 'image') return new Set([3]) + if (mediaContentType === 'video') return new Set([43]) + if (mediaContentType === 'emoji') return new Set([47]) + + const selected = new Set() + if (options.exportImages) selected.add(3) + if (options.exportVoices) selected.add(34) + if (options.exportVideos) selected.add(43) + if (options.exportEmojis) selected.add(47) + return selected + } + + private resolveCollectMode(options: ExportOptions): MessageCollectMode { + if (this.isMediaContentBatchExport(options)) { + return 'media-fast' + } + return this.shouldUseFastTextCollection(options) ? 'text-fast' : 'full' + } + + private resolveCollectParams(options: ExportOptions): { mode: MessageCollectMode; targetMediaTypes?: Set } { + const mode = this.resolveCollectMode(options) + if (mode === 'media-fast') { + const targetMediaTypes = this.getTargetMediaLocalTypes(options) + if (targetMediaTypes.size > 0) { + return { mode, targetMediaTypes } + } + } + return { mode } + } + + private shouldDecodeMessageContentInFastMode(localType: number): boolean { + // 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容 + if (localType === 3 || localType === 34 || localType === 42 || localType === 43 || localType === 47) { + return false + } + return true + } + + private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set | null): boolean { + if (!targetMediaTypes || !targetMediaTypes.has(localType)) return false + // 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容 + if (localType === 34) return false + // 图片/视频/表情可能需要从 XML 提取 md5/datName/cdnUrl + if (localType === 3 || localType === 43 || localType === 47) return true + return false + } + private cleanAccountDirName(dirName: string): string { const trimmed = dirName.trim() if (!trimmed) return trimmed @@ -1620,7 +1703,10 @@ class ExportService { } const msgId = String(msg.localId) - const fileName = `voice_${msgId}.wav` + const safeSession = this.cleanAccountDirName(sessionId) + .replace(/[^a-zA-Z0-9_-]/g, '_') + .slice(0, 48) || 'session' + const fileName = `voice_${safeSession}_${msgId}.wav` const destPath = path.join(voicesDir, fileName) // 如果已存在则跳过 @@ -1906,25 +1992,39 @@ class ExportService { sessionId: string, cleanedMyWxid: string, dateRange?: { start: number; end: number } | null, - senderUsernameFilter?: string + senderUsernameFilter?: string, + collectMode: MessageCollectMode = 'full', + targetMediaTypes?: Set ): Promise<{ rows: any[]; memberSet: Map; firstTime: number | null; lastTime: number | null }> { const rows: any[] = [] const memberSet = new Map() const senderSet = new Set() let firstTime: number | null = null let lastTime: number | null = null + const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0 + ? targetMediaTypes + : null // 修复时间范围:0 表示不限制,而不是时间戳 0 const beginTime = dateRange?.start || 0 const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0 - const cursor = await wcdbService.openMessageCursor( - sessionId, - 500, - true, - beginTime, - endTime - ) + const batchSize = (collectMode === 'text-fast' || collectMode === 'media-fast') ? 2000 : 500 + const cursor = collectMode === 'media-fast' + ? await wcdbService.openMessageCursorLite( + sessionId, + batchSize, + true, + beginTime, + endTime + ) + : await wcdbService.openMessageCursor( + sessionId, + batchSize, + true, + beginTime, + endTime + ) if (!cursor.success || !cursor.cursor) { console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`) return { rows, memberSet, firstTime, lastTime } @@ -1950,8 +2050,16 @@ class ExportService { if (createTime < dateRange.start || createTime > dateRange.end) continue } - const content = this.decodeMessageContent(row.message_content, row.compress_content) const localType = parseInt(row.local_type || row.type || '1', 10) + if (mediaTypeFilter && !mediaTypeFilter.has(localType)) { + continue + } + const shouldDecodeContent = collectMode === 'full' + || (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType)) + || (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter)) + const content = shouldDecodeContent + ? this.decodeMessageContent(row.message_content, row.compress_content) + : '' const senderUsername = row.sender_username || '' const isSendRaw = row.computed_is_send ?? row.is_send ?? '0' const isSend = parseInt(isSendRaw, 10) === 1 @@ -1987,7 +2095,7 @@ class ExportService { } senderSet.add(actualSender) - // 提取媒体相关字段 + // 提取媒体相关字段(轻量模式下跳过) let imageMd5: string | undefined let imageDatName: string | undefined let emojiCdnUrl: string | undefined @@ -1995,22 +2103,31 @@ class ExportService { let videoMd5: string | undefined let chatRecordList: any[] | undefined - if (localType === 3 && content) { - // 图片消息 - imageMd5 = this.extractImageMd5(content) - imageDatName = this.extractImageDatName(content) - } else if (localType === 47 && content) { - // 动画表情 - emojiCdnUrl = this.extractEmojiUrl(content) - emojiMd5 = this.extractEmojiMd5(content) - } else if (localType === 43 && content) { - // 视频消息 - videoMd5 = this.extractVideoMd5(content) - } else if (localType === 49 && content) { - // 检查是否是聊天记录消息(type=19) - const xmlType = this.extractXmlValue(content, 'type') - if (xmlType === '19') { - chatRecordList = this.parseChatHistory(content) + if (collectMode === 'full' || collectMode === 'media-fast') { + // 优先复用游标返回的字段,缺失时再回退到 XML 解析。 + imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined + imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined + emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined + emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || undefined + videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined + + if (localType === 3 && content) { + // 图片消息 + imageMd5 = imageMd5 || this.extractImageMd5(content) + imageDatName = imageDatName || this.extractImageDatName(content) + } else if (localType === 47 && content) { + // 动画表情 + emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content) + emojiMd5 = emojiMd5 || this.extractEmojiMd5(content) + } else if (localType === 43 && content) { + // 视频消息 + videoMd5 = videoMd5 || this.extractVideoMd5(content) + } else if (collectMode === 'full' && localType === 49 && content) { + // 检查是否是聊天记录消息(type=19) + const xmlType = this.extractXmlValue(content, 'type') + if (xmlType === '19') { + chatRecordList = this.parseChatHistory(content) + } } } @@ -2045,6 +2162,10 @@ class ExportService { } } + if (collectMode === 'media-fast' && mediaTypeFilter && rows.length > 0) { + await this.backfillMediaFieldsFromMessageDetail(sessionId, rows, mediaTypeFilter) + } + if (senderSet.size > 0) { const usernames = Array.from(senderSet) const [nameResult, avatarResult] = await Promise.all([ @@ -2072,6 +2193,60 @@ class ExportService { return { rows, memberSet, firstTime, lastTime } } + private async backfillMediaFieldsFromMessageDetail( + sessionId: string, + rows: any[], + targetMediaTypes: Set + ): Promise { + const needsBackfill = rows.filter((msg) => { + if (!targetMediaTypes.has(msg.localType)) return false + if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName + if (msg.localType === 47) return !msg.emojiMd5 && !msg.emojiCdnUrl + if (msg.localType === 43) return !msg.videoMd5 + return false + }) + if (needsBackfill.length === 0) return + + const DETAIL_CONCURRENCY = 6 + await parallelLimit(needsBackfill, DETAIL_CONCURRENCY, async (msg) => { + const localId = Number(msg.localId || 0) + if (!Number.isFinite(localId) || localId <= 0) return + + try { + const detail = await wcdbService.getMessageById(sessionId, localId) + if (!detail.success || !detail.message) return + + const row = detail.message as any + const rawMessageContent = row.message_content ?? row.messageContent ?? row.msg_content ?? row.msgContent ?? '' + const rawCompressContent = row.compress_content ?? row.compressContent ?? row.msg_compress_content ?? row.msgCompressContent ?? '' + const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) + + if (msg.localType === 3) { + const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) + const imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || this.extractImageDatName(content) + if (imageMd5) msg.imageMd5 = imageMd5 + if (imageDatName) msg.imageDatName = imageDatName + return + } + + if (msg.localType === 47) { + const emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || this.extractEmojiMd5(content) + const emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || this.extractEmojiUrl(content) + if (emojiMd5) msg.emojiMd5 = emojiMd5 + if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl + return + } + + if (msg.localType === 43) { + const videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || this.extractVideoMd5(content) + if (videoMd5) msg.videoMd5 = videoMd5 + } + } catch (error) { + // 详情补取失败时保持降级导出(占位符),避免中断整批任务。 + } + }) + } + // 补齐群成员,避免只导出发言者导致头像缺失 private async mergeGroupMembers( chatroomId: string, @@ -2631,7 +2806,15 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes + ) const allMessages = collected.rows // 如果没有消息,不创建文件 @@ -3026,7 +3209,15 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes + ) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -3600,7 +3791,15 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes + ) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -4087,7 +4286,15 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes + ) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -4367,7 +4574,15 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes + ) if (collected.rows.length === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -4709,7 +4924,15 @@ class ExportService { await this.ensureVoiceModel(onProgress) } - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes + ) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -5187,7 +5410,13 @@ class ExportService { for (const sessionId of sessionIds) { const sessionInfo = await this.getContactInfo(sessionId) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + 'text-fast' + ) const msgs = collected.rows const voiceMsgs = msgs.filter(m => m.localType === 34) const mediaMsgs = msgs.filter(m => { @@ -5264,8 +5493,12 @@ class ExportService { return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error } } - const exportMediaEnabled = options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options) + ? { ...options, exportVoiceAsText: false } + : options + + const exportMediaEnabled = effectiveOptions.exportMedia === true && + Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis) const rawWriteLayout = this.configService.get('exportWriteLayout') const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' ? rawWriteLayout @@ -5277,17 +5510,29 @@ class ExportService { fs.mkdirSync(exportBaseDir, { recursive: true }) } const sessionLayout = exportMediaEnabled - ? (options.sessionLayout ?? 'per-session') + ? (effectiveOptions.sessionLayout ?? 'per-session') : 'shared' let completedCount = 0 const defaultConcurrency = exportMediaEnabled ? 2 : 4 - const rawConcurrency = typeof options.exportConcurrency === 'number' - ? Math.floor(options.exportConcurrency) + const rawConcurrency = typeof effectiveOptions.exportConcurrency === 'number' + ? Math.floor(effectiveOptions.exportConcurrency) : defaultConcurrency const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, 6)) - const sessionConcurrency = (exportMediaEnabled && sessionLayout === 'shared') - ? 1 - : clampedConcurrency + const sessionConcurrency = clampedConcurrency + const emptySessionIds = new Set() + const canFastSkipEmptySessions = this.isUnboundedDateRange(effectiveOptions.dateRange) && + !String(effectiveOptions.senderUsername || '').trim() + if (canFastSkipEmptySessions && sessionIds.length > 0) { + const countsResult = await wcdbService.getMessageCounts(sessionIds) + if (countsResult.success && countsResult.counts) { + for (const sessionId of sessionIds) { + const count = countsResult.counts[sessionId] + if (typeof count === 'number' && Number.isFinite(count) && count <= 0) { + emptySessionIds.add(sessionId) + } + } + } + } const queue = [...sessionIds] let pauseRequested = false let stopRequested = false @@ -5295,6 +5540,21 @@ class ExportService { const runOne = async (sessionId: string) => { const sessionInfo = await this.getContactInfo(sessionId) + if (emptySessionIds.has(sessionId)) { + failCount++ + failedSessionIds.push(sessionId) + completedCount++ + onProgress?.({ + current: completedCount, + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId, + phase: 'exporting', + phaseLabel: '该会话没有消息,已跳过' + }) + return + } + const sessionProgress = (progress: ExportProgress) => { onProgress?.({ ...progress, @@ -5314,7 +5574,7 @@ class ExportService { const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim() const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session' - const suffix = sanitizeName(options.fileNameSuffix || '') + const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '') const safeName = suffix ? `${baseName}_${suffix}` : baseName const fileNameWithPrefix = `${await this.getSessionFilePrefix(sessionId)}${safeName}` const useSessionFolder = sessionLayout === 'per-session' @@ -5325,28 +5585,28 @@ class ExportService { } let ext = '.json' - if (options.format === 'chatlab-jsonl') ext = '.jsonl' - else if (options.format === 'excel') ext = '.xlsx' - else if (options.format === 'txt') ext = '.txt' - else if (options.format === 'weclone') ext = '.csv' - else if (options.format === 'html') ext = '.html' + if (effectiveOptions.format === 'chatlab-jsonl') ext = '.jsonl' + else if (effectiveOptions.format === 'excel') ext = '.xlsx' + else if (effectiveOptions.format === 'txt') ext = '.txt' + else if (effectiveOptions.format === 'weclone') ext = '.csv' + else if (effectiveOptions.format === 'html') ext = '.html' const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`) let result: { success: boolean; error?: string } - if (options.format === 'json' || options.format === 'arkme-json') { - result = await this.exportSessionToDetailedJson(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') { - result = await this.exportSessionToChatLab(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'excel') { - result = await this.exportSessionToExcel(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'txt') { - result = await this.exportSessionToTxt(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'weclone') { - result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'html') { - result = await this.exportSessionToHtml(sessionId, outputPath, options, sessionProgress) + if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') { + result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress) + } else if (effectiveOptions.format === 'chatlab' || effectiveOptions.format === 'chatlab-jsonl') { + result = await this.exportSessionToChatLab(sessionId, outputPath, effectiveOptions, sessionProgress) + } else if (effectiveOptions.format === 'excel') { + result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress) + } else if (effectiveOptions.format === 'txt') { + result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress) + } else if (effectiveOptions.format === 'weclone') { + result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress) + } else if (effectiveOptions.format === 'html') { + result = await this.exportSessionToHtml(sessionId, outputPath, effectiveOptions, sessionProgress) } else { - result = { success: false, error: `不支持的格式: ${options.format}` } + result = { success: false, error: `不支持的格式: ${effectiveOptions.format}` } } if (result.success) { diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index f763337..463b80a 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -24,6 +24,11 @@ export interface GroupMember { isOwner?: boolean } +export interface GroupMembersPanelEntry extends GroupMember { + isFriend: boolean + messageCount: number +} + export interface GroupMessageRank { member: GroupMember messageCount: number @@ -44,8 +49,26 @@ export interface GroupMediaStats { total: number } +interface GroupMemberContactInfo { + remark: string + nickName: string + alias: string + username: string + userName: string + encryptUsername: string + encryptUserName: string + localType: number +} + class GroupAnalyticsService { private configService: ConfigService + private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000 + private readonly groupMembersPanelCache = new Map() + private readonly groupMembersPanelInFlight = new Map< + string, + Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> + >() + private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) constructor() { this.configService = new ConfigService() @@ -419,6 +442,166 @@ class GroupAnalyticsService { return Array.from(set) } + private toNonNegativeInteger(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return 0 + return Math.max(0, Math.floor(parsed)) + } + + private pickStringField(row: Record, keys: string[]): string { + for (const key of keys) { + const value = row[key] + if (value == null) continue + const text = String(value).trim() + if (text) return text + } + return '' + } + + private pickIntegerField(row: Record, keys: string[], fallback: number = 0): number { + for (const key of keys) { + const value = row[key] + if (value == null || value === '') continue + const parsed = Number(value) + if (Number.isFinite(parsed)) return Math.floor(parsed) + } + return fallback + } + + private buildGroupMembersPanelCacheKey(chatroomId: string): string { + const dbPath = String(this.configService.get('dbPath') || '').trim() + const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim()) + return `${dbPath}::${wxid}::${chatroomId}` + } + + private pruneGroupMembersPanelCache(maxEntries: number = 80): void { + if (this.groupMembersPanelCache.size <= maxEntries) return + const entries = Array.from(this.groupMembersPanelCache.entries()) + .sort((a, b) => a[1].updatedAt - b[1].updatedAt) + const removeCount = this.groupMembersPanelCache.size - maxEntries + for (let i = 0; i < removeCount; i += 1) { + this.groupMembersPanelCache.delete(entries[i][0]) + } + } + + private async buildGroupMemberContactLookup(usernames: string[]): Promise> { + const lookup = new Map() + const candidates = this.buildIdCandidates(usernames) + if (candidates.length === 0) return lookup + + const batchSize = 200 + for (let i = 0; i < candidates.length; i += batchSize) { + const batch = candidates.slice(i, i + batchSize) + if (batch.length === 0) continue + + const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',') + const sql = `SELECT * 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 contact: GroupMemberContactInfo = { + remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']), + nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']), + alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']), + username: this.pickStringField(row, ['username', 'WCDB_CT_username']), + userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']), + encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']), + encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']), + localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) + } + const lookupKeys = this.buildIdCandidates([ + contact.username, + contact.userName, + contact.encryptUsername, + contact.encryptUserName, + contact.alias + ]) + for (const key of lookupKeys) { + const normalized = key.toLowerCase() + if (!lookup.has(normalized)) { + lookup.set(normalized, contact) + } + } + } + } + return lookup + } + + private resolveContactByCandidates( + lookup: Map, + candidates: Array + ): GroupMemberContactInfo | undefined { + const ids = this.buildIdCandidates(candidates) + for (const id of ids) { + const hit = lookup.get(id.toLowerCase()) + if (hit) return hit + } + return undefined + } + + private async buildGroupMessageCountLookup(chatroomId: string): Promise> { + const lookup = new Map() + const result = await wcdbService.getGroupStats(chatroomId, 0, 0) + if (!result.success || !result.data) return lookup + + const sessionData = result.data?.sessions?.[chatroomId] + if (!sessionData || !sessionData.senders) return lookup + + const idMap = result.data.idMap || {} + for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record)) { + const username = String(idMap[senderId] || senderId || '').trim() + if (!username) continue + const count = this.toNonNegativeInteger(rawCount) + const keys = this.buildIdCandidates([username]) + for (const key of keys) { + const normalized = key.toLowerCase() + const prev = lookup.get(normalized) || 0 + if (count > prev) { + lookup.set(normalized, count) + } + } + } + return lookup + } + + private resolveMessageCountByCandidates( + lookup: Map, + candidates: Array + ): number { + let maxCount = 0 + const ids = this.buildIdCandidates(candidates) + for (const id of ids) { + const count = lookup.get(id.toLowerCase()) + if (typeof count === 'number' && count > maxCount) { + maxCount = count + } + } + return maxCount + } + + private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean { + const normalizedWxid = String(wxid || '').trim().toLowerCase() + if (!normalizedWxid) return false + if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false + if (this.friendExcludeNames.has(normalizedWxid)) return false + if (!contact) return false + return contact.localType === 1 + } + + private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] { + return members.sort((a, b) => { + const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner)) + if (ownerDiff !== 0) return ownerDiff + + const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend)) + if (friendDiff !== 0) return friendDiff + + if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount + return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN') + }) + } + private resolveGroupNicknameByCandidates(groupNicknames: Map, candidates: string[]): string { const idCandidates = this.buildIdCandidates(candidates) if (idCandidates.length === 0) return '' @@ -606,6 +789,138 @@ class GroupAnalyticsService { } } + private async loadGroupMembersPanelDataFresh( + chatroomId: string + ): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> { + const membersResult = await wcdbService.getGroupMembers(chatroomId) + if (!membersResult.success || !membersResult.members) { + return { success: false, error: membersResult.error || '获取群成员失败' } + } + + const members = membersResult.members as Array<{ + username: string + avatarUrl?: string + originalName?: string + [key: string]: unknown + }> + if (members.length === 0) return { success: true, data: [] } + + const usernames = members + .map((member) => String(member.username || '').trim()) + .filter(Boolean) + if (usernames.length === 0) return { success: true, data: [] } + + const displayNamesPromise = wcdbService.getDisplayNames(usernames) + const contactLookupPromise = this.buildGroupMemberContactLookup(usernames) + const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members) + const messageCountLookupPromise = this.buildGroupMessageCountLookup(chatroomId) + + const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([ + displayNamesPromise, + contactLookupPromise, + ownerPromise, + messageCountLookupPromise + ]) + + const nicknameCandidates = this.buildIdCandidates([ + ...members.map((member) => member.username), + ...members.map((member) => member.originalName), + ...Array.from(contactLookup.values()).map((contact) => contact?.username), + ...Array.from(contactLookup.values()).map((contact) => contact?.userName), + ...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername), + ...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName), + ...Array.from(contactLookup.values()).map((contact) => contact?.alias) + ]) + const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) + const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') + + const data: GroupMembersPanelEntry[] = members + .map((member) => { + const wxid = String(member.username || '').trim() + if (!wxid) return null + + const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName]) + const nickname = contact?.nickName || '' + const remark = contact?.remark || '' + const alias = contact?.alias || '' + const normalizedWxid = this.cleanAccountDirName(wxid) + const lookupCandidates = this.buildIdCandidates([ + wxid, + member.originalName as string | undefined, + contact?.username, + contact?.userName, + contact?.encryptUsername, + contact?.encryptUserName, + alias + ]) + if (normalizedWxid === myWxid) { + lookupCandidates.push(myWxid) + } + const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates) + const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid + + return { + username: wxid, + displayName, + nickname, + alias, + remark, + groupNickname, + avatarUrl: member.avatarUrl, + isOwner: Boolean(ownerUsername && ownerUsername === wxid), + isFriend: this.isFriendMember(wxid, contact), + messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates) + } + }) + .filter((member): member is GroupMembersPanelEntry => Boolean(member)) + + return { success: true, data: this.sortGroupMembersPanelEntries(data) } + } + + async getGroupMembersPanelData( + chatroomId: string, + forceRefresh: boolean = false + ): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> { + try { + const normalizedChatroomId = String(chatroomId || '').trim() + if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' } + + const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId) + const now = Date.now() + const cached = this.groupMembersPanelCache.get(cacheKey) + if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) { + return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt } + } + + if (!forceRefresh) { + const pending = this.groupMembersPanelInFlight.get(cacheKey) + if (pending) return pending + } + + const requestPromise = (async () => { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const fresh = await this.loadGroupMembersPanelDataFresh(normalizedChatroomId) + if (!fresh.success || !fresh.data) { + return { success: false, error: fresh.error || '获取群成员面板数据失败' } + } + + const updatedAt = Date.now() + this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data }) + this.pruneGroupMembersPanelCache() + return { success: true, data: fresh.data, fromCache: false, updatedAt } + })().finally(() => { + this.groupMembersPanelInFlight.delete(cacheKey) + }) + + this.groupMembersPanelInFlight.set(cacheKey, requestPromise) + return await requestPromise + } catch (e) { + return { success: false, error: String(e) } + } + } + async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> { try { const conn = await this.ensureConnected() diff --git a/electron/services/sessionStatsCacheService.ts b/electron/services/sessionStatsCacheService.ts new file mode 100644 index 0000000..6af90f6 --- /dev/null +++ b/electron/services/sessionStatsCacheService.ts @@ -0,0 +1,276 @@ +import { join, dirname } from 'path' +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' +import { ConfigService } from './config' + +const CACHE_VERSION = 1 +const MAX_SESSION_ENTRIES_PER_SCOPE = 2000 +const MAX_SCOPE_ENTRIES = 12 + +export interface SessionStatsCacheStats { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + +export interface SessionStatsCacheEntry { + updatedAt: number + includeRelations: boolean + stats: SessionStatsCacheStats +} + +interface SessionStatsScopeMap { + [sessionId: string]: SessionStatsCacheEntry +} + +interface SessionStatsCacheStore { + version: number + scopes: Record +} + +function toNonNegativeInt(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + return Math.max(0, Math.floor(value)) +} + +function normalizeStats(raw: unknown): SessionStatsCacheStats | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + + const totalMessages = toNonNegativeInt(source.totalMessages) + const voiceMessages = toNonNegativeInt(source.voiceMessages) + const imageMessages = toNonNegativeInt(source.imageMessages) + const videoMessages = toNonNegativeInt(source.videoMessages) + const emojiMessages = toNonNegativeInt(source.emojiMessages) + + if ( + totalMessages === undefined || + voiceMessages === undefined || + imageMessages === undefined || + videoMessages === undefined || + emojiMessages === undefined + ) { + return null + } + + const normalized: SessionStatsCacheStats = { + totalMessages, + voiceMessages, + imageMessages, + videoMessages, + emojiMessages + } + + const firstTimestamp = toNonNegativeInt(source.firstTimestamp) + if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp + + const lastTimestamp = toNonNegativeInt(source.lastTimestamp) + if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp + + const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups) + if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups + + const groupMemberCount = toNonNegativeInt(source.groupMemberCount) + if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount + + const groupMyMessages = toNonNegativeInt(source.groupMyMessages) + if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages + + const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers) + if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers + + const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends) + if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends + + return normalized +} + +function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false + const stats = normalizeStats(source.stats) + + if (updatedAt === undefined || !stats) { + return null + } + + return { + updatedAt, + includeRelations, + stats + } +} + +export class SessionStatsCacheService { + private readonly cacheFilePath: string + private store: SessionStatsCacheStore = { + version: CACHE_VERSION, + scopes: {} + } + + constructor(cacheBasePath?: string) { + const basePath = cacheBasePath && cacheBasePath.trim().length > 0 + ? cacheBasePath + : ConfigService.getInstance().getCacheBasePath() + this.cacheFilePath = join(basePath, 'session-stats.json') + this.ensureCacheDir() + this.load() + } + + private ensureCacheDir(): void { + const dir = dirname(this.cacheFilePath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + } + + private load(): void { + if (!existsSync(this.cacheFilePath)) return + try { + const raw = readFileSync(this.cacheFilePath, 'utf8') + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const payload = parsed as Record + const scopesRaw = payload.scopes + if (!scopesRaw || typeof scopesRaw !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const scopes: Record = {} + for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record)) { + if (!scopeValue || typeof scopeValue !== 'object') continue + const normalizedScope: SessionStatsScopeMap = {} + for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record)) { + const entry = normalizeEntry(entryRaw) + if (!entry) continue + normalizedScope[sessionId] = entry + } + if (Object.keys(normalizedScope).length > 0) { + scopes[scopeKey] = normalizedScope + } + } + + this.store = { + version: CACHE_VERSION, + scopes + } + } catch (error) { + console.error('SessionStatsCacheService: 载入缓存失败', error) + this.store = { version: CACHE_VERSION, scopes: {} } + } + } + + get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined { + if (!scopeKey || !sessionId) return undefined + const scope = this.store.scopes[scopeKey] + if (!scope) return undefined + const entry = normalizeEntry(scope[sessionId]) + if (!entry) { + delete scope[sessionId] + if (Object.keys(scope).length === 0) { + delete this.store.scopes[scopeKey] + } + this.persist() + return undefined + } + return entry + } + + set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void { + if (!scopeKey || !sessionId) return + const normalized = normalizeEntry(entry) + if (!normalized) return + + if (!this.store.scopes[scopeKey]) { + this.store.scopes[scopeKey] = {} + } + this.store.scopes[scopeKey][sessionId] = normalized + + this.trimScope(scopeKey) + this.trimScopes() + this.persist() + } + + delete(scopeKey: string, sessionId: string): void { + if (!scopeKey || !sessionId) return + const scope = this.store.scopes[scopeKey] + if (!scope) return + if (!(sessionId in scope)) return + + delete scope[sessionId] + if (Object.keys(scope).length === 0) { + delete this.store.scopes[scopeKey] + } + this.persist() + } + + clearScope(scopeKey: string): void { + if (!scopeKey) return + if (!this.store.scopes[scopeKey]) return + delete this.store.scopes[scopeKey] + this.persist() + } + + clearAll(): void { + this.store = { version: CACHE_VERSION, scopes: {} } + try { + rmSync(this.cacheFilePath, { force: true }) + } catch (error) { + console.error('SessionStatsCacheService: 清理缓存失败', error) + } + } + + private trimScope(scopeKey: string): void { + const scope = this.store.scopes[scopeKey] + if (!scope) return + const entries = Object.entries(scope) + if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return + + entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + const trimmed: SessionStatsScopeMap = {} + for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) { + trimmed[sessionId] = entry + } + this.store.scopes[scopeKey] = trimmed + } + + private trimScopes(): void { + const scopeEntries = Object.entries(this.store.scopes) + if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return + + scopeEntries.sort((a, b) => { + const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0) + const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0) + return bUpdatedAt - aUpdatedAt + }) + + const trimmedScopes: Record = {} + for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) { + trimmedScopes[scopeKey] = scopeMap + } + this.store.scopes = trimmedScopes + } + + private persist(): void { + try { + writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8') + } catch (error) { + console.error('SessionStatsCacheService: 保存缓存失败', error) + } + } +} diff --git a/package.json b/package.json index 5e35e9e..514d99d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "postinstall": "electron-builder install-app-deps", "rebuild": "electron-rebuild", "dev": "vite", + "typecheck": "tsc --noEmit", "build": "tsc && vite build && electron-builder", "preview": "vite preview", "electron:dev": "vite --mode electron", diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx index d0cce60..40d8243 100644 --- a/src/components/GlobalSessionMonitor.tsx +++ b/src/components/GlobalSessionMonitor.tsx @@ -198,11 +198,12 @@ export function GlobalSessionMonitor() { // 尝试丰富或获取联系人详情 const contact = await window.electronAPI.chat.getContact(newSession.username) if (contact) { - if (contact.remark || contact.nickname) { - title = contact.remark || contact.nickname + if (contact.remark || contact.nickName) { + title = contact.remark || contact.nickName } - if (contact.avatarUrl) { - avatarUrl = contact.avatarUrl + const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username) + if (avatarResult?.avatarUrl) { + avatarUrl = avatarResult.avatarUrl } } else { // 如果不在缓存/数据库中 @@ -222,8 +223,11 @@ export function GlobalSessionMonitor() { if (title === newSession.username || title.startsWith('wxid_')) { const retried = await window.electronAPI.chat.getContact(newSession.username) if (retried) { - title = retried.remark || retried.nickname || title - avatarUrl = retried.avatarUrl || avatarUrl + title = retried.remark || retried.nickName || title + const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username) + if (retriedAvatar?.avatarUrl) { + avatarUrl = retriedAvatar.avatarUrl + } } } } diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx index 626f315..3aa49b1 100644 --- a/src/pages/AnnualReportPage.tsx +++ b/src/pages/AnnualReportPage.tsx @@ -23,10 +23,11 @@ function AnnualReportPage() { setLoadError(null) try { const result = await window.electronAPI.annualReport.getAvailableYears() - if (result.success && result.data && result.data.length > 0) { - setAvailableYears(result.data) - setSelectedYear((prev) => prev ?? result.data[0]) - setSelectedPairYear((prev) => prev ?? result.data[0]) + const years = result.data + if (result.success && Array.isArray(years) && years.length > 0) { + setAvailableYears(years) + setSelectedYear((prev) => prev ?? years[0]) + setSelectedPairYear((prev) => prev ?? years[0]) } else if (!result.success) { setLoadError(result.error || '加载年度数据失败') } diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index db73c49..1698bed 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2727,6 +2727,13 @@ opacity: 0.7; } } + + .detail-stats-meta { + margin-top: -6px; + margin-bottom: 10px; + font-size: 12px; + color: var(--text-tertiary); + } } .detail-item { @@ -2764,6 +2771,26 @@ } } + .detail-inline-btn { + border: none; + background: var(--bg-secondary); + color: var(--primary); + border-radius: 6px; + padding: 4px 8px; + font-size: 12px; + line-height: 1; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.7; + } + + &:hover:not(:disabled) { + background: var(--bg-hover); + } + } + .copy-btn { display: flex; align-items: center; @@ -2872,6 +2899,26 @@ } } + .group-members-status { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 16px; + font-size: 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + + .spin { + animation: spin 1s linear infinite; + } + + &.warning { + color: #b45309; + background: color-mix(in srgb, #f59e0b 10%, transparent); + } + } + .group-members-list { flex: 1; min-height: 0; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 5a83b63..b06d78d 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -152,6 +152,7 @@ const CHAT_SESSION_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000 const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000 const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30 const CHAT_SESSION_PREVIEW_MAX_SESSIONS = 18 +const GROUP_MEMBERS_PANEL_CACHE_TTL_MS = 10 * 60 * 1000 function buildChatSessionListCacheKey(scope: string): string { return `weflow.chat.sessions.v1::${scope || 'default'}` @@ -186,6 +187,17 @@ function formatYmdDateFromSeconds(timestamp?: number): string { return `${y}-${m}-${day}` } +function formatYmdHmDateTime(timestamp?: number): string { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const d = new Date(timestamp) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + const h = `${d.getHours()}`.padStart(2, '0') + const min = `${d.getMinutes()}`.padStart(2, '0') + return `${y}-${m}-${day} ${h}:${min}` +} + interface ChatPageProps { // 保留接口以备将来扩展 } @@ -208,11 +220,36 @@ interface SessionDetail { groupMyMessages?: number groupActiveSpeakers?: number groupMutualFriends?: number + relationStatsLoaded?: boolean + statsUpdatedAt?: number + statsStale?: boolean firstMessageTime?: number latestMessageTime?: number messageTables: { dbName: string; tableName: string; count: number }[] } +interface SessionExportMetric { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + +interface SessionExportCacheMeta { + updatedAt: number + stale: boolean + includeRelations: boolean + source: 'memory' | 'disk' | 'fresh' +} + interface GroupPanelMember { username: string displayName: string @@ -241,6 +278,11 @@ interface SessionPreviewCachePayload { entries: Record } +interface GroupMembersPanelCacheEntry { + updatedAt: number + members: GroupPanelMember[] +} + // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts import { avatarLoadQueue } from '../utils/AvatarLoadQueue' @@ -395,9 +437,13 @@ function ChatPage(_props: ChatPageProps) { const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingDetail, setIsLoadingDetail] = useState(false) const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false) + const [isRefreshingDetailStats, setIsRefreshingDetailStats] = useState(false) + const [isLoadingRelationStats, setIsLoadingRelationStats] = useState(false) const [groupPanelMembers, setGroupPanelMembers] = useState([]) const [isLoadingGroupMembers, setIsLoadingGroupMembers] = useState(false) const [groupMembersError, setGroupMembersError] = useState(null) + const [groupMembersLoadingHint, setGroupMembersLoadingHint] = useState('') + const [isRefreshingGroupMembers, setIsRefreshingGroupMembers] = useState(false) const [groupMemberSearchKeyword, setGroupMemberSearchKeyword] = useState('') const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) @@ -478,6 +524,8 @@ function ChatPage(_props: ChatPageProps) { const lastPreloadSessionRef = useRef(null) const detailRequestSeqRef = useRef(0) const groupMembersRequestSeqRef = useRef(0) + const groupMembersPanelCacheRef = useRef>(new Map()) + const hasInitializedGroupMembersRef = useRef(false) const chatCacheScopeRef = useRef('default') const previewCacheRef = useRef>({}) const previewPersistTimerRef = useRef(null) @@ -722,6 +770,40 @@ function ChatPage(_props: ChatPageProps) { } }, []) + const applySessionDetailStats = useCallback(( + sessionId: string, + metric: SessionExportMetric, + cacheMeta?: SessionExportCacheMeta, + relationLoadedOverride?: boolean + ) => { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== sessionId) return prev + const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded) + return { + ...prev, + messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount, + voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages, + imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages, + videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages, + emojiMessages: Number.isFinite(metric.emojiMessages) ? metric.emojiMessages : prev.emojiMessages, + groupMemberCount: Number.isFinite(metric.groupMemberCount) ? metric.groupMemberCount : prev.groupMemberCount, + groupMyMessages: Number.isFinite(metric.groupMyMessages) ? metric.groupMyMessages : prev.groupMyMessages, + groupActiveSpeakers: Number.isFinite(metric.groupActiveSpeakers) ? metric.groupActiveSpeakers : prev.groupActiveSpeakers, + privateMutualGroups: relationLoaded && Number.isFinite(metric.privateMutualGroups) + ? metric.privateMutualGroups + : prev.privateMutualGroups, + groupMutualFriends: relationLoaded && Number.isFinite(metric.groupMutualFriends) + ? metric.groupMutualFriends + : prev.groupMutualFriends, + relationStatsLoaded: relationLoaded, + statsUpdatedAt: cacheMeta?.updatedAt ?? prev.statsUpdatedAt, + statsStale: typeof cacheMeta?.stale === 'boolean' ? cacheMeta.stale : prev.statsStale, + firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : prev.firstMessageTime, + latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime + } + }) + }, []) + // 加载会话详情 const loadSessionDetail = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() @@ -733,6 +815,8 @@ function ChatPage(_props: ChatPageProps) { ? Math.floor(mappedSession.messageCountHint) : undefined + setIsRefreshingDetailStats(false) + setIsLoadingRelationStats(false) setSessionDetail((prev) => { const sameSession = prev?.wxid === normalizedSessionId return { @@ -752,6 +836,9 @@ function ChatPage(_props: ChatPageProps) { groupMyMessages: sameSession ? prev?.groupMyMessages : undefined, groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined, groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined, + relationStatsLoaded: sameSession ? prev?.relationStatsLoaded : false, + statsUpdatedAt: sameSession ? prev?.statsUpdatedAt : undefined, + statsStale: sameSession ? prev?.statsStale : undefined, firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] @@ -781,6 +868,9 @@ function ChatPage(_props: ChatPageProps) { groupMyMessages: prev?.groupMyMessages, groupActiveSpeakers: prev?.groupActiveSpeakers, groupMutualFriends: prev?.groupMutualFriends, + relationStatsLoaded: prev?.relationStatsLoaded, + statsUpdatedAt: prev?.statsUpdatedAt, + statsStale: prev?.statsStale, firstMessageTime: prev?.firstMessageTime, latestMessageTime: prev?.latestMessageTime, messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] @@ -797,47 +887,82 @@ function ChatPage(_props: ChatPageProps) { try { const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), - window.electronAPI.chat.getExportSessionStats([normalizedSessionId]) + window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, allowStaleCache: true } + ) ]) if (requestSeq !== detailRequestSeqRef.current) return - setSessionDetail((prev) => { - if (!prev || prev.wxid !== normalizedSessionId) return prev - - let next = { ...prev } - if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) { - next = { - ...next, - firstMessageTime: extraResultSettled.value.detail.firstMessageTime, - latestMessageTime: extraResultSettled.value.detail.latestMessageTime, - messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : [] - } + if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) { + const detail = extraResultSettled.value.detail + if (detail) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + firstMessageTime: detail.firstMessageTime, + latestMessageTime: detail.latestMessageTime, + messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : [] + } + }) } + } - if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) { - const metric = statsResultSettled.value.data[normalizedSessionId] - if (metric) { - next = { - ...next, - messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount, - voiceMessages: metric.voiceMessages, - imageMessages: metric.imageMessages, - videoMessages: metric.videoMessages, - emojiMessages: metric.emojiMessages, - privateMutualGroups: metric.privateMutualGroups, - groupMemberCount: metric.groupMemberCount, - groupMyMessages: metric.groupMyMessages, - groupActiveSpeakers: metric.groupActiveSpeakers, - groupMutualFriends: metric.groupMutualFriends, - firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime, - latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime + let refreshIncludeRelations = false + let shouldRefreshStats = false + if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { + const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined + const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + refreshIncludeRelations = Boolean(cacheMeta?.includeRelations) + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, refreshIncludeRelations) + } else if (cacheMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + relationStatsLoaded: refreshIncludeRelations || prev.relationStatsLoaded, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) + } + shouldRefreshStats = Array.isArray(statsResultSettled.value.needsRefresh) && + statsResultSettled.value.needsRefresh.includes(normalizedSessionId) + } + + if (shouldRefreshStats) { + setIsRefreshingDetailStats(true) + void (async () => { + try { + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: refreshIncludeRelations, forceRefresh: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (metric) { + applySessionDetailStats( + normalizedSessionId, + metric, + cacheMeta, + refreshIncludeRelations ? true : undefined + ) + } + } + } catch (error) { + console.error('刷新会话统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingDetailStats(false) } } - } - - return next - }) + })() + } } catch (e) { console.error('加载会话详情补充统计失败:', e) } finally { @@ -845,51 +970,120 @@ function ChatPage(_props: ChatPageProps) { setIsLoadingDetailExtra(false) } } - }, []) + }, [applySessionDetailStats]) + + const loadRelationStats = useCallback(async () => { + const normalizedSessionId = String(currentSessionId || '').trim() + if (!normalizedSessionId || isLoadingRelationStats) return + + const requestSeq = detailRequestSeqRef.current + setIsLoadingRelationStats(true) + try { + const relationResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: true, allowStaleCache: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + + const metric = relationResult.success && relationResult.data + ? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined + : undefined + const cacheMeta = relationResult.success + ? relationResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + : undefined + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true) + } + + const needRefresh = relationResult.success && + Array.isArray(relationResult.needsRefresh) && + relationResult.needsRefresh.includes(normalizedSessionId) + + if (needRefresh) { + setIsRefreshingDetailStats(true) + void (async () => { + try { + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: true, forceRefresh: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (freshMetric) { + applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true) + } + } + } catch (error) { + console.error('刷新会话关系统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingDetailStats(false) + } + } + })() + } + } catch (error) { + console.error('加载会话关系统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingRelationStats(false) + } + } + }, [applySessionDetailStats, currentSessionId, isLoadingRelationStats]) const loadGroupMembersPanel = useCallback(async (chatroomId: string) => { if (!chatroomId || !isGroupChatSession(chatroomId)) return const requestSeq = ++groupMembersRequestSeqRef.current - setIsLoadingGroupMembers(true) + const now = Date.now() + const cached = groupMembersPanelCacheRef.current.get(chatroomId) + const cacheFresh = Boolean(cached && now - cached.updatedAt < GROUP_MEMBERS_PANEL_CACHE_TTL_MS) + const hasCachedMembers = Boolean(cached && cached.members.length > 0) + + if (cacheFresh && cached) { + setGroupPanelMembers(cached.members) + setGroupMembersError(null) + setGroupMembersLoadingHint('') + setIsRefreshingGroupMembers(false) + setIsLoadingGroupMembers(false) + hasInitializedGroupMembersRef.current = true + return + } + setGroupMembersError(null) + if (hasCachedMembers && cached) { + setGroupPanelMembers(cached.members) + setIsRefreshingGroupMembers(true) + setGroupMembersLoadingHint('') + setIsLoadingGroupMembers(false) + } else { + setGroupPanelMembers([]) + setIsRefreshingGroupMembers(false) + setIsLoadingGroupMembers(true) + setGroupMembersLoadingHint( + hasInitializedGroupMembersRef.current + ? '加载群成员中...' + : '首次加载群成员,正在初始化索引(可能需要几秒)' + ) + } try { - const [membersResult, rankingResult, contactsResult] = await Promise.all([ - window.electronAPI.groupAnalytics.getGroupMembers(chatroomId), - window.electronAPI.groupAnalytics.getGroupMessageRanking(chatroomId, 20000), - window.electronAPI.chat.getContacts() - ]) + const membersResult = await window.electronAPI.groupAnalytics.getGroupMembersPanelData(chatroomId) if (requestSeq !== groupMembersRequestSeqRef.current) return if (!membersResult.success || !Array.isArray(membersResult.data)) { - setGroupPanelMembers([]) - setGroupMembersError(membersResult.error || '加载群成员失败') + if (!hasCachedMembers) { + setGroupPanelMembers([]) + } + setGroupMembersError(membersResult.error || (hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : '加载群成员失败')) return } - const messageCountMap = new Map() - if (rankingResult.success && Array.isArray(rankingResult.data)) { - for (const rank of rankingResult.data) { - const username = String(rank.member?.username || '').trim() - if (!username) continue - const count = Number.isFinite(rank.messageCount) ? Math.max(0, Math.floor(rank.messageCount)) : 0 - messageCountMap.set(username, count) - } - } - - const friendSet = new Set() - if (contactsResult.success && Array.isArray(contactsResult.contacts)) { - for (const contact of contactsResult.contacts) { - if (contact.type !== 'friend') continue - const username = String(contact.username || '').trim() - if (!username) continue - friendSet.add(username) - } - } - - const members: GroupPanelMember[] = membersResult.data - .map((member) => { + const membersPayload = membersResult.data as GroupPanelMember[] + const members: GroupPanelMember[] = membersPayload + .map((member: GroupPanelMember): GroupPanelMember | null => { const username = String(member.username || '').trim() if (!username) return null const preferredName = String( @@ -909,12 +1103,12 @@ function ChatPage(_props: ChatPageProps) { remark: member.remark, groupNickname: member.groupNickname, isOwner: Boolean(member.isOwner), - isFriend: friendSet.has(username), - messageCount: messageCountMap.get(username) || 0 + isFriend: Boolean(member.isFriend), + messageCount: Number.isFinite(member.messageCount) ? Math.max(0, Math.floor(member.messageCount)) : 0 } }) - .filter((member): member is GroupPanelMember => Boolean(member)) - .sort((a, b) => { + .filter((member: GroupPanelMember | null): member is GroupPanelMember => Boolean(member)) + .sort((a: GroupPanelMember, b: GroupPanelMember) => { const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner)) if (ownerDiff !== 0) return ownerDiff @@ -926,19 +1120,33 @@ function ChatPage(_props: ChatPageProps) { }) setGroupPanelMembers(members) - if (!rankingResult.success) { - setGroupMembersError(rankingResult.error || '群成员发言统计加载失败') + setGroupMembersError(null) + groupMembersPanelCacheRef.current.set(chatroomId, { + updatedAt: Date.now(), + members + }) + if (groupMembersPanelCacheRef.current.size > 80) { + const oldestEntry = Array.from(groupMembersPanelCacheRef.current.entries()) + .sort((a, b) => a[1].updatedAt - b[1].updatedAt)[0] + if (oldestEntry) { + groupMembersPanelCacheRef.current.delete(oldestEntry[0]) + } } + hasInitializedGroupMembersRef.current = true } catch (e) { if (requestSeq !== groupMembersRequestSeqRef.current) return - setGroupPanelMembers([]) - setGroupMembersError(String(e)) + if (!hasCachedMembers) { + setGroupPanelMembers([]) + } + setGroupMembersError(hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : String(e)) } finally { if (requestSeq === groupMembersRequestSeqRef.current) { setIsLoadingGroupMembers(false) + setIsRefreshingGroupMembers(false) + setGroupMembersLoadingHint('') } } - }, []) + }, [isGroupChatSession]) const toggleGroupMembersPanel = useCallback(() => { if (!currentSessionId || !isGroupChatSession(currentSessionId)) return @@ -1024,12 +1232,18 @@ function ChatPage(_props: ChatPageProps) { pendingSessionLoadRef.current = null setIsSessionSwitching(false) setSessionDetail(null) + setIsRefreshingDetailStats(false) + setIsLoadingRelationStats(false) setShowDetailPanel(false) setShowGroupMembersPanel(false) setGroupPanelMembers([]) setGroupMembersError(null) + setGroupMembersLoadingHint('') + setIsRefreshingGroupMembers(false) setGroupMemberSearchKeyword('') groupMembersRequestSeqRef.current += 1 + groupMembersPanelCacheRef.current.clear() + hasInitializedGroupMembersRef.current = false setIsLoadingGroupMembers(false) setCurrentSession(null) setSessions([]) @@ -1694,9 +1908,13 @@ function ChatPage(_props: ChatPageProps) { setShowGroupMembersPanel(false) setGroupMemberSearchKeyword('') setGroupMembersError(null) + setGroupMembersLoadingHint('') + setIsRefreshingGroupMembers(false) groupMembersRequestSeqRef.current += 1 setIsLoadingGroupMembers(false) setSessionDetail(null) + setIsRefreshingDetailStats(false) + setIsLoadingRelationStats(false) } // 搜索过滤 @@ -3146,10 +3364,22 @@ function ChatPage(_props: ChatPageProps) { + {isRefreshingGroupMembers && ( +
+ + 正在更新群成员数据... +
+ )} + {groupMembersError && groupPanelMembers.length > 0 && ( +
+ {groupMembersError} +
+ )} + {isLoadingGroupMembers ? (
- 加载群成员中... + {groupMembersLoadingHint || '加载群成员中...'}
) : groupMembersError && groupPanelMembers.length === 0 ? (
{groupMembersError}
@@ -3256,6 +3486,13 @@ function ChatPage(_props: ChatPageProps) { 消息统计(导出口径) +
+ {isRefreshingDetailStats + ? '统计刷新中...' + : sessionDetail.statsUpdatedAt + ? `${sessionDetail.statsStale ? '缓存于' : '更新于'} ${formatYmdHmDateTime(sessionDetail.statsUpdatedAt)}${sessionDetail.statsStale ? '(将后台刷新)' : ''}` + : (isLoadingDetailExtra ? '统计加载中...' : '暂无统计缓存')} +
消息总数 @@ -3325,9 +3562,19 @@ function ChatPage(_props: ChatPageProps) {
群共同好友数 - {Number.isFinite(sessionDetail.groupMutualFriends) - ? (sessionDetail.groupMutualFriends as number).toLocaleString() - : (isLoadingDetailExtra ? '统计中...' : '—')} + {sessionDetail.relationStatsLoaded + ? (Number.isFinite(sessionDetail.groupMutualFriends) + ? (sessionDetail.groupMutualFriends as number).toLocaleString() + : '—') + : ( + + )}
@@ -3335,9 +3582,19 @@ function ChatPage(_props: ChatPageProps) {
共同群聊数 - {Number.isFinite(sessionDetail.privateMutualGroups) - ? (sessionDetail.privateMutualGroups as number).toLocaleString() - : (isLoadingDetailExtra ? '统计中...' : '—')} + {sessionDetail.relationStatsLoaded + ? (Number.isFinite(sessionDetail.privateMutualGroups) + ? (sessionDetail.privateMutualGroups as number).toLocaleString() + : '—') + : ( + + )}
)} diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 9d155b7..585fb1e 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -107,7 +107,16 @@ function DualReportWindow() { setLoadingStage('完成') if (result.success && result.data) { - setReportData(result.data) + const normalizedResponse = result.data.response + ? { + ...result.data.response, + slowest: result.data.response.slowest ?? result.data.response.avg + } + : undefined + setReportData({ + ...result.data, + response: normalizedResponse + }) setIsLoading(false) } else { setError(result.error || '生成报告失败') diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index b266431..22a2c06 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1411,6 +1411,13 @@ text-transform: uppercase; letter-spacing: 0.4px; } + + .detail-stats-meta { + margin-top: -4px; + margin-bottom: 10px; + font-size: 12px; + color: var(--text-tertiary); + } } .detail-item { @@ -1443,6 +1450,26 @@ } } + .detail-inline-btn { + border: none; + background: var(--bg-secondary); + color: var(--primary); + border-radius: 6px; + padding: 4px 8px; + font-size: 12px; + line-height: 1; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.7; + } + + &:hover:not(:disabled) { + background: var(--bg-hover); + } + } + .copy-btn { display: flex; align-items: center; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 4f71555..248a63d 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -378,6 +378,17 @@ const formatYmdDateFromSeconds = (timestamp?: number): string => { return `${y}-${m}-${day}` } +const formatYmdHmDateTime = (timestamp?: number): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const d = new Date(timestamp) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + const h = `${d.getHours()}`.padStart(2, '0') + const min = `${d.getMinutes()}`.padStart(2, '0') + return `${y}-${m}-${day} ${h}:${min}` +} + const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { if (!timestamp) return '' const diff = Math.max(0, now - timestamp) @@ -496,11 +507,36 @@ interface SessionDetail { groupMyMessages?: number groupActiveSpeakers?: number groupMutualFriends?: number + relationStatsLoaded?: boolean + statsUpdatedAt?: number + statsStale?: boolean firstMessageTime?: number latestMessageTime?: number messageTables: { dbName: string; tableName: string; count: number }[] } +interface SessionExportMetric { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + +interface SessionExportCacheMeta { + updatedAt: number + stale: boolean + includeRelations: boolean + source: 'memory' | 'disk' | 'fresh' +} + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -772,6 +808,8 @@ function ExportPage() { const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false) const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false) + const [isRefreshingSessionDetailStats, setIsRefreshingSessionDetailStats] = useState(false) + const [isLoadingSessionRelationStats, setIsLoadingSessionRelationStats] = useState(false) const [copiedDetailField, setCopiedDetailField] = useState(null) const [exportFolder, setExportFolder] = useState('') @@ -1718,6 +1756,7 @@ function ExportPage() { next.exportVoices = payload.contentType === 'voice' next.exportVideos = payload.contentType === 'video' next.exportEmojis = payload.contentType === 'emoji' + next.exportVoiceAsText = false } } @@ -1813,6 +1852,7 @@ function ExportPage() { if (contentType === 'text') { return { ...base, + contentType, exportAvatars: true, exportMedia: false, exportImages: false, @@ -1824,11 +1864,13 @@ function ExportPage() { return { ...base, + contentType, exportMedia: true, exportImages: contentType === 'image', exportVoices: contentType === 'voice', exportVideos: contentType === 'video', - exportEmojis: contentType === 'emoji' + exportEmojis: contentType === 'emoji', + exportVoiceAsText: false } } @@ -2048,6 +2090,8 @@ function ExportPage() { })) } else { const doneAt = Date.now() + const successCount = result.successCount ?? 0 + const failCount = result.failCount ?? 0 const contentTypes = next.payload.contentType ? [next.payload.contentType] : inferContentTypesFromOptions(next.payload.options) @@ -2063,16 +2107,16 @@ function ExportPage() { updateTask(next.id, task => ({ ...task, status: 'stopped', - controlState: undefined, - finishedAt: doneAt, - progress: { - ...task.progress, - current: result.successCount + result.failCount, - total: task.progress.total || next.payload.sessionIds.length, - phaseLabel: '已停止' - }, - performance: finalizeTaskPerformance(task, doneAt) - })) + controlState: undefined, + finishedAt: doneAt, + progress: { + ...task.progress, + current: successCount + failCount, + total: task.progress.total || next.payload.sessionIds.length, + phaseLabel: '已停止' + }, + performance: finalizeTaskPerformance(task, doneAt) + })) } else if (result.paused) { const pendingSessionIds = Array.isArray(result.pendingSessionIds) ? result.pendingSessionIds @@ -2112,7 +2156,7 @@ function ExportPage() { }, progress: { ...task.progress, - current: result.successCount + result.failCount, + current: successCount + failCount, total: task.progress.total || next.payload.sessionIds.length, phaseLabel: '已暂停' }, @@ -2531,6 +2575,40 @@ function ExportPage() { return map }, [contactsList]) + const applySessionDetailStats = useCallback(( + sessionId: string, + metric: SessionExportMetric, + cacheMeta?: SessionExportCacheMeta, + relationLoadedOverride?: boolean + ) => { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== sessionId) return prev + const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded) + return { + ...prev, + messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount, + voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages, + imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages, + videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages, + emojiMessages: Number.isFinite(metric.emojiMessages) ? metric.emojiMessages : prev.emojiMessages, + groupMemberCount: Number.isFinite(metric.groupMemberCount) ? metric.groupMemberCount : prev.groupMemberCount, + groupMyMessages: Number.isFinite(metric.groupMyMessages) ? metric.groupMyMessages : prev.groupMyMessages, + groupActiveSpeakers: Number.isFinite(metric.groupActiveSpeakers) ? metric.groupActiveSpeakers : prev.groupActiveSpeakers, + privateMutualGroups: relationLoaded && Number.isFinite(metric.privateMutualGroups) + ? metric.privateMutualGroups + : prev.privateMutualGroups, + groupMutualFriends: relationLoaded && Number.isFinite(metric.groupMutualFriends) + ? metric.groupMutualFriends + : prev.groupMutualFriends, + relationStatsLoaded: relationLoaded, + statsUpdatedAt: cacheMeta?.updatedAt ?? prev.statsUpdatedAt, + statsStale: typeof cacheMeta?.stale === 'boolean' ? cacheMeta.stale : prev.statsStale, + firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : prev.firstMessageTime, + latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime + } + }) + }, []) + const loadSessionDetail = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return @@ -2543,6 +2621,8 @@ function ExportPage() { : undefined setCopiedDetailField(null) + setIsRefreshingSessionDetailStats(false) + setIsLoadingSessionRelationStats(false) setSessionDetail((prev) => { const sameSession = prev?.wxid === normalizedSessionId return { @@ -2562,6 +2642,9 @@ function ExportPage() { groupMyMessages: sameSession ? prev?.groupMyMessages : undefined, groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined, groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined, + relationStatsLoaded: sameSession ? prev?.relationStatsLoaded : false, + statsUpdatedAt: sameSession ? prev?.statsUpdatedAt : undefined, + statsStale: sameSession ? prev?.statsStale : undefined, firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] @@ -2591,6 +2674,9 @@ function ExportPage() { groupMyMessages: prev?.groupMyMessages, groupActiveSpeakers: prev?.groupActiveSpeakers, groupMutualFriends: prev?.groupMutualFriends, + relationStatsLoaded: prev?.relationStatsLoaded, + statsUpdatedAt: prev?.statsUpdatedAt, + statsStale: prev?.statsStale, firstMessageTime: prev?.firstMessageTime, latestMessageTime: prev?.latestMessageTime, messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] @@ -2607,47 +2693,82 @@ function ExportPage() { try { const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), - window.electronAPI.chat.getExportSessionStats([normalizedSessionId]) + window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, allowStaleCache: true } + ) ]) if (requestSeq !== detailRequestSeqRef.current) return - setSessionDetail((prev) => { - if (!prev || prev.wxid !== normalizedSessionId) return prev - - let next = { ...prev } - if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) { - next = { - ...next, - firstMessageTime: extraResultSettled.value.detail.firstMessageTime, - latestMessageTime: extraResultSettled.value.detail.latestMessageTime, - messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : [] - } + if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) { + const detail = extraResultSettled.value.detail + if (detail) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + firstMessageTime: detail.firstMessageTime, + latestMessageTime: detail.latestMessageTime, + messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : [] + } + }) } + } - if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) { - const metric = statsResultSettled.value.data[normalizedSessionId] - if (metric) { - next = { - ...next, - messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount, - voiceMessages: metric.voiceMessages, - imageMessages: metric.imageMessages, - videoMessages: metric.videoMessages, - emojiMessages: metric.emojiMessages, - privateMutualGroups: metric.privateMutualGroups, - groupMemberCount: metric.groupMemberCount, - groupMyMessages: metric.groupMyMessages, - groupActiveSpeakers: metric.groupActiveSpeakers, - groupMutualFriends: metric.groupMutualFriends, - firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime, - latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime + let refreshIncludeRelations = false + let shouldRefreshStats = false + if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { + const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined + const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + refreshIncludeRelations = Boolean(cacheMeta?.includeRelations) + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, refreshIncludeRelations) + } else if (cacheMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + relationStatsLoaded: refreshIncludeRelations || prev.relationStatsLoaded, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) + } + shouldRefreshStats = Array.isArray(statsResultSettled.value.needsRefresh) && + statsResultSettled.value.needsRefresh.includes(normalizedSessionId) + } + + if (shouldRefreshStats) { + setIsRefreshingSessionDetailStats(true) + void (async () => { + try { + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: refreshIncludeRelations, forceRefresh: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (metric) { + applySessionDetailStats( + normalizedSessionId, + metric, + cacheMeta, + refreshIncludeRelations ? true : undefined + ) + } + } + } catch (error) { + console.error('导出页刷新会话统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingSessionDetailStats(false) } } - } - - return next - }) + })() + } } catch (error) { console.error('导出页加载会话详情补充统计失败:', error) } finally { @@ -2655,7 +2776,77 @@ function ExportPage() { setIsLoadingSessionDetailExtra(false) } } - }, [contactByUsername, sessionRowByUsername]) + }, [applySessionDetailStats, contactByUsername, sessionRowByUsername]) + + const loadSessionRelationStats = useCallback(async () => { + const normalizedSessionId = String(sessionDetail?.wxid || '').trim() + if (!normalizedSessionId || isLoadingSessionRelationStats) return + + const requestSeq = detailRequestSeqRef.current + setIsLoadingSessionRelationStats(true) + try { + const relationResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: true, allowStaleCache: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + + const metric = relationResult.success && relationResult.data + ? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined + : undefined + const cacheMeta = relationResult.success + ? relationResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + : undefined + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true) + } + + const needRefresh = relationResult.success && + Array.isArray(relationResult.needsRefresh) && + relationResult.needsRefresh.includes(normalizedSessionId) + + if (needRefresh) { + setIsRefreshingSessionDetailStats(true) + void (async () => { + try { + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: true, forceRefresh: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (freshMetric) { + applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true) + } + } + } catch (error) { + console.error('导出页刷新会话关系统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingSessionDetailStats(false) + } + } + })() + } + } catch (error) { + console.error('导出页加载会话关系统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingSessionRelationStats(false) + } + } + }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid]) + + const closeSessionDetailPanel = useCallback(() => { + detailRequestSeqRef.current += 1 + setShowSessionDetailPanel(false) + setIsLoadingSessionDetail(false) + setIsLoadingSessionDetailExtra(false) + setIsRefreshingSessionDetailStats(false) + setIsLoadingSessionRelationStats(false) + }, []) const openSessionDetail = useCallback((sessionId: string) => { if (!sessionId) return @@ -2667,12 +2858,12 @@ function ExportPage() { if (!showSessionDetailPanel) return const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - setShowSessionDetailPanel(false) + closeSessionDetailPanel() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [showSessionDetailPanel]) + }, [closeSessionDetailPanel, showSessionDetailPanel]) const handleCopyDetailField = useCallback(async (text: string, field: string) => { try { @@ -3421,7 +3612,7 @@ function ExportPage() { {showSessionDetailPanel && (
setShowSessionDetailPanel(false)} + onClick={closeSessionDetailPanel} >
- @@ -3498,6 +3689,13 @@ function ExportPage() { 消息统计(导出口径) +
+ {isRefreshingSessionDetailStats + ? '统计刷新中...' + : sessionDetail.statsUpdatedAt + ? `${sessionDetail.statsStale ? '缓存于' : '更新于'} ${formatYmdHmDateTime(sessionDetail.statsUpdatedAt)}${sessionDetail.statsStale ? '(将后台刷新)' : ''}` + : (isLoadingSessionDetailExtra ? '统计加载中...' : '暂无统计缓存')} +
消息总数 @@ -3567,9 +3765,19 @@ function ExportPage() {
群共同好友数 - {Number.isFinite(sessionDetail.groupMutualFriends) - ? (sessionDetail.groupMutualFriends as number).toLocaleString() - : (isLoadingSessionDetailExtra ? '统计中...' : '—')} + {sessionDetail.relationStatsLoaded + ? (Number.isFinite(sessionDetail.groupMutualFriends) + ? (sessionDetail.groupMutualFriends as number).toLocaleString() + : '—') + : ( + + )}
@@ -3577,9 +3785,19 @@ function ExportPage() {
共同群聊数 - {Number.isFinite(sessionDetail.privateMutualGroups) - ? (sessionDetail.privateMutualGroups as number).toLocaleString() - : (isLoadingSessionDetailExtra ? '统计中...' : '—')} + {sessionDetail.relationStatsLoaded + ? (Number.isFinite(sessionDetail.privateMutualGroups) + ? (sessionDetail.privateMutualGroups as number).toLocaleString() + : '—') + : ( + + )}
)} diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index d2ef292..c2fa407 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -449,10 +449,10 @@ export default function SnsPage() { const snsPostCountMap = new Map( Object.entries(snsCountsResult.data).map(([username, count]) => [username, Math.max(0, Number(count || 0))]) ) - const contactsWithCounts = contactsList.map(contact => ({ + const contactsWithCounts: Contact[] = contactsList.map(contact => ({ ...contact, postCount: snsPostCountMap.get(contact.username) ?? 0, - postCountStatus: 'ready' + postCountStatus: 'ready' as const })) setContacts(contactsWithCounts) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 3101799..128bf40 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -48,9 +48,18 @@ export interface ElectronAPI { onDownloadProgress: (callback: (progress: number) => void) => () => void onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void } + notification: { + show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void> + close: () => Promise + click: (sessionId: string) => void + ready: () => void + resize: (width: number, height: number) => void + onShow: (callback: (event: any, data: any) => void) => () => void + } log: { getPath: () => Promise read: () => Promise<{ success: boolean; content?: string; error?: string }> + debug: (data: any) => void } dbPath: { autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }> @@ -125,6 +134,11 @@ export interface ElectronAPI { messages?: Message[] error?: string }> + getCachedMessages: (sessionId: string) => Promise<{ + success: boolean + messages?: Message[] + error?: string + }> getContact: (username: string) => Promise getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }> @@ -176,7 +190,10 @@ export interface ElectronAPI { } error?: string }> - getExportSessionStats: (sessionIds: string[]) => Promise<{ + getExportSessionStats: ( + sessionIds: string[], + options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean } + ) => Promise<{ success: boolean data?: Record + cache?: Record + needsRefresh?: string[] error?: string }> getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> @@ -211,7 +235,7 @@ export interface ElectronAPI { } image: { - decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }> + decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }> resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }> preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void @@ -327,6 +351,24 @@ export interface ElectronAPI { }> error?: string }> + getGroupMembersPanelData: (chatroomId: string, forceRefresh?: boolean) => Promise<{ + success: boolean + data?: Array<{ + username: string + displayName: string + avatarUrl?: string + nickname?: string + alias?: string + remark?: string + groupNickname?: string + isOwner?: boolean + isFriend: boolean + messageCount: number + }> + fromCache?: boolean + updatedAt?: number + error?: string + }> getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => Promise<{ success: boolean data?: Array<{ @@ -443,6 +485,20 @@ export interface ElectronAPI { phrase: string count: number }> + snsStats?: { + totalPosts: number + typeCounts?: Record + topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[] + topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[] + } + lostFriend: { + username: string + displayName: string + avatarUrl?: string + earlyCount: number + lateCount: number + periodDesc: string + } | null } error?: string }> @@ -498,15 +554,26 @@ export interface ElectronAPI { myTopEmojiMd5?: string friendTopEmojiMd5?: string myTopEmojiUrl?: string + friendTopEmojiUrl?: string + myTopEmojiCount?: number + friendTopEmojiCount?: number topPhrases: Array<{ phrase: string; count: number }> myExclusivePhrases: Array<{ phrase: string; count: number }> friendExclusivePhrases: Array<{ phrase: string; count: number }> heatmap?: number[][] initiative?: { initiated: number; received: number } - response?: { avg: number; fastest: number; count: number } + response?: { avg: number; fastest: number; slowest?: number; count: number } monthly?: Record streak?: { days: number; startDate: string; endDate: string } } + topPhrases: Array<{ phrase: string; count: number }> + myExclusivePhrases: Array<{ phrase: string; count: number }> + friendExclusivePhrases: Array<{ phrase: string; count: number }> + heatmap?: number[][] + initiative?: { initiated: number; received: number } + response?: { avg: number; fastest: number; slowest?: number; count: number } + monthly?: Record + streak?: { days: number; startDate: string; endDate: string } } error?: string }> @@ -585,7 +652,7 @@ export interface ElectronAPI { error?: string }> debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }> - proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }> + proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; videoPath?: string; error?: string }> downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }> exportTimeline: (options: { outputDir: string @@ -618,6 +685,7 @@ export interface ElectronAPI { export interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' + contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' dateRange?: { start: number; end: number } | null senderUsername?: string fileNameSuffix?: string diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 5b4176e..11f02fe 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,22 +1 @@ /// - -interface Window { - electronAPI: { - // ... other methods ... - auth: { - hello: (message?: string) => Promise<{ success: boolean; error?: string }> - verifyEnabled: () => Promise - unlock: (password: string) => Promise<{ success: boolean; error?: string }> - enableLock: (password: string) => Promise<{ success: boolean; error?: string }> - disableLock: (password: string) => Promise<{ success: boolean; error?: string }> - changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }> - setHelloSecret: (password: string) => Promise<{ success: boolean }> - clearHelloSecret: () => Promise<{ success: boolean }> - isLockMode: () => Promise - } - // For brevity, using 'any' for other parts or properly importing types if available. - // In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts - // or import a shared type definition. - [key: string]: any - } -}