feat(export): improve count accuracy and include pending updates

This commit is contained in:
tisonhuang
2026-03-02 19:26:29 +08:00
parent b6878aefd6
commit 91f630209c
8 changed files with 526 additions and 31 deletions

View File

@@ -1061,6 +1061,10 @@ function registerIpcHandlers() {
return chatService.getExportSessionStats(sessionIds, options) 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) => { ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => {
return chatService.getImageData(sessionId, msgId) return chatService.getImageData(sessionId, msgId)
}) })

View File

@@ -165,6 +165,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
sessionIds: string[], sessionIds: string[],
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean } options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean }
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options), ) => 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), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),

View File

@@ -13,6 +13,7 @@ import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService' import { MessageCacheService } from './messageCacheService'
import { ContactCacheService, ContactCacheEntry } from './contactCacheService' import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService' import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService'
import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService'
import { import {
ExportContentScopeStatsEntry, ExportContentScopeStatsEntry,
ExportContentSessionStatsEntry, ExportContentSessionStatsEntry,
@@ -226,6 +227,7 @@ class ChatService {
private readonly contactCacheService: ContactCacheService private readonly contactCacheService: ContactCacheService
private readonly messageCacheService: MessageCacheService private readonly messageCacheService: MessageCacheService
private readonly sessionStatsCacheService: SessionStatsCacheService private readonly sessionStatsCacheService: SessionStatsCacheService
private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService
private readonly exportContentStatsCacheService: ExportContentStatsCacheService private readonly exportContentStatsCacheService: ExportContentStatsCacheService
private voiceWavCache: LRUCache<string, Buffer> private voiceWavCache: LRUCache<string, Buffer>
private voiceTranscriptCache: LRUCache<string, string> private voiceTranscriptCache: LRUCache<string, string>
@@ -265,6 +267,8 @@ class ChatService {
private allGroupSessionIdsCache: { ids: string[]; updatedAt: number } | null = null private allGroupSessionIdsCache: { ids: string[]; updatedAt: number } | null = null
private readonly sessionStatsCacheTtlMs = 10 * 60 * 1000 private readonly sessionStatsCacheTtlMs = 10 * 60 * 1000
private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000 private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000
private groupMyMessageCountCacheScope = ''
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>()
private exportContentStatsScope = '' private exportContentStatsScope = ''
private exportContentStatsMemory = new Map<string, ExportContentSessionStatsEntry>() private exportContentStatsMemory = new Map<string, ExportContentSessionStatsEntry>()
private exportContentStatsScopeUpdatedAt = 0 private exportContentStatsScopeUpdatedAt = 0
@@ -282,6 +286,7 @@ class ChatService {
this.avatarCache = new Map(Object.entries(persisted)) this.avatarCache = new Map(Object.entries(persisted))
this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath()) this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath()) this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath())
this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath())
this.exportContentStatsCacheService = new ExportContentStatsCacheService(this.configService.getCacheBasePath()) this.exportContentStatsCacheService = new ExportContentStatsCacheService(this.configService.getCacheBasePath())
// 初始化LRU缓存限制大小防止内存泄漏 // 初始化LRU缓存限制大小防止内存泄漏
this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries) 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 success: boolean
counts?: Record<string, number> counts?: Record<string, number>
error?: string error?: string
@@ -923,28 +931,36 @@ class ChatService {
return { success: true, counts: {} } return { success: true, counts: {} }
} }
const preferHintCache = options?.preferHintCache !== false
const bypassSessionCache = options?.bypassSessionCache === true
this.refreshSessionMessageCountCacheScope() this.refreshSessionMessageCountCacheScope()
const counts: Record<string, number> = {} const counts: Record<string, number> = {}
const now = Date.now() const now = Date.now()
const pendingSessionIds: string[] = [] const pendingSessionIds: string[] = []
for (const sessionId of normalizedSessionIds) { for (const sessionId of normalizedSessionIds) {
const cached = this.sessionMessageCountCache.get(sessionId) if (!bypassSessionCache) {
if (cached && now - cached.updatedAt <= this.sessionMessageCountCacheTtlMs) { const cached = this.sessionMessageCountCache.get(sessionId)
counts[sessionId] = cached.count if (cached && now - cached.updatedAt <= this.sessionMessageCountCacheTtlMs) {
continue counts[sessionId] = cached.count
continue
}
} }
const hintCount = this.sessionMessageCountHintCache.get(sessionId) if (preferHintCache) {
if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { const hintCount = this.sessionMessageCountHintCache.get(sessionId)
counts[sessionId] = Math.floor(hintCount) if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) {
this.sessionMessageCountCache.set(sessionId, { counts[sessionId] = Math.floor(hintCount)
count: Math.floor(hintCount), this.sessionMessageCountCache.set(sessionId, {
updatedAt: now count: Math.floor(hintCount),
}) updatedAt: now
} else { })
pendingSessionIds.push(sessionId) continue
}
} }
pendingSessionIds.push(sessionId)
} }
const batchSize = 320 const batchSize = 320
@@ -1618,6 +1634,7 @@ class ChatService {
const scope = `${dbPath}::${myWxid}` const scope = `${dbPath}::${myWxid}`
if (scope === this.sessionMessageCountCacheScope) { if (scope === this.sessionMessageCountCacheScope) {
this.refreshSessionStatsCacheScope(scope) this.refreshSessionStatsCacheScope(scope)
this.refreshGroupMyMessageCountCacheScope(scope)
this.refreshExportContentStatsScope(scope) this.refreshExportContentStatsScope(scope)
return return
} }
@@ -1628,9 +1645,16 @@ class ChatService {
this.sessionDetailExtraCache.clear() this.sessionDetailExtraCache.clear()
this.sessionStatusCache.clear() this.sessionStatusCache.clear()
this.refreshSessionStatsCacheScope(scope) this.refreshSessionStatsCacheScope(scope)
this.refreshGroupMyMessageCountCacheScope(scope)
this.refreshExportContentStatsScope(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 { private refreshExportContentStatsScope(scope: string): void {
if (scope === this.exportContentStatsScope) return if (scope === this.exportContentStatsScope) return
this.exportContentStatsScope = scope this.exportContentStatsScope = scope
@@ -1718,7 +1742,7 @@ class ChatService {
return { return {
...entry, ...entry,
updatedAt: Date.now(), updatedAt: Date.now(),
mediaReady: true mediaReady: false
} }
} }
@@ -1783,9 +1807,18 @@ class ChatService {
if (targets.length > 0) { if (targets.length > 0) {
await this.forEachWithConcurrency(targets, 3, async (sessionId) => { await this.forEachWithConcurrency(targets, 3, async (sessionId) => {
const nextEntry = await this.collectExportContentEntry(sessionId) try {
this.exportContentStatsMemory.set(sessionId, nextEntry) const nextEntry = await this.collectExportContentEntry(sessionId)
this.exportContentStatsDirtySessionIds.delete(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) {
if (entry.hasAny) { if (entry.hasAny) {
textSessions += 1 textSessions += 1
} else if (this.isExportContentEntryDirty(sessionId)) { } else if (forceRefresh || this.isExportContentEntryDirty(sessionId)) {
missingTextCountSessionIds.push(sessionId) missingTextCountSessionIds.push(sessionId)
} }
} else { } else {
@@ -1873,7 +1906,10 @@ class ChatService {
} }
if (missingTextCountSessionIds.length > 0) { 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) { if (textCountResult.success && textCountResult.counts) {
const now = Date.now() const now = Date.now()
for (const sessionId of missingTextCountSessionIds) { for (const sessionId of missingTextCountSessionIds) {
@@ -1948,6 +1984,43 @@ class ChatService {
return `${this.sessionStatsCacheScope}::${sessionId}` 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 { private toSessionStatsCacheStats(stats: ExportSessionStats): SessionStatsCacheStats {
const normalized: SessionStatsCacheStats = { const normalized: SessionStatsCacheStats = {
totalMessages: Number.isFinite(stats.totalMessages) ? Math.max(0, Math.floor(stats.totalMessages)) : 0, 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 { private setSessionStatsCacheEntry(sessionId: string, stats: ExportSessionStats, includeRelations: boolean): number {
const updatedAt = Date.now() const updatedAt = Date.now()
const normalizedStats = this.toSessionStatsCacheStats(stats)
const entry: SessionStatsCacheEntry = { const entry: SessionStatsCacheEntry = {
updatedAt, updatedAt,
includeRelations, includeRelations,
stats: this.toSessionStatsCacheStats(stats) stats: normalizedStats
} }
const scopedKey = this.buildScopedSessionStatsKey(sessionId) const scopedKey = this.buildScopedSessionStatsKey(sessionId)
this.sessionStatsMemoryCache.set(scopedKey, entry) this.sessionStatsMemoryCache.set(scopedKey, entry)
this.sessionStatsCacheService.set(this.sessionStatsCacheScope, sessionId, 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 return updatedAt
} }
@@ -2217,6 +2294,9 @@ class ChatService {
if (sessionId.endsWith('@chatroom')) { if (sessionId.endsWith('@chatroom')) {
stats.groupActiveSpeakers = senderIdentities.size stats.groupActiveSpeakers = senderIdentities.size
if (Number.isFinite(stats.groupMyMessages)) {
this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number)
}
} }
return stats return stats
} }
@@ -4264,6 +4344,8 @@ class ChatService {
this.sessionStatsPendingFull.clear() this.sessionStatsPendingFull.clear()
this.allGroupSessionIdsCache = null this.allGroupSessionIdsCache = null
this.sessionStatsCacheService.clearAll() this.sessionStatsCacheService.clearAll()
this.groupMyMessageCountMemoryCache.clear()
this.groupMyMessageCountCacheService.clearAll()
this.exportContentStatsMemory.clear() this.exportContentStatsMemory.clear()
this.exportContentStatsDirtySessionIds.clear() this.exportContentStatsDirtySessionIds.clear()
this.exportContentScopeSessionIdsCache = null 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<{ async getExportSessionStats(sessionIds: string[], options: ExportSessionStatsOptions = {}): Promise<{
success: boolean success: boolean
data?: Record<string, ExportSessionStats> data?: Record<string, ExportSessionStats>
@@ -4743,12 +4870,18 @@ class ChatService {
const now = Date.now() const now = Date.now()
for (const sessionId of normalizedSessionIds) { for (const sessionId of normalizedSessionIds) {
const groupMyMessagesHint = sessionId.endsWith('@chatroom')
? this.getGroupMyMessageCountHintEntry(sessionId)
: null
if (!forceRefresh) { if (!forceRefresh) {
const cachedResult = this.getSessionStatsCacheEntry(sessionId) const cachedResult = this.getSessionStatsCacheEntry(sessionId)
if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
if (!stale || allowStaleCache) { if (!stale || allowStaleCache) {
resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats)
if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) {
resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount
}
cacheMeta[sessionId] = { cacheMeta[sessionId] = {
updatedAt: cachedResult.entry.updatedAt, updatedAt: cachedResult.entry.updatedAt,
stale, stale,

View File

@@ -875,6 +875,7 @@ class GroupAnalyticsService {
]) ])
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
let myGroupMessageCountHint: number | undefined
const data: GroupMembersPanelEntry[] = members const data: GroupMembersPanelEntry[] = members
.map((member) => { .map((member) => {
@@ -916,6 +917,17 @@ class GroupAnalyticsService {
}) })
.filter((member): member is GroupMembersPanelEntry => Boolean(member)) .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) } return { success: true, data: this.sortGroupMembersPanelEntries(data) }
} }

View File

@@ -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<string, GroupMyMessageCountScopeMap>
}
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<string, unknown>
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<string, unknown>
const scopesRaw = payload.scopes
if (!scopesRaw || typeof scopesRaw !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopes: Record<string, GroupMyMessageCountScopeMap> = {}
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
if (!scopeValue || typeof scopeValue !== 'object') continue
const normalizedScope: GroupMyMessageCountScopeMap = {}
for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
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<string, GroupMyMessageCountScopeMap> = {}
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)
}
}
}

View File

@@ -848,6 +848,26 @@ function ChatPage(_props: ChatPageProps) {
setIsLoadingDetail(true) setIsLoadingDetail(true)
setIsLoadingDetailExtra(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 { try {
const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId)
if (requestSeq !== detailRequestSeqRef.current) return 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(( const updateGroupMembersPanelCache = useCallback((
chatroomId: string, chatroomId: string,
members: GroupPanelMember[], 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 ( const getGroupMembersPanelDataWithTimeout = useCallback(async (
chatroomId: string, chatroomId: string,
options: { forceRefresh?: boolean; includeMessageCounts?: boolean }, options: { forceRefresh?: boolean; includeMessageCounts?: boolean },
@@ -1145,6 +1257,7 @@ function ChatPage(_props: ChatPageProps) {
const membersWithCounts = normalizeGroupPanelMembers(countsResult.data as GroupPanelMember[]) const membersWithCounts = normalizeGroupPanelMembers(countsResult.data as GroupPanelMember[])
setGroupPanelMembers(membersWithCounts) setGroupPanelMembers(membersWithCounts)
syncGroupMyMessagesFromMembers(chatroomId, membersWithCounts)
setGroupMembersError(null) setGroupMembersError(null)
updateGroupMembersPanelCache(chatroomId, membersWithCounts, true) updateGroupMembersPanelCache(chatroomId, membersWithCounts, true)
hasInitializedGroupMembersRef.current = true hasInitializedGroupMembersRef.current = true
@@ -1161,6 +1274,9 @@ function ChatPage(_props: ChatPageProps) {
if (cacheFresh && cached) { if (cacheFresh && cached) {
setGroupPanelMembers(cached.members) setGroupPanelMembers(cached.members)
if (cached.includeMessageCounts) {
syncGroupMyMessagesFromMembers(chatroomId, cached.members)
}
setGroupMembersError(null) setGroupMembersError(null)
setGroupMembersLoadingHint('') setGroupMembersLoadingHint('')
setIsLoadingGroupMembers(false) setIsLoadingGroupMembers(false)
@@ -1176,6 +1292,9 @@ function ChatPage(_props: ChatPageProps) {
setGroupMembersError(null) setGroupMembersError(null)
if (hasCachedMembers && cached) { if (hasCachedMembers && cached) {
setGroupPanelMembers(cached.members) setGroupPanelMembers(cached.members)
if (cached.includeMessageCounts) {
syncGroupMyMessagesFromMembers(chatroomId, cached.members)
}
setIsRefreshingGroupMembers(true) setIsRefreshingGroupMembers(true)
setGroupMembersLoadingHint('') setGroupMembersLoadingHint('')
setIsLoadingGroupMembers(false) setIsLoadingGroupMembers(false)
@@ -1230,6 +1349,7 @@ function ChatPage(_props: ChatPageProps) {
}, [ }, [
getGroupMembersPanelDataWithTimeout, getGroupMembersPanelDataWithTimeout,
isGroupChatSession, isGroupChatSession,
syncGroupMyMessagesFromMembers,
normalizeGroupPanelMembers, normalizeGroupPanelMembers,
updateGroupMembersPanelCache updateGroupMembersPanelCache
]) ])
@@ -1267,6 +1387,13 @@ function ChatPage(_props: ChatPageProps) {
void loadGroupMembersPanel(currentSessionId) void loadGroupMembersPanel(currentSessionId)
}, [showGroupMembersPanel, currentSessionId, loadGroupMembersPanel, isGroupChatSession]) }, [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) => { const handleCopyField = useCallback(async (text: string, field: string) => {
try { try {

View File

@@ -876,7 +876,6 @@ function ExportPage() {
totalFriends: 0 totalFriends: 0
}) })
const [contentSessionCounts, setContentSessionCounts] = useState<ExportContentSessionCountsSummary>(defaultContentSessionCounts) const [contentSessionCounts, setContentSessionCounts] = useState<ExportContentSessionCountsSummary>(defaultContentSessionCounts)
const [isContentSessionCountsLoading, setIsContentSessionCountsLoading] = useState(true)
const [hasSeededContentSessionCounts, setHasSeededContentSessionCounts] = useState(false) const [hasSeededContentSessionCounts, setHasSeededContentSessionCounts] = useState(false)
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
const [nowTick, setNowTick] = useState(Date.now()) const [nowTick, setNowTick] = useState(Date.now())
@@ -904,6 +903,7 @@ function ExportPage() {
const inProgressSessionIdsRef = useRef<string[]>([]) const inProgressSessionIdsRef = useRef<string[]>([])
const activeTaskCountRef = useRef(0) const activeTaskCountRef = useRef(0)
const hasBaseConfigReadyRef = useRef(false) const hasBaseConfigReadyRef = useRef(false)
const contentSessionCountsForceRetryRef = useRef(0)
const ensureExportCacheScope = useCallback(async (): Promise<string> => { const ensureExportCacheScope = useCallback(async (): Promise<string> => {
if (exportCacheScopeReadyRef.current) { if (exportCacheScopeReadyRef.current) {
@@ -1413,9 +1413,6 @@ function ExportPage() {
}, []) }, [])
const loadContentSessionCounts = useCallback(async (options?: { silent?: boolean; forceRefresh?: boolean }) => { const loadContentSessionCounts = useCallback(async (options?: { silent?: boolean; forceRefresh?: boolean }) => {
if (!options?.silent) {
setIsContentSessionCountsLoading(true)
}
try { try {
const result = await withTimeout( const result = await withTimeout(
window.electronAPI.chat.getExportContentSessionCounts({ window.electronAPI.chat.getExportContentSessionCounts({
@@ -1437,14 +1434,23 @@ function ExportPage() {
refreshing: result.data.refreshing === true refreshing: result.data.refreshing === true
} }
setContentSessionCounts(next) 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) { } catch (error) {
console.error('加载导出内容会话统计失败:', error) console.error('加载导出内容会话统计失败:', error)
} finally {
if (!options?.silent) {
setIsContentSessionCountsLoading(false)
}
} }
}, []) }, [])
@@ -3205,7 +3211,7 @@ function ExportPage() {
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog const shouldShowMediaSection = !isContentScopeDialog
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
const isSessionCardStatsLoading = isBaseConfigLoading || (isContentSessionCountsLoading && !hasSeededContentSessionCounts) const isSessionCardStatsLoading = isBaseConfigLoading || !hasSeededContentSessionCounts
const isSessionCardStatsRefreshing = contentSessionCounts.refreshing || contentSessionCounts.pendingMediaSessions > 0 const isSessionCardStatsRefreshing = contentSessionCounts.refreshing || contentSessionCounts.pendingMediaSessions > 0
const isSnsCardStatsLoading = !hasSeededSnsStats const isSnsCardStatsLoading = !hasSeededSnsStats
const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskRunningCount = tasks.filter(task => task.status === 'running').length

View File

@@ -240,6 +240,13 @@ export interface ElectronAPI {
needsRefresh?: string[] needsRefresh?: string[]
error?: 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 }> 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 }> 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 }> getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>