From 580242b9d2b578cc363fc49fc185c9505fa487b0 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Wed, 4 Mar 2026 17:23:55 +0800 Subject: [PATCH] perf(export): persist session list stats across app restarts --- src/pages/ExportPage.tsx | 227 ++++++++++++++++++++++++++++++++++++--- src/services/config.ts | 96 +++++++++++++++++ 2 files changed, 310 insertions(+), 13 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 6e76f45..47dda93 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -461,6 +461,8 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString( const createExportDiagTraceId = (): string => `export-card-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 +const EXPORT_SESSION_MESSAGE_COUNT_CACHE_STALE_MS = 12 * 60 * 60 * 1000 +const EXPORT_SESSION_CONTENT_METRIC_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 const CONTACTS_LIST_VIRTUAL_ROW_HEIGHT = 76 const CONTACTS_LIST_VIRTUAL_OVERSCAN = 10 @@ -637,6 +639,14 @@ const withTimeout = async (promise: Promise, timeoutMs: number): Promise< } } +const normalizeTimestampToMs = (value: unknown): number => { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return 0 + if (value < 1000000000000) { + return Math.floor(value * 1000) + } + return Math.floor(value) +} + const toContactMapFromCaches = ( contacts: configService.ContactsListCacheContact[], avatarEntries: Record @@ -1551,7 +1561,11 @@ function ExportPage() { const loadSessionContentStats = useCallback(async ( sourceSessions: SessionRow[], priorityTab: ConversationTab, - resolvedMessageCounts?: Record + resolvedMessageCounts?: Record, + options?: { + scopeKey?: string + seededMetrics?: Record + } ) => { const requestId = sessionContentStatsRequestIdRef.current + 1 sessionContentStatsRequestIdRef.current = requestId @@ -1603,6 +1617,33 @@ function ExportPage() { const total = orderedSessionIds.length const processedSessionIds = new Set() + const mergedMetrics: Record = {} + for (const [sessionId, metricRaw] of Object.entries(options?.seededMetrics || {})) { + const metric: configService.ExportSessionContentMetricCacheEntry = {} + const totalMessages = normalizeMessageCount(metricRaw.totalMessages) + const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages) + const imageMessages = normalizeMessageCount(metricRaw.imageMessages) + const videoMessages = normalizeMessageCount(metricRaw.videoMessages) + const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages) + if (typeof totalMessages === 'number') metric.totalMessages = totalMessages + if (typeof voiceMessages === 'number') metric.voiceMessages = voiceMessages + if (typeof imageMessages === 'number') metric.imageMessages = imageMessages + if (typeof videoMessages === 'number') metric.videoMessages = videoMessages + if (typeof emojiMessages === 'number') metric.emojiMessages = emojiMessages + if (Object.keys(metric).length > 0) { + mergedMetrics[sessionId] = metric + } + } + + for (const [sessionId, countRaw] of Object.entries(resolvedMessageCounts || {})) { + const count = normalizeMessageCount(countRaw) + if (typeof count !== 'number') continue + mergedMetrics[sessionId] = { + ...(mergedMetrics[sessionId] || {}), + totalMessages: count + } + } + const markChunkProcessed = (chunk: string[]) => { for (const sessionId of chunk) { processedSessionIds.add(sessionId) @@ -1624,6 +1665,25 @@ function ExportPage() { if (isStale()) return if (result?.success && result.data) { mergeSessionContentMetrics(result.data as Record) + for (const [sessionId, metricRaw] of Object.entries(result.data as Record)) { + if (!metricRaw) continue + const metric: configService.ExportSessionContentMetricCacheEntry = {} + const totalMessages = normalizeMessageCount(metricRaw.totalMessages) + const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages) + const imageMessages = normalizeMessageCount(metricRaw.imageMessages) + const videoMessages = normalizeMessageCount(metricRaw.videoMessages) + const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages) + if (typeof totalMessages === 'number') metric.totalMessages = totalMessages + if (typeof voiceMessages === 'number') metric.voiceMessages = voiceMessages + if (typeof imageMessages === 'number') metric.imageMessages = imageMessages + if (typeof videoMessages === 'number') metric.videoMessages = videoMessages + if (typeof emojiMessages === 'number') metric.emojiMessages = emojiMessages + if (Object.keys(metric).length === 0) continue + mergedMetrics[sessionId] = { + ...(mergedMetrics[sessionId] || {}), + ...metric + } + } } markChunkProcessed(chunk) } @@ -1664,13 +1724,24 @@ function ExportPage() { if (!isStale()) { setSessionContentStatsProgress({ completed: processedSessionIds.size, total }) setIsLoadingSessionContentStats(false) + if (options?.scopeKey && Object.keys(mergedMetrics).length > 0) { + try { + await configService.setExportSessionContentMetricCache(options.scopeKey, mergedMetrics) + } catch (cacheError) { + console.error('写入导出页会话媒体统计缓存失败:', cacheError) + } + } } } }, [mergeSessionContentMetrics]) const loadSessionMessageCounts = useCallback(async ( sourceSessions: SessionRow[], - priorityTab: ConversationTab + priorityTab: ConversationTab, + options?: { + scopeKey?: string + seededCounts?: Record + } ): Promise> => { const requestId = sessionCountRequestIdRef.current + 1 sessionCountRequestIdRef.current = requestId @@ -1684,11 +1755,19 @@ function ExportPage() { } return acc }, {}) - const accumulatedCounts: Record = { ...seededHintCounts } - setSessionMessageCounts(seededHintCounts) - if (Object.keys(seededHintCounts).length > 0) { + const seededPersistentCounts = Object.entries(options?.seededCounts || {}).reduce>((acc, [sessionId, countRaw]) => { + const nextCount = normalizeMessageCount(countRaw) + if (typeof nextCount === 'number') { + acc[sessionId] = nextCount + } + return acc + }, {}) + const seededCounts = { ...seededHintCounts, ...seededPersistentCounts } + const accumulatedCounts: Record = { ...seededCounts } + setSessionMessageCounts(seededCounts) + if (Object.keys(seededCounts).length > 0) { mergeSessionContentMetrics( - Object.entries(seededHintCounts).reduce>((acc, [sessionId, count]) => { + Object.entries(seededCounts).reduce>((acc, [sessionId, count]) => { acc[sessionId] = { totalMessages: count } return acc }, {}) @@ -1700,11 +1779,20 @@ function ExportPage() { return { ...accumulatedCounts } } - const prioritizedSessionIds = exportableSessions + const pendingSessions = exportableSessions.filter((session) => { + const nextCount = normalizeMessageCount(accumulatedCounts[session.username]) + return typeof nextCount !== 'number' + }) + if (pendingSessions.length === 0) { + setIsLoadingSessionCounts(false) + return { ...accumulatedCounts } + } + + const prioritizedSessionIds = pendingSessions .filter(session => session.kind === priorityTab) .map(session => session.username) const prioritizedSet = new Set(prioritizedSessionIds) - const remainingSessionIds = exportableSessions + const remainingSessionIds = pendingSessions .filter(session => !prioritizedSet.has(session.username)) .map(session => session.username) @@ -1752,6 +1840,13 @@ function ExportPage() { } finally { if (!isStale()) { setIsLoadingSessionCounts(false) + if (options?.scopeKey && Object.keys(accumulatedCounts).length > 0) { + try { + await configService.setExportSessionMessageCountCache(options.scopeKey, accumulatedCounts) + } catch (cacheError) { + console.error('写入导出页会话总消息缓存失败:', cacheError) + } + } } } return { ...accumulatedCounts } @@ -1777,11 +1872,21 @@ function ExportPage() { const scopeKey = await ensureExportCacheScope() if (isStale()) return + const [ + cachedContactsPayload, + cachedMessageCountsPayload, + cachedContentMetricsPayload + ] = await Promise.all([ + loadContactsCaches(scopeKey), + configService.getExportSessionMessageCountCache(scopeKey), + configService.getExportSessionContentMetricCache(scopeKey) + ]) + if (isStale()) return + const { contactsItem: cachedContactsItem, avatarItem: cachedAvatarItem - } = await loadContactsCaches(scopeKey) - if (isStale()) return + } = cachedContactsPayload const cachedContacts = cachedContactsItem?.contacts || [] const cachedAvatarEntries = cachedAvatarItem?.avatars || {} @@ -1808,14 +1913,110 @@ function ExportPage() { if (sessionsResult.success && sessionsResult.sessions) { const rawSessions = sessionsResult.sessions const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap) + const exportableSessionIds = baseSessions + .filter((session) => session.hasSession) + .map((session) => session.username) + const exportableSessionIdSet = new Set(exportableSessionIds) + + const cachedMessageCounts = Object.entries(cachedMessageCountsPayload?.counts || {}).reduce>((acc, [sessionId, countRaw]) => { + if (!exportableSessionIdSet.has(sessionId)) return acc + const nextCount = normalizeMessageCount(countRaw) + if (typeof nextCount === 'number') { + acc[sessionId] = nextCount + } + return acc + }, {}) + + const cachedContentMetrics = Object.entries(cachedContentMetricsPayload?.metrics || {}).reduce>((acc, [sessionId, metricRaw]) => { + if (!exportableSessionIdSet.has(sessionId)) return acc + if (!metricRaw || typeof metricRaw !== 'object') return acc + const metric: SessionContentMetric = {} + const totalMessages = normalizeMessageCount(metricRaw.totalMessages) + const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages) + const imageMessages = normalizeMessageCount(metricRaw.imageMessages) + const videoMessages = normalizeMessageCount(metricRaw.videoMessages) + const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages) + if (typeof totalMessages === 'number') metric.totalMessages = totalMessages + if (typeof voiceMessages === 'number') metric.voiceMessages = voiceMessages + if (typeof imageMessages === 'number') metric.imageMessages = imageMessages + if (typeof videoMessages === 'number') metric.videoMessages = videoMessages + if (typeof emojiMessages === 'number') metric.emojiMessages = emojiMessages + if (Object.keys(metric).length > 0) { + acc[sessionId] = metric + } + return acc + }, {}) + + const cachedCountAsMetrics = Object.entries(cachedMessageCounts).reduce>((acc, [sessionId, count]) => { + if (typeof normalizeMessageCount(cachedContentMetrics[sessionId]?.totalMessages) === 'number') return acc + acc[sessionId] = { totalMessages: count } + return acc + }, {}) + + const latestSessionActivityMs = baseSessions.reduce((maxTs, session) => { + const activityTs = normalizeTimestampToMs(session.sortTimestamp || session.lastTimestamp || 0) + return Math.max(maxTs, activityTs) + }, 0) + const messageCountCacheUpdatedAt = normalizeTimestampToMs(cachedMessageCountsPayload?.updatedAt || 0) + const contentMetricCacheUpdatedAt = normalizeTimestampToMs(cachedContentMetricsPayload?.updatedAt || 0) + const isMessageCountCacheFresh = ( + messageCountCacheUpdatedAt > 0 && + Date.now() - messageCountCacheUpdatedAt <= EXPORT_SESSION_MESSAGE_COUNT_CACHE_STALE_MS && + latestSessionActivityMs <= messageCountCacheUpdatedAt + ) + const isContentMetricCacheFresh = ( + contentMetricCacheUpdatedAt > 0 && + Date.now() - contentMetricCacheUpdatedAt <= EXPORT_SESSION_CONTENT_METRIC_CACHE_STALE_MS && + latestSessionActivityMs <= contentMetricCacheUpdatedAt + ) + const hasMessageCountCoverage = exportableSessionIds.every((sessionId) => ( + typeof normalizeMessageCount(cachedMessageCounts[sessionId]) === 'number' + )) + const hasContentMetricCoverage = exportableSessionIds.every((sessionId) => { + const metric = cachedContentMetrics[sessionId] + if (!metric) return false + return ( + typeof normalizeMessageCount(metric.imageMessages) === 'number' && + typeof normalizeMessageCount(metric.voiceMessages) === 'number' && + typeof normalizeMessageCount(metric.emojiMessages) === 'number' && + typeof normalizeMessageCount(metric.videoMessages) === 'number' + ) + }) if (isStale()) return + if (Object.keys(cachedMessageCounts).length > 0) { + setSessionMessageCounts(cachedMessageCounts) + } + if (Object.keys(cachedContentMetrics).length > 0) { + mergeSessionContentMetrics(cachedContentMetrics) + } + if (Object.keys(cachedCountAsMetrics).length > 0) { + mergeSessionContentMetrics(cachedCountAsMetrics) + } setSessions(baseSessions) sessionsHydratedAtRef.current = Date.now() void (async () => { - const resolvedMessageCounts = await loadSessionMessageCounts(baseSessions, activeTabRef.current) + let resolvedMessageCounts = { ...cachedMessageCounts } + const shouldRefreshMessageCounts = !isMessageCountCacheFresh || !hasMessageCountCoverage + if (shouldRefreshMessageCounts) { + resolvedMessageCounts = await loadSessionMessageCounts(baseSessions, activeTabRef.current, { + scopeKey, + seededCounts: cachedMessageCounts + }) + } else { + setIsLoadingSessionCounts(false) + } if (isStale()) return - await loadSessionContentStats(baseSessions, activeTabRef.current, resolvedMessageCounts) + const shouldRefreshContentStats = !isContentMetricCacheFresh || !hasContentMetricCoverage + if (shouldRefreshContentStats) { + await loadSessionContentStats(baseSessions, activeTabRef.current, resolvedMessageCounts, { + scopeKey, + seededMetrics: cachedContentMetrics + }) + } else { + setIsLoadingSessionContentStats(false) + setSessionContentStatsProgress({ completed: 0, total: 0 }) + } })() setSessionDataSource(cachedContacts.length > 0 ? 'cache' : 'network') if (cachedContacts.length === 0) { @@ -2005,7 +2206,7 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [ensureExportCacheScope, loadContactsCaches, loadSessionContentStats, loadSessionMessageCounts, syncContactTypeCounts]) + }, [ensureExportCacheScope, loadContactsCaches, loadSessionContentStats, loadSessionMessageCounts, mergeSessionContentMetrics, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return diff --git a/src/services/config.ts b/src/services/config.ts index 7625ca1..7dbe239 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -37,6 +37,7 @@ export const CONFIG_KEYS = { EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount', EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', + EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap', EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', SNS_PAGE_CACHE_MAP: 'snsPageCacheMap', CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs', @@ -460,6 +461,19 @@ export interface ExportSessionMessageCountCacheItem { counts: Record } +export interface ExportSessionContentMetricCacheEntry { + totalMessages?: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number +} + +export interface ExportSessionContentMetricCacheItem { + updatedAt: number + metrics: Record +} + export interface ExportSnsStatsCacheItem { updatedAt: number totalPosts: number @@ -550,6 +564,88 @@ export async function setExportSessionMessageCountCache(scopeKey: string, counts await config.set(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP, map) } +export async function getExportSessionContentMetricCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const rawUpdatedAt = (rawItem as Record).updatedAt + const rawMetrics = (rawItem as Record).metrics + if (!rawMetrics || typeof rawMetrics !== 'object') return null + + const metrics: Record = {} + for (const [sessionId, rawMetric] of Object.entries(rawMetrics as Record)) { + if (!rawMetric || typeof rawMetric !== 'object') continue + const source = rawMetric as Record + const metric: ExportSessionContentMetricCacheEntry = {} + if (typeof source.totalMessages === 'number' && Number.isFinite(source.totalMessages) && source.totalMessages >= 0) { + metric.totalMessages = Math.floor(source.totalMessages) + } + if (typeof source.voiceMessages === 'number' && Number.isFinite(source.voiceMessages) && source.voiceMessages >= 0) { + metric.voiceMessages = Math.floor(source.voiceMessages) + } + if (typeof source.imageMessages === 'number' && Number.isFinite(source.imageMessages) && source.imageMessages >= 0) { + metric.imageMessages = Math.floor(source.imageMessages) + } + if (typeof source.videoMessages === 'number' && Number.isFinite(source.videoMessages) && source.videoMessages >= 0) { + metric.videoMessages = Math.floor(source.videoMessages) + } + if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) { + metric.emojiMessages = Math.floor(source.emojiMessages) + } + if (Object.keys(metric).length === 0) continue + metrics[sessionId] = metric + } + + return { + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + metrics + } +} + +export async function setExportSessionContentMetricCache( + scopeKey: string, + metrics: Record +): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: Record = {} + for (const [sessionId, rawMetric] of Object.entries(metrics || {})) { + if (!rawMetric || typeof rawMetric !== 'object') continue + const metric: ExportSessionContentMetricCacheEntry = {} + if (typeof rawMetric.totalMessages === 'number' && Number.isFinite(rawMetric.totalMessages) && rawMetric.totalMessages >= 0) { + metric.totalMessages = Math.floor(rawMetric.totalMessages) + } + if (typeof rawMetric.voiceMessages === 'number' && Number.isFinite(rawMetric.voiceMessages) && rawMetric.voiceMessages >= 0) { + metric.voiceMessages = Math.floor(rawMetric.voiceMessages) + } + if (typeof rawMetric.imageMessages === 'number' && Number.isFinite(rawMetric.imageMessages) && rawMetric.imageMessages >= 0) { + metric.imageMessages = Math.floor(rawMetric.imageMessages) + } + if (typeof rawMetric.videoMessages === 'number' && Number.isFinite(rawMetric.videoMessages) && rawMetric.videoMessages >= 0) { + metric.videoMessages = Math.floor(rawMetric.videoMessages) + } + if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) { + metric.emojiMessages = Math.floor(rawMetric.emojiMessages) + } + if (Object.keys(metric).length === 0) continue + normalized[sessionId] = metric + } + + map[scopeKey] = { + updatedAt: Date.now(), + metrics: normalized + } + await config.set(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP, map) +} + export async function getExportSnsStatsCache(scopeKey: string): Promise { if (!scopeKey) return null const value = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)