From b6878aefd6933b10405f4d4b5d69c25319e7d69b Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 19:07:17 +0800 Subject: [PATCH] feat(export): fast accurate content session counts on cards --- electron/main.ts | 13 + electron/preload.ts | 4 + electron/services/chatService.ts | 351 ++++++++++++++++++ .../exportContentStatsCacheService.ts | 229 ++++++++++++ src/pages/ExportPage.scss | 13 + src/pages/ExportPage.tsx | 94 ++++- src/types/electron.d.ts | 22 ++ 7 files changed, 721 insertions(+), 5 deletions(-) create mode 100644 electron/services/exportContentStatsCacheService.ts diff --git a/electron/main.ts b/electron/main.ts index 1062f49..be7fd80 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -970,6 +970,19 @@ function registerIpcHandlers() { return chatService.getSessionMessageCounts(sessionIds) }) + ipcMain.handle('chat:getExportContentSessionCounts', async (_, options?: { + triggerRefresh?: boolean + forceRefresh?: boolean + }) => { + return chatService.getExportContentSessionCounts(options) + }) + + ipcMain.handle('chat:refreshExportContentSessionCounts', async (_, options?: { + forceRefresh?: boolean + }) => { + return chatService.refreshExportContentSessionCounts(options) + }) + ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { return chatService.enrichSessionsContactInfo(usernames) }) diff --git a/electron/preload.ts b/electron/preload.ts index cdcde72..ddb999a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -134,6 +134,10 @@ contextBridge.exposeInMainWorld('electronAPI', { getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), + getExportContentSessionCounts: (options?: { triggerRefresh?: boolean; forceRefresh?: boolean }) => + ipcRenderer.invoke('chat:getExportContentSessionCounts', options), + refreshExportContentSessionCounts: (options?: { forceRefresh?: boolean }) => + ipcRenderer.invoke('chat:refreshExportContentSessionCounts', options), enrichSessionsContactInfo: (usernames: string[]) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 58b0e78..65b4308 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -13,6 +13,11 @@ import { wcdbService } from './wcdbService' import { MessageCacheService } from './messageCacheService' import { ContactCacheService, ContactCacheEntry } from './contactCacheService' import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService' +import { + ExportContentScopeStatsEntry, + ExportContentSessionStatsEntry, + ExportContentStatsCacheService +} from './exportContentStatsCacheService' import { voiceTranscribeService } from './voiceTranscribeService' import { LRUCache } from '../utils/LRUCache.js' @@ -166,6 +171,18 @@ interface ExportSessionStatsCacheMeta { source: 'memory' | 'disk' | 'fresh' } +interface ExportContentSessionCounts { + totalSessions: number + textSessions: number + voiceSessions: number + imageSessions: number + videoSessions: number + emojiSessions: number + pendingMediaSessions: number + updatedAt: number + refreshing: boolean +} + interface ExportTabCounts { private: number group: number @@ -209,6 +226,7 @@ class ChatService { private readonly contactCacheService: ContactCacheService private readonly messageCacheService: MessageCacheService private readonly sessionStatsCacheService: SessionStatsCacheService + private readonly exportContentStatsCacheService: ExportContentStatsCacheService private voiceWavCache: LRUCache private voiceTranscriptCache: LRUCache private voiceTranscriptPending = new Map>() @@ -247,6 +265,15 @@ class ChatService { private allGroupSessionIdsCache: { ids: string[]; updatedAt: number } | null = null private readonly sessionStatsCacheTtlMs = 10 * 60 * 1000 private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000 + private exportContentStatsScope = '' + private exportContentStatsMemory = new Map() + private exportContentStatsScopeUpdatedAt = 0 + private exportContentStatsRefreshPromise: Promise | null = null + private exportContentStatsRefreshQueued = false + private exportContentStatsRefreshForceQueued = false + private exportContentStatsDirtySessionIds = new Set() + private exportContentScopeSessionIdsCache: { ids: string[]; updatedAt: number } | null = null + private readonly exportContentScopeSessionIdsCacheTtlMs = 60 * 1000 constructor() { this.configService = new ConfigService() @@ -255,6 +282,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.exportContentStatsCacheService = new ExportContentStatsCacheService(this.configService.getCacheBasePath()) // 初始化LRU缓存,限制大小防止内存泄漏 this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries) this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录 @@ -325,6 +353,8 @@ class ChatService { // 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接) this.warmupMediaDbsCache() + // 预热导出内容会话统计缓存(后台异步,不阻塞连接) + void this.startExportContentStatsRefresh(false) return { success: true } } catch (e) { @@ -393,6 +423,10 @@ class ChatService { console.error('ChatService: 关闭数据库失败:', e) } this.connected = false + this.exportContentStatsRefreshPromise = null + this.exportContentStatsRefreshQueued = false + this.exportContentStatsRefreshForceQueued = false + this.exportContentScopeSessionIdsCache = null } /** @@ -1584,6 +1618,7 @@ class ChatService { const scope = `${dbPath}::${myWxid}` if (scope === this.sessionMessageCountCacheScope) { this.refreshSessionStatsCacheScope(scope) + this.refreshExportContentStatsScope(scope) return } this.sessionMessageCountCacheScope = scope @@ -1593,6 +1628,311 @@ class ChatService { this.sessionDetailExtraCache.clear() this.sessionStatusCache.clear() this.refreshSessionStatsCacheScope(scope) + this.refreshExportContentStatsScope(scope) + } + + private refreshExportContentStatsScope(scope: string): void { + if (scope === this.exportContentStatsScope) return + this.exportContentStatsScope = scope + this.exportContentStatsMemory.clear() + this.exportContentStatsDirtySessionIds.clear() + this.exportContentScopeSessionIdsCache = null + const scopeEntry = this.exportContentStatsCacheService.getScope(scope) + if (scopeEntry) { + this.exportContentStatsScopeUpdatedAt = scopeEntry.updatedAt + for (const [sessionId, entry] of Object.entries(scopeEntry.sessions)) { + this.exportContentStatsMemory.set(sessionId, { ...entry }) + } + } else { + this.exportContentStatsScopeUpdatedAt = 0 + } + } + + private persistExportContentStatsScope(validSessionIds?: Set): void { + if (!this.exportContentStatsScope) return + const sessions: Record = {} + for (const [sessionId, entry] of this.exportContentStatsMemory.entries()) { + if (validSessionIds && !validSessionIds.has(sessionId)) continue + sessions[sessionId] = { ...entry } + } + + const updatedAt = this.exportContentStatsScopeUpdatedAt || Date.now() + const scopeEntry: ExportContentScopeStatsEntry = { + updatedAt, + sessions + } + this.exportContentStatsCacheService.setScope(this.exportContentStatsScope, scopeEntry) + } + + private async listExportContentScopeSessionIds(force = false): Promise { + const now = Date.now() + if ( + !force && + this.exportContentScopeSessionIdsCache && + now - this.exportContentScopeSessionIdsCache.updatedAt <= this.exportContentScopeSessionIdsCacheTtlMs + ) { + return this.exportContentScopeSessionIdsCache.ids + } + + const sessionsResult = await this.getSessions() + if (!sessionsResult.success || !sessionsResult.sessions) { + return [] + } + + const ids = Array.from( + new Set( + sessionsResult.sessions + .map((session) => String(session.username || '').trim()) + .filter(Boolean) + .filter((sessionId) => sessionId.endsWith('@chatroom') || !sessionId.startsWith('gh_')) + ) + ) + + this.exportContentScopeSessionIdsCache = { + ids, + updatedAt: now + } + return ids + } + + private createDefaultExportContentEntry(): ExportContentSessionStatsEntry { + return { + updatedAt: 0, + hasAny: false, + hasVoice: false, + hasImage: false, + hasVideo: false, + hasEmoji: false, + mediaReady: false + } + } + + private isExportContentEntryDirty(sessionId: string): boolean { + return this.exportContentStatsDirtySessionIds.has(sessionId) + } + + private async collectExportContentEntry(sessionId: string): Promise { + const entry = this.createDefaultExportContentEntry() + const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 400, false, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return { + ...entry, + updatedAt: Date.now(), + mediaReady: true + } + } + + const cursor = cursorResult.cursor + try { + let done = false + while (!done) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + break + } + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + for (const row of rows) { + entry.hasAny = true + const localType = this.getRowInt( + row, + ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], + 1 + ) + if (localType === 34) entry.hasVoice = true + if (localType === 3) entry.hasImage = true + if (localType === 43) entry.hasVideo = true + if (localType === 47) entry.hasEmoji = true + + if (entry.hasVoice && entry.hasImage && entry.hasVideo && entry.hasEmoji) { + done = true + break + } + } + + if (!batch.hasMore || rows.length === 0) { + break + } + } + } finally { + await wcdbService.closeMessageCursor(cursor) + } + + entry.mediaReady = true + entry.updatedAt = Date.now() + return entry + } + + private async startExportContentStatsRefresh(force = false): Promise { + if (this.exportContentStatsRefreshPromise) { + this.exportContentStatsRefreshQueued = true + this.exportContentStatsRefreshForceQueued = this.exportContentStatsRefreshForceQueued || force + return this.exportContentStatsRefreshPromise + } + + const task = (async () => { + const sessionIds = await this.listExportContentScopeSessionIds(force) + const sessionIdSet = new Set(sessionIds) + const targets: string[] = [] + + for (const sessionId of sessionIds) { + const cached = this.exportContentStatsMemory.get(sessionId) + if (force || this.exportContentStatsDirtySessionIds.has(sessionId) || !cached || !cached.mediaReady) { + targets.push(sessionId) + } + } + + 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) + }) + } + + for (const sessionId of Array.from(this.exportContentStatsMemory.keys())) { + if (!sessionIdSet.has(sessionId)) { + this.exportContentStatsMemory.delete(sessionId) + this.exportContentStatsDirtySessionIds.delete(sessionId) + } + } + + this.exportContentStatsScopeUpdatedAt = Date.now() + this.persistExportContentStatsScope(sessionIdSet) + })() + + this.exportContentStatsRefreshPromise = task + try { + await task + } finally { + this.exportContentStatsRefreshPromise = null + if (this.exportContentStatsRefreshQueued) { + const rerunForce = this.exportContentStatsRefreshForceQueued + this.exportContentStatsRefreshQueued = false + this.exportContentStatsRefreshForceQueued = false + void this.startExportContentStatsRefresh(rerunForce) + } + } + } + + async getExportContentSessionCounts(options?: { + triggerRefresh?: boolean + forceRefresh?: boolean + }): Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + this.refreshSessionMessageCountCacheScope() + + const forceRefresh = options?.forceRefresh === true + const triggerRefresh = options?.triggerRefresh !== false + const sessionIds = await this.listExportContentScopeSessionIds(forceRefresh) + const sessionIdSet = new Set(sessionIds) + + for (const sessionId of Array.from(this.exportContentStatsMemory.keys())) { + if (!sessionIdSet.has(sessionId)) { + this.exportContentStatsMemory.delete(sessionId) + this.exportContentStatsDirtySessionIds.delete(sessionId) + } + } + + const missingTextCountSessionIds: string[] = [] + let textSessions = 0 + let voiceSessions = 0 + let imageSessions = 0 + let videoSessions = 0 + let emojiSessions = 0 + const pendingMediaSessionSet = new Set() + + for (const sessionId of sessionIds) { + const entry = this.exportContentStatsMemory.get(sessionId) + if (entry) { + if (entry.hasAny) { + textSessions += 1 + } else if (this.isExportContentEntryDirty(sessionId)) { + missingTextCountSessionIds.push(sessionId) + } + } else { + missingTextCountSessionIds.push(sessionId) + } + + const hasMediaSnapshot = Boolean(entry && entry.mediaReady) + if (hasMediaSnapshot) { + if (entry!.hasVoice) voiceSessions += 1 + if (entry!.hasImage) imageSessions += 1 + if (entry!.hasVideo) videoSessions += 1 + if (entry!.hasEmoji) emojiSessions += 1 + } else { + pendingMediaSessionSet.add(sessionId) + } + + if (this.isExportContentEntryDirty(sessionId) && hasMediaSnapshot) { + pendingMediaSessionSet.add(sessionId) + } + } + + if (missingTextCountSessionIds.length > 0) { + const textCountResult = await this.getSessionMessageCounts(missingTextCountSessionIds) + if (textCountResult.success && textCountResult.counts) { + const now = Date.now() + for (const sessionId of missingTextCountSessionIds) { + const count = textCountResult.counts[sessionId] + const hasAny = Number.isFinite(count) && Number(count) > 0 + const prevEntry = this.exportContentStatsMemory.get(sessionId) || this.createDefaultExportContentEntry() + const nextEntry: ExportContentSessionStatsEntry = { + ...prevEntry, + hasAny, + updatedAt: prevEntry.updatedAt || now + } + this.exportContentStatsMemory.set(sessionId, nextEntry) + if (hasAny) { + textSessions += 1 + } + } + this.persistExportContentStatsScope(sessionIdSet) + } + } + + if (forceRefresh && triggerRefresh) { + void this.startExportContentStatsRefresh(true) + } else if (triggerRefresh && (pendingMediaSessionSet.size > 0 || this.exportContentStatsDirtySessionIds.size > 0)) { + void this.startExportContentStatsRefresh(false) + } + + return { + success: true, + data: { + totalSessions: sessionIds.length, + textSessions, + voiceSessions, + imageSessions, + videoSessions, + emojiSessions, + pendingMediaSessions: pendingMediaSessionSet.size, + updatedAt: this.exportContentStatsScopeUpdatedAt, + refreshing: this.exportContentStatsRefreshPromise !== null + } + } + } catch (e) { + console.error('ChatService: 获取导出内容会话统计失败:', e) + return { success: false, error: String(e) } + } + } + + async refreshExportContentSessionCounts(options?: { forceRefresh?: boolean }): Promise<{ success: boolean; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + this.refreshSessionMessageCountCacheScope() + await this.startExportContentStatsRefresh(options?.forceRefresh === true) + return { success: true } + } catch (e) { + console.error('ChatService: 刷新导出内容会话统计失败:', e) + return { success: false, error: String(e) } + } } private refreshSessionStatsCacheScope(scope: string): void { @@ -1741,6 +2081,8 @@ class ChatService { if (ids.size > 0) { ids.forEach((sessionId) => this.deleteSessionStatsCacheEntry(sessionId)) + this.exportContentScopeSessionIdsCache = null + ids.forEach((sessionId) => this.exportContentStatsDirtySessionIds.add(sessionId)) if (Array.from(ids).some((id) => id.includes('@chatroom'))) { this.allGroupSessionIdsCache = null } @@ -1756,6 +2098,10 @@ class ChatService { normalizedType.includes('contact') ) { this.clearSessionStatsCacheForScope() + this.exportContentScopeSessionIdsCache = null + for (const sessionId of this.exportContentStatsMemory.keys()) { + this.exportContentStatsDirtySessionIds.add(sessionId) + } } } @@ -3918,6 +4264,11 @@ class ChatService { this.sessionStatsPendingFull.clear() this.allGroupSessionIdsCache = null this.sessionStatsCacheService.clearAll() + this.exportContentStatsMemory.clear() + this.exportContentStatsDirtySessionIds.clear() + this.exportContentScopeSessionIdsCache = null + this.exportContentStatsScopeUpdatedAt = 0 + this.exportContentStatsCacheService.clearAll() } for (const state of this.hardlinkCache.values()) { diff --git a/electron/services/exportContentStatsCacheService.ts b/electron/services/exportContentStatsCacheService.ts new file mode 100644 index 0000000..ee8fd5f --- /dev/null +++ b/electron/services/exportContentStatsCacheService.ts @@ -0,0 +1,229 @@ +import { join, dirname } from 'path' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { ConfigService } from './config' + +const CACHE_VERSION = 1 +const MAX_SCOPE_ENTRIES = 12 +const MAX_SESSION_ENTRIES_PER_SCOPE = 6000 + +export interface ExportContentSessionStatsEntry { + updatedAt: number + hasAny: boolean + hasVoice: boolean + hasImage: boolean + hasVideo: boolean + hasEmoji: boolean + mediaReady: boolean +} + +export interface ExportContentScopeStatsEntry { + updatedAt: number + sessions: Record +} + +interface ExportContentStatsStore { + 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 toBoolean(value: unknown, fallback = false): boolean { + if (typeof value === 'boolean') return value + return fallback +} + +function normalizeSessionStatsEntry(raw: unknown): ExportContentSessionStatsEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + if (updatedAt === undefined) return null + return { + updatedAt, + hasAny: toBoolean(source.hasAny, false), + hasVoice: toBoolean(source.hasVoice, false), + hasImage: toBoolean(source.hasImage, false), + hasVideo: toBoolean(source.hasVideo, false), + hasEmoji: toBoolean(source.hasEmoji, false), + mediaReady: toBoolean(source.mediaReady, false) + } +} + +function normalizeScopeStatsEntry(raw: unknown): ExportContentScopeStatsEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + if (updatedAt === undefined) return null + + const sessionsRaw = source.sessions + if (!sessionsRaw || typeof sessionsRaw !== 'object') { + return { + updatedAt, + sessions: {} + } + } + + const sessions: Record = {} + for (const [sessionId, entryRaw] of Object.entries(sessionsRaw as Record)) { + const normalized = normalizeSessionStatsEntry(entryRaw) + if (!normalized) continue + sessions[sessionId] = normalized + } + + return { + updatedAt, + sessions + } +} + +function cloneScope(scope: ExportContentScopeStatsEntry): ExportContentScopeStatsEntry { + return { + updatedAt: scope.updatedAt, + sessions: Object.fromEntries( + Object.entries(scope.sessions).map(([sessionId, entry]) => [sessionId, { ...entry }]) + ) + } +} + +export class ExportContentStatsCacheService { + private readonly cacheFilePath: string + private store: ExportContentStatsStore = { + version: CACHE_VERSION, + scopes: {} + } + + constructor(cacheBasePath?: string) { + const basePath = cacheBasePath && cacheBasePath.trim().length > 0 + ? cacheBasePath + : ConfigService.getInstance().getCacheBasePath() + this.cacheFilePath = join(basePath, 'export-content-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, scopeRaw] of Object.entries(scopesRaw as Record)) { + const normalizedScope = normalizeScopeStatsEntry(scopeRaw) + if (!normalizedScope) continue + scopes[scopeKey] = normalizedScope + } + + this.store = { + version: CACHE_VERSION, + scopes + } + } catch (error) { + console.error('ExportContentStatsCacheService: 载入缓存失败', error) + this.store = { version: CACHE_VERSION, scopes: {} } + } + } + + getScope(scopeKey: string): ExportContentScopeStatsEntry | undefined { + if (!scopeKey) return undefined + const rawScope = this.store.scopes[scopeKey] + if (!rawScope) return undefined + const normalizedScope = normalizeScopeStatsEntry(rawScope) + if (!normalizedScope) { + delete this.store.scopes[scopeKey] + this.persist() + return undefined + } + this.store.scopes[scopeKey] = normalizedScope + return cloneScope(normalizedScope) + } + + setScope(scopeKey: string, scope: ExportContentScopeStatsEntry): void { + if (!scopeKey) return + const normalized = normalizeScopeStatsEntry(scope) + if (!normalized) return + this.store.scopes[scopeKey] = normalized + this.trimScope(scopeKey) + this.trimScopes() + this.persist() + } + + deleteSession(scopeKey: string, sessionId: string): void { + if (!scopeKey || !sessionId) return + const scope = this.store.scopes[scopeKey] + if (!scope) return + if (!(sessionId in scope.sessions)) return + delete scope.sessions[sessionId] + if (Object.keys(scope.sessions).length === 0) { + delete this.store.scopes[scopeKey] + } else { + scope.updatedAt = Date.now() + } + 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('ExportContentStatsCacheService: 清理缓存失败', error) + } + } + + private trimScope(scopeKey: string): void { + const scope = this.store.scopes[scopeKey] + if (!scope) return + + const entries = Object.entries(scope.sessions) + if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return + + entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + scope.sessions = Object.fromEntries(entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) + } + + private trimScopes(): void { + const scopeEntries = Object.entries(this.store.scopes) + if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return + + scopeEntries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + this.store.scopes = Object.fromEntries(scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) + } + + private persist(): void { + try { + this.ensureCacheDir() + writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8') + } catch (error) { + console.error('ExportContentStatsCacheService: 持久化缓存失败', error) + } + } +} diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 22a2c06..290281f 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -304,6 +304,13 @@ flex-direction: column; gap: 8px; + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + .card-title { display: flex; align-items: center; @@ -313,6 +320,12 @@ font-weight: 600; } + .card-refresh-hint { + color: var(--text-tertiary); + font-size: 11px; + white-space: nowrap; + } + .card-stats { display: grid; grid-template-columns: 1fr; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 2b5812e..583a913 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -537,6 +537,30 @@ interface SessionExportCacheMeta { source: 'memory' | 'disk' | 'fresh' } +interface ExportContentSessionCountsSummary { + totalSessions: number + textSessions: number + voiceSessions: number + imageSessions: number + videoSessions: number + emojiSessions: number + pendingMediaSessions: number + updatedAt: number + refreshing: boolean +} + +const defaultContentSessionCounts: ExportContentSessionCountsSummary = { + totalSessions: 0, + textSessions: 0, + voiceSessions: 0, + imageSessions: 0, + videoSessions: 0, + emojiSessions: 0, + pendingMediaSessions: 0, + updatedAt: 0, + refreshing: false +} + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -851,6 +875,9 @@ function ExportPage() { totalPosts: 0, 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()) const tabCounts = useContactTypeCountsStore(state => state.tabCounts) @@ -1385,6 +1412,42 @@ 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({ + triggerRefresh: true, + forceRefresh: options?.forceRefresh === true + }), + 3200 + ) + if (result?.success && result.data) { + const next: ExportContentSessionCountsSummary = { + totalSessions: Number.isFinite(result.data.totalSessions) ? Math.max(0, Math.floor(result.data.totalSessions)) : 0, + textSessions: Number.isFinite(result.data.textSessions) ? Math.max(0, Math.floor(result.data.textSessions)) : 0, + voiceSessions: Number.isFinite(result.data.voiceSessions) ? Math.max(0, Math.floor(result.data.voiceSessions)) : 0, + imageSessions: Number.isFinite(result.data.imageSessions) ? Math.max(0, Math.floor(result.data.imageSessions)) : 0, + videoSessions: Number.isFinite(result.data.videoSessions) ? Math.max(0, Math.floor(result.data.videoSessions)) : 0, + emojiSessions: Number.isFinite(result.data.emojiSessions) ? Math.max(0, Math.floor(result.data.emojiSessions)) : 0, + pendingMediaSessions: Number.isFinite(result.data.pendingMediaSessions) ? Math.max(0, Math.floor(result.data.pendingMediaSessions)) : 0, + updatedAt: Number.isFinite(result.data.updatedAt) ? Math.max(0, Math.floor(result.data.updatedAt)) : 0, + refreshing: result.data.refreshing === true + } + setContentSessionCounts(next) + setHasSeededContentSessionCounts(true) + } + } catch (error) { + console.error('加载导出内容会话统计失败:', error) + } finally { + if (!options?.silent) { + setIsContentSessionCountsLoading(false) + } + } + }, []) + const loadSessions = useCallback(async () => { const loadToken = Date.now() sessionLoadTokenRef.current = loadToken @@ -1631,6 +1694,7 @@ function ExportPage() { void loadBaseConfig() void ensureSharedTabCountsLoaded() void loadSessions() + void loadContentSessionCounts({ forceRefresh: true }) // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { @@ -1638,7 +1702,15 @@ function ExportPage() { }, 120) return () => window.clearTimeout(timer) - }, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats]) + }, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats, loadContentSessionCounts]) + + useEffect(() => { + if (!isExportRoute) return + const timer = window.setInterval(() => { + void loadContentSessionCounts({ silent: true }) + }, 3000) + return () => window.clearInterval(timer) + }, [isExportRoute, loadContentSessionCounts]) useEffect(() => { if (isExportRoute) return @@ -2497,8 +2569,14 @@ function ExportPage() { const contentCards = useMemo(() => { const scopeSessions = sessions.filter(isContentScopeSession) - const totalSessions = tabCounts.private + tabCounts.group + tabCounts.former_friend const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts) + const contentSessionCountByType: Record = { + text: contentSessionCounts.textSessions, + voice: contentSessionCounts.voiceSessions, + image: contentSessionCounts.imageSessions, + video: contentSessionCounts.videoSessions, + emoji: contentSessionCounts.emojiSessions + } const sessionCards = [ { type: 'text' as ContentType, icon: MessageSquareText }, @@ -2518,7 +2596,7 @@ function ExportPage() { ...item, label: contentTypeLabels[item.type], stats: [ - { label: '总会话数', value: totalSessions }, + { label: '可导出会话数', value: contentSessionCountByType[item.type] || 0 }, { label: '已导出', value: exported } ] } @@ -2535,7 +2613,7 @@ function ExportPage() { } return [...sessionCards, snsCard] - }, [sessions, tabCounts, lastExportByContent, snsStats, lastSnsExportPostCount]) + }, [sessions, contentSessionCounts, lastExportByContent, snsStats, lastSnsExportPostCount]) const activeTabLabel = useMemo(() => { if (activeTab === 'private') return '私聊' @@ -3127,7 +3205,8 @@ function ExportPage() { const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowMediaSection = !isContentScopeDialog const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady - const isSessionCardStatsLoading = isBaseConfigLoading || (isSharedTabCountsLoading && !isSharedTabCountsReady) + const isSessionCardStatsLoading = isBaseConfigLoading || (isContentSessionCountsLoading && !hasSeededContentSessionCounts) + const isSessionCardStatsRefreshing = contentSessionCounts.refreshing || contentSessionCounts.pendingMediaSessions > 0 const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length @@ -3399,6 +3478,11 @@ function ExportPage() {
{card.label}
+ {card.type !== 'sns' && !isCardStatsLoading && isSessionCardStatsRefreshing && ( + + 刷新中 + + )}
{card.stats.map((stat) => ( diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e49273e..b937542 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -113,6 +113,28 @@ export interface ElectronAPI { counts?: Record error?: string }> + getExportContentSessionCounts: (options?: { + triggerRefresh?: boolean + forceRefresh?: boolean + }) => Promise<{ + success: boolean + data?: { + totalSessions: number + textSessions: number + voiceSessions: number + imageSessions: number + videoSessions: number + emojiSessions: number + pendingMediaSessions: number + updatedAt: number + refreshing: boolean + } + error?: string + }> + refreshExportContentSessionCounts: (options?: { forceRefresh?: boolean }) => Promise<{ + success: boolean + error?: string + }> enrichSessionsContactInfo: (usernames: string[]) => Promise<{ success: boolean contacts?: Record