diff --git a/electron/main.ts b/electron/main.ts index be7fd80..cb55797 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1061,6 +1061,10 @@ function registerIpcHandlers() { return chatService.getExportSessionStats(sessionIds, options) }) + ipcMain.handle('chat:getGroupMyMessageCountHint', async (_, chatroomId: string) => { + return chatService.getGroupMyMessageCountHint(chatroomId) + }) + ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => { return chatService.getImageData(sessionId, msgId) }) diff --git a/electron/preload.ts b/electron/preload.ts index ddb999a..2391fec 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -165,6 +165,8 @@ contextBridge.exposeInMainWorld('electronAPI', { sessionIds: string[], options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean } ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options), + getGroupMyMessageCountHint: (chatroomId: string) => + ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId), 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), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 65b4308..5b6313b 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -13,6 +13,7 @@ import { wcdbService } from './wcdbService' import { MessageCacheService } from './messageCacheService' import { ContactCacheService, ContactCacheEntry } from './contactCacheService' import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService' +import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService' import { ExportContentScopeStatsEntry, ExportContentSessionStatsEntry, @@ -226,6 +227,7 @@ class ChatService { private readonly contactCacheService: ContactCacheService private readonly messageCacheService: MessageCacheService private readonly sessionStatsCacheService: SessionStatsCacheService + private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService private readonly exportContentStatsCacheService: ExportContentStatsCacheService private voiceWavCache: LRUCache private voiceTranscriptCache: LRUCache @@ -265,6 +267,8 @@ class ChatService { private allGroupSessionIdsCache: { ids: string[]; updatedAt: number } | null = null private readonly sessionStatsCacheTtlMs = 10 * 60 * 1000 private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000 + private groupMyMessageCountCacheScope = '' + private groupMyMessageCountMemoryCache = new Map() private exportContentStatsScope = '' private exportContentStatsMemory = new Map() private exportContentStatsScopeUpdatedAt = 0 @@ -282,6 +286,7 @@ class ChatService { this.avatarCache = new Map(Object.entries(persisted)) this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath()) this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath()) + this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath()) this.exportContentStatsCacheService = new ExportContentStatsCacheService(this.configService.getCacheBasePath()) // 初始化LRU缓存,限制大小防止内存泄漏 this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries) @@ -901,7 +906,10 @@ class ChatService { /** * 批量获取会话消息总数(轻量接口,用于列表优先排序) */ - async getSessionMessageCounts(sessionIds: string[]): Promise<{ + async getSessionMessageCounts( + sessionIds: string[], + options?: { preferHintCache?: boolean; bypassSessionCache?: boolean } + ): Promise<{ success: boolean counts?: Record error?: string @@ -923,28 +931,36 @@ class ChatService { return { success: true, counts: {} } } + const preferHintCache = options?.preferHintCache !== false + const bypassSessionCache = options?.bypassSessionCache === true + this.refreshSessionMessageCountCacheScope() const counts: Record = {} const now = Date.now() const pendingSessionIds: string[] = [] for (const sessionId of normalizedSessionIds) { - const cached = this.sessionMessageCountCache.get(sessionId) - if (cached && now - cached.updatedAt <= this.sessionMessageCountCacheTtlMs) { - counts[sessionId] = cached.count - continue + if (!bypassSessionCache) { + const cached = this.sessionMessageCountCache.get(sessionId) + if (cached && now - cached.updatedAt <= this.sessionMessageCountCacheTtlMs) { + counts[sessionId] = cached.count + continue + } } - const hintCount = this.sessionMessageCountHintCache.get(sessionId) - if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { - counts[sessionId] = Math.floor(hintCount) - this.sessionMessageCountCache.set(sessionId, { - count: Math.floor(hintCount), - updatedAt: now - }) - } else { - pendingSessionIds.push(sessionId) + if (preferHintCache) { + const hintCount = this.sessionMessageCountHintCache.get(sessionId) + if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { + counts[sessionId] = Math.floor(hintCount) + this.sessionMessageCountCache.set(sessionId, { + count: Math.floor(hintCount), + updatedAt: now + }) + continue + } } + + pendingSessionIds.push(sessionId) } const batchSize = 320 @@ -1618,6 +1634,7 @@ class ChatService { const scope = `${dbPath}::${myWxid}` if (scope === this.sessionMessageCountCacheScope) { this.refreshSessionStatsCacheScope(scope) + this.refreshGroupMyMessageCountCacheScope(scope) this.refreshExportContentStatsScope(scope) return } @@ -1628,9 +1645,16 @@ class ChatService { this.sessionDetailExtraCache.clear() this.sessionStatusCache.clear() this.refreshSessionStatsCacheScope(scope) + this.refreshGroupMyMessageCountCacheScope(scope) this.refreshExportContentStatsScope(scope) } + private refreshGroupMyMessageCountCacheScope(scope: string): void { + if (scope === this.groupMyMessageCountCacheScope) return + this.groupMyMessageCountCacheScope = scope + this.groupMyMessageCountMemoryCache.clear() + } + private refreshExportContentStatsScope(scope: string): void { if (scope === this.exportContentStatsScope) return this.exportContentStatsScope = scope @@ -1718,7 +1742,7 @@ class ChatService { return { ...entry, updatedAt: Date.now(), - mediaReady: true + mediaReady: false } } @@ -1783,9 +1807,18 @@ class ChatService { if (targets.length > 0) { await this.forEachWithConcurrency(targets, 3, async (sessionId) => { - const nextEntry = await this.collectExportContentEntry(sessionId) - this.exportContentStatsMemory.set(sessionId, nextEntry) - this.exportContentStatsDirtySessionIds.delete(sessionId) + try { + const nextEntry = await this.collectExportContentEntry(sessionId) + this.exportContentStatsMemory.set(sessionId, nextEntry) + if (nextEntry.mediaReady) { + this.exportContentStatsDirtySessionIds.delete(sessionId) + } else { + this.exportContentStatsDirtySessionIds.add(sessionId) + } + } catch (error) { + console.error('ChatService: 刷新导出内容会话统计失败:', sessionId, error) + this.exportContentStatsDirtySessionIds.add(sessionId) + } }) } @@ -1850,7 +1883,7 @@ class ChatService { if (entry) { if (entry.hasAny) { textSessions += 1 - } else if (this.isExportContentEntryDirty(sessionId)) { + } else if (forceRefresh || this.isExportContentEntryDirty(sessionId)) { missingTextCountSessionIds.push(sessionId) } } else { @@ -1873,7 +1906,10 @@ class ChatService { } if (missingTextCountSessionIds.length > 0) { - const textCountResult = await this.getSessionMessageCounts(missingTextCountSessionIds) + const textCountResult = await this.getSessionMessageCounts(missingTextCountSessionIds, { + preferHintCache: false, + bypassSessionCache: true + }) if (textCountResult.success && textCountResult.counts) { const now = Date.now() for (const sessionId of missingTextCountSessionIds) { @@ -1948,6 +1984,43 @@ class ChatService { return `${this.sessionStatsCacheScope}::${sessionId}` } + private buildScopedGroupMyMessageCountKey(chatroomId: string): string { + return `${this.groupMyMessageCountCacheScope}::${chatroomId}` + } + + private getGroupMyMessageCountHintEntry( + chatroomId: string + ): { entry: GroupMyMessageCountCacheEntry; source: 'memory' | 'disk' } | null { + const scopedKey = this.buildScopedGroupMyMessageCountKey(chatroomId) + const inMemory = this.groupMyMessageCountMemoryCache.get(scopedKey) + if (inMemory) { + return { entry: inMemory, source: 'memory' } + } + + const persisted = this.groupMyMessageCountCacheService.get(this.groupMyMessageCountCacheScope, chatroomId) + if (!persisted) return null + this.groupMyMessageCountMemoryCache.set(scopedKey, persisted) + return { entry: persisted, source: 'disk' } + } + + private setGroupMyMessageCountHintEntry(chatroomId: string, messageCount: number, updatedAt?: number): number { + const nextCount = Number.isFinite(messageCount) ? Math.max(0, Math.floor(messageCount)) : 0 + const nextUpdatedAt = Number.isFinite(updatedAt) ? Math.max(0, Math.floor(updatedAt as number)) : Date.now() + const scopedKey = this.buildScopedGroupMyMessageCountKey(chatroomId) + const existing = this.groupMyMessageCountMemoryCache.get(scopedKey) + if (existing && existing.updatedAt > nextUpdatedAt) { + return existing.updatedAt + } + + const entry: GroupMyMessageCountCacheEntry = { + updatedAt: nextUpdatedAt, + messageCount: nextCount + } + this.groupMyMessageCountMemoryCache.set(scopedKey, entry) + this.groupMyMessageCountCacheService.set(this.groupMyMessageCountCacheScope, chatroomId, entry) + return nextUpdatedAt + } + private toSessionStatsCacheStats(stats: ExportSessionStats): SessionStatsCacheStats { const normalized: SessionStatsCacheStats = { totalMessages: Number.isFinite(stats.totalMessages) ? Math.max(0, Math.floor(stats.totalMessages)) : 0, @@ -2005,14 +2078,18 @@ class ChatService { private setSessionStatsCacheEntry(sessionId: string, stats: ExportSessionStats, includeRelations: boolean): number { const updatedAt = Date.now() + const normalizedStats = this.toSessionStatsCacheStats(stats) const entry: SessionStatsCacheEntry = { updatedAt, includeRelations, - stats: this.toSessionStatsCacheStats(stats) + stats: normalizedStats } const scopedKey = this.buildScopedSessionStatsKey(sessionId) this.sessionStatsMemoryCache.set(scopedKey, entry) this.sessionStatsCacheService.set(this.sessionStatsCacheScope, sessionId, entry) + if (sessionId.endsWith('@chatroom') && Number.isFinite(normalizedStats.groupMyMessages)) { + this.setGroupMyMessageCountHintEntry(sessionId, normalizedStats.groupMyMessages as number, updatedAt) + } return updatedAt } @@ -2217,6 +2294,9 @@ class ChatService { if (sessionId.endsWith('@chatroom')) { stats.groupActiveSpeakers = senderIdentities.size + if (Number.isFinite(stats.groupMyMessages)) { + this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) + } } return stats } @@ -4264,6 +4344,8 @@ class ChatService { this.sessionStatsPendingFull.clear() this.allGroupSessionIdsCache = null this.sessionStatsCacheService.clearAll() + this.groupMyMessageCountMemoryCache.clear() + this.groupMyMessageCountCacheService.clearAll() this.exportContentStatsMemory.clear() this.exportContentStatsDirtySessionIds.clear() this.exportContentScopeSessionIdsCache = null @@ -4707,6 +4789,51 @@ class ChatService { } } + async getGroupMyMessageCountHint(chatroomId: string): Promise<{ + success: boolean + count?: number + updatedAt?: number + source?: 'memory' | 'disk' + error?: string + }> { + try { + this.refreshSessionMessageCountCacheScope() + const normalizedChatroomId = String(chatroomId || '').trim() + if (!normalizedChatroomId || !normalizedChatroomId.endsWith('@chatroom')) { + return { success: false, error: '群聊ID无效' } + } + + const cached = this.getGroupMyMessageCountHintEntry(normalizedChatroomId) + if (!cached) return { success: true } + return { + success: true, + count: cached.entry.messageCount, + updatedAt: cached.entry.updatedAt, + source: cached.source + } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async setGroupMyMessageCountHint( + chatroomId: string, + messageCount: number, + updatedAt?: number + ): Promise<{ success: boolean; updatedAt?: number; error?: string }> { + try { + this.refreshSessionMessageCountCacheScope() + const normalizedChatroomId = String(chatroomId || '').trim() + if (!normalizedChatroomId || !normalizedChatroomId.endsWith('@chatroom')) { + return { success: false, error: '群聊ID无效' } + } + const savedAt = this.setGroupMyMessageCountHintEntry(normalizedChatroomId, messageCount, updatedAt) + return { success: true, updatedAt: savedAt } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getExportSessionStats(sessionIds: string[], options: ExportSessionStatsOptions = {}): Promise<{ success: boolean data?: Record @@ -4743,12 +4870,18 @@ class ChatService { const now = Date.now() for (const sessionId of normalizedSessionIds) { + const groupMyMessagesHint = sessionId.endsWith('@chatroom') + ? this.getGroupMyMessageCountHintEntry(sessionId) + : null 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) + if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { + resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount + } cacheMeta[sessionId] = { updatedAt: cachedResult.entry.updatedAt, stale, diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index fcfcac7..fbdb32e 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -875,6 +875,7 @@ class GroupAnalyticsService { ]) const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') + let myGroupMessageCountHint: number | undefined const data: GroupMembersPanelEntry[] = members .map((member) => { @@ -916,6 +917,17 @@ class GroupAnalyticsService { }) .filter((member): member is GroupMembersPanelEntry => Boolean(member)) + if (includeMessageCounts && myWxid) { + const selfEntry = data.find((member) => this.cleanAccountDirName(member.username) === myWxid) + if (selfEntry && Number.isFinite(selfEntry.messageCount)) { + myGroupMessageCountHint = Math.max(0, Math.floor(selfEntry.messageCount)) + } + } + + if (includeMessageCounts && Number.isFinite(myGroupMessageCountHint)) { + void chatService.setGroupMyMessageCountHint(chatroomId, myGroupMessageCountHint as number) + } + return { success: true, data: this.sortGroupMembersPanelEntries(data) } } diff --git a/electron/services/groupMyMessageCountCacheService.ts b/electron/services/groupMyMessageCountCacheService.ts new file mode 100644 index 0000000..68ee346 --- /dev/null +++ b/electron/services/groupMyMessageCountCacheService.ts @@ -0,0 +1,204 @@ +import { join, dirname } from 'path' +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' +import { ConfigService } from './config' + +const CACHE_VERSION = 1 +const MAX_GROUP_ENTRIES_PER_SCOPE = 3000 +const MAX_SCOPE_ENTRIES = 12 + +export interface GroupMyMessageCountCacheEntry { + updatedAt: number + messageCount: number +} + +interface GroupMyMessageCountScopeMap { + [chatroomId: string]: GroupMyMessageCountCacheEntry +} + +interface GroupMyMessageCountCacheStore { + 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 normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + const messageCount = toNonNegativeInt(source.messageCount) + if (updatedAt === undefined || messageCount === undefined) return null + return { + updatedAt, + messageCount + } +} + +export class GroupMyMessageCountCacheService { + private readonly cacheFilePath: string + private store: GroupMyMessageCountCacheStore = { + version: CACHE_VERSION, + scopes: {} + } + + constructor(cacheBasePath?: string) { + const basePath = cacheBasePath && cacheBasePath.trim().length > 0 + ? cacheBasePath + : ConfigService.getInstance().getCacheBasePath() + this.cacheFilePath = join(basePath, 'group-my-message-counts.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: GroupMyMessageCountScopeMap = {} + for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record)) { + const entry = normalizeEntry(entryRaw) + if (!entry) continue + normalizedScope[chatroomId] = entry + } + if (Object.keys(normalizedScope).length > 0) { + scopes[scopeKey] = normalizedScope + } + } + + this.store = { + version: CACHE_VERSION, + scopes + } + } catch (error) { + console.error('GroupMyMessageCountCacheService: 载入缓存失败', error) + this.store = { version: CACHE_VERSION, scopes: {} } + } + } + + get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined { + if (!scopeKey || !chatroomId) return undefined + const scope = this.store.scopes[scopeKey] + if (!scope) return undefined + const entry = normalizeEntry(scope[chatroomId]) + if (!entry) { + delete scope[chatroomId] + if (Object.keys(scope).length === 0) { + delete this.store.scopes[scopeKey] + } + this.persist() + return undefined + } + return entry + } + + set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void { + if (!scopeKey || !chatroomId) return + const normalized = normalizeEntry(entry) + if (!normalized) return + + if (!this.store.scopes[scopeKey]) { + this.store.scopes[scopeKey] = {} + } + + const existing = this.store.scopes[scopeKey][chatroomId] + if (existing && existing.updatedAt > normalized.updatedAt) { + return + } + + this.store.scopes[scopeKey][chatroomId] = normalized + this.trimScope(scopeKey) + this.trimScopes() + this.persist() + } + + delete(scopeKey: string, chatroomId: string): void { + if (!scopeKey || !chatroomId) return + const scope = this.store.scopes[scopeKey] + if (!scope) return + if (!(chatroomId in scope)) return + delete scope[chatroomId] + 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('GroupMyMessageCountCacheService: 清理缓存失败', error) + } + } + + private trimScope(scopeKey: string): void { + const scope = this.store.scopes[scopeKey] + if (!scope) return + const entries = Object.entries(scope) + if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return + entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + const trimmed: GroupMyMessageCountScopeMap = {} + for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) { + trimmed[chatroomId] = 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('GroupMyMessageCountCacheService: 保存缓存失败', error) + } + } +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index e17bb34..5a657c9 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -848,6 +848,26 @@ function ChatPage(_props: ChatPageProps) { setIsLoadingDetail(true) setIsLoadingDetailExtra(true) + if (normalizedSessionId.includes('@chatroom')) { + void (async () => { + try { + const hintResult = await window.electronAPI.chat.getGroupMyMessageCountHint(normalizedSessionId) + if (requestSeq !== detailRequestSeqRef.current) return + if (!hintResult.success || !Number.isFinite(hintResult.count)) return + const hintedMyCount = Math.max(0, Math.floor(hintResult.count as number)) + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + groupMyMessages: hintedMyCount + } + }) + } catch { + // ignore hint errors + } + })() + } + try { const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) if (requestSeq !== detailRequestSeqRef.current) return @@ -1074,6 +1094,57 @@ function ChatPage(_props: ChatPageProps) { }) }, []) + const normalizeWxidLikeIdentity = useCallback((value?: string): string => { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + const lowered = trimmed.toLowerCase() + if (lowered.startsWith('wxid_')) { + const matched = lowered.match(/^(wxid_[^_]+)/i) + return matched ? matched[1].toLowerCase() : lowered + } + const suffixMatch = lowered.match(/^(.+)_([a-z0-9]{4})$/i) + return suffixMatch ? suffixMatch[1].toLowerCase() : lowered + }, []) + + const isSelfGroupMember = useCallback((memberUsername?: string): boolean => { + const selfRaw = String(myWxid || '').trim().toLowerCase() + const selfNormalized = normalizeWxidLikeIdentity(myWxid) + if (!selfRaw && !selfNormalized) return false + const memberRaw = String(memberUsername || '').trim().toLowerCase() + const memberNormalized = normalizeWxidLikeIdentity(memberUsername) + return Boolean( + (selfRaw && memberRaw && selfRaw === memberRaw) || + (selfNormalized && memberNormalized && selfNormalized === memberNormalized) + ) + }, [myWxid, normalizeWxidLikeIdentity]) + + const resolveMyGroupMessageCountFromMembers = useCallback((members: GroupPanelMember[]): number | undefined => { + if (!myWxid) return undefined + + for (const member of members) { + if (!isSelfGroupMember(member.username)) continue + if (Number.isFinite(member.messageCount)) { + return Math.max(0, Math.floor(member.messageCount)) + } + return 0 + } + + return undefined + }, [isSelfGroupMember, myWxid]) + + const syncGroupMyMessagesFromMembers = useCallback((chatroomId: string, members: GroupPanelMember[]) => { + const myMessageCount = resolveMyGroupMessageCountFromMembers(members) + if (!Number.isFinite(myMessageCount)) return + + setSessionDetail((prev) => { + if (!prev || prev.wxid !== chatroomId || !prev.wxid.includes('@chatroom')) return prev + return { + ...prev, + groupMyMessages: myMessageCount as number + } + }) + }, [resolveMyGroupMessageCountFromMembers]) + const updateGroupMembersPanelCache = useCallback(( chatroomId: string, members: GroupPanelMember[], @@ -1093,6 +1164,47 @@ function ChatPage(_props: ChatPageProps) { } }, []) + const syncGroupMembersMyCountFromDetail = useCallback((chatroomId: string, myMessageCount: number) => { + if (!chatroomId || !chatroomId.includes('@chatroom')) return + const normalizedCount = Number.isFinite(myMessageCount) ? Math.max(0, Math.floor(myMessageCount)) : 0 + + const patchMembers = (members: GroupPanelMember[]): { changed: boolean; members: GroupPanelMember[] } => { + if (!Array.isArray(members) || members.length === 0) { + return { changed: false, members } + } + let changed = false + const patched = members.map((member) => { + if (!isSelfGroupMember(member.username)) return member + if (member.messageCount === normalizedCount) return member + changed = true + return { + ...member, + messageCount: normalizedCount + } + }) + if (!changed) return { changed: false, members } + return { changed: true, members: normalizeGroupPanelMembers(patched) } + } + + const cached = groupMembersPanelCacheRef.current.get(chatroomId) + if (cached && cached.members.length > 0) { + const patchedCache = patchMembers(cached.members) + if (patchedCache.changed) { + updateGroupMembersPanelCache(chatroomId, patchedCache.members, true) + } + } + + setGroupPanelMembers((prev) => { + const patched = patchMembers(prev) + if (!patched.changed) return prev + return patched.members + }) + }, [ + isSelfGroupMember, + normalizeGroupPanelMembers, + updateGroupMembersPanelCache + ]) + const getGroupMembersPanelDataWithTimeout = useCallback(async ( chatroomId: string, options: { forceRefresh?: boolean; includeMessageCounts?: boolean }, @@ -1145,6 +1257,7 @@ function ChatPage(_props: ChatPageProps) { const membersWithCounts = normalizeGroupPanelMembers(countsResult.data as GroupPanelMember[]) setGroupPanelMembers(membersWithCounts) + syncGroupMyMessagesFromMembers(chatroomId, membersWithCounts) setGroupMembersError(null) updateGroupMembersPanelCache(chatroomId, membersWithCounts, true) hasInitializedGroupMembersRef.current = true @@ -1161,6 +1274,9 @@ function ChatPage(_props: ChatPageProps) { if (cacheFresh && cached) { setGroupPanelMembers(cached.members) + if (cached.includeMessageCounts) { + syncGroupMyMessagesFromMembers(chatroomId, cached.members) + } setGroupMembersError(null) setGroupMembersLoadingHint('') setIsLoadingGroupMembers(false) @@ -1176,6 +1292,9 @@ function ChatPage(_props: ChatPageProps) { setGroupMembersError(null) if (hasCachedMembers && cached) { setGroupPanelMembers(cached.members) + if (cached.includeMessageCounts) { + syncGroupMyMessagesFromMembers(chatroomId, cached.members) + } setIsRefreshingGroupMembers(true) setGroupMembersLoadingHint('') setIsLoadingGroupMembers(false) @@ -1230,6 +1349,7 @@ function ChatPage(_props: ChatPageProps) { }, [ getGroupMembersPanelDataWithTimeout, isGroupChatSession, + syncGroupMyMessagesFromMembers, normalizeGroupPanelMembers, updateGroupMembersPanelCache ]) @@ -1267,6 +1387,13 @@ function ChatPage(_props: ChatPageProps) { void loadGroupMembersPanel(currentSessionId) }, [showGroupMembersPanel, currentSessionId, loadGroupMembersPanel, isGroupChatSession]) + useEffect(() => { + const chatroomId = String(sessionDetail?.wxid || '').trim() + if (!chatroomId || !chatroomId.includes('@chatroom')) return + if (!Number.isFinite(sessionDetail?.groupMyMessages)) return + syncGroupMembersMyCountFromDetail(chatroomId, sessionDetail!.groupMyMessages as number) + }, [sessionDetail?.groupMyMessages, sessionDetail?.wxid, syncGroupMembersMyCountFromDetail]) + // 复制字段值到剪贴板 const handleCopyField = useCallback(async (text: string, field: string) => { try { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 583a913..9b85cc1 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -876,7 +876,6 @@ function ExportPage() { totalFriends: 0 }) const [contentSessionCounts, setContentSessionCounts] = useState(defaultContentSessionCounts) - const [isContentSessionCountsLoading, setIsContentSessionCountsLoading] = useState(true) const [hasSeededContentSessionCounts, setHasSeededContentSessionCounts] = useState(false) const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [nowTick, setNowTick] = useState(Date.now()) @@ -904,6 +903,7 @@ function ExportPage() { const inProgressSessionIdsRef = useRef([]) const activeTaskCountRef = useRef(0) const hasBaseConfigReadyRef = useRef(false) + const contentSessionCountsForceRetryRef = useRef(0) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -1413,9 +1413,6 @@ function ExportPage() { }, []) const loadContentSessionCounts = useCallback(async (options?: { silent?: boolean; forceRefresh?: boolean }) => { - if (!options?.silent) { - setIsContentSessionCountsLoading(true) - } try { const result = await withTimeout( window.electronAPI.chat.getExportContentSessionCounts({ @@ -1437,14 +1434,23 @@ function ExportPage() { refreshing: result.data.refreshing === true } setContentSessionCounts(next) - setHasSeededContentSessionCounts(true) + const looksLikeAllZero = next.totalSessions > 0 && + next.textSessions === 0 && + next.voiceSessions === 0 && + next.imageSessions === 0 && + next.videoSessions === 0 && + next.emojiSessions === 0 + + if (looksLikeAllZero && contentSessionCountsForceRetryRef.current < 3) { + contentSessionCountsForceRetryRef.current += 1 + void window.electronAPI.chat.refreshExportContentSessionCounts({ forceRefresh: true }) + } else { + contentSessionCountsForceRetryRef.current = 0 + setHasSeededContentSessionCounts(true) + } } } catch (error) { console.error('加载导出内容会话统计失败:', error) - } finally { - if (!options?.silent) { - setIsContentSessionCountsLoading(false) - } } }, []) @@ -3205,7 +3211,7 @@ function ExportPage() { const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowMediaSection = !isContentScopeDialog const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady - const isSessionCardStatsLoading = isBaseConfigLoading || (isContentSessionCountsLoading && !hasSeededContentSessionCounts) + const isSessionCardStatsLoading = isBaseConfigLoading || !hasSeededContentSessionCounts const isSessionCardStatsRefreshing = contentSessionCounts.refreshing || contentSessionCounts.pendingMediaSessions > 0 const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index b937542..374077e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -240,6 +240,13 @@ export interface ElectronAPI { needsRefresh?: string[] error?: string }> + getGroupMyMessageCountHint: (chatroomId: string) => Promise<{ + success: boolean + count?: number + updatedAt?: number + source?: 'memory' | 'disk' + error?: string + }> getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }> getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>