From 285ddeb62eb1050ead38d06df1a66d9b2fca15b1 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Wed, 4 Mar 2026 16:12:24 +0800 Subject: [PATCH] perf(export): reduce reloads when switching back --- src/pages/ExportPage.scss | 64 +++++++- src/pages/ExportPage.tsx | 319 +++++++++++++++++++++++++++++++++++--- 2 files changed, 358 insertions(+), 25 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 6a2ad67..2cd4d2f 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1115,7 +1115,7 @@ } .table-wrap { - --contacts-message-col-width: 92px; + --contacts-message-col-width: 420px; --contacts-action-col-width: 172px; overflow: hidden; border: 1px solid var(--border-color); @@ -1259,6 +1259,9 @@ width: var(--contacts-message-col-width); text-align: right; flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .contacts-list-header-actions { @@ -1378,26 +1381,57 @@ width: var(--contacts-message-col-width); min-width: var(--contacts-message-col-width); display: flex; - align-items: flex-end; - justify-content: center; + align-items: center; + justify-content: flex-end; flex-shrink: 0; text-align: right; } + .row-message-stats { + width: 100%; + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 8px; + white-space: nowrap; + } + + .row-message-stat { + display: inline-flex; + align-items: baseline; + gap: 3px; + font-size: 11px; + color: var(--text-secondary); + min-width: 0; + + .label { + color: var(--text-tertiary); + flex-shrink: 0; + } + + &.total .label { + color: var(--text-secondary); + } + } + .row-message-count-value { margin: 0; - font-size: 13px; - line-height: 1.2; + font-size: 12px; + line-height: 1.1; color: var(--text-primary); font-weight: 600; font-variant-numeric: tabular-nums; &.muted { - font-size: 12px; + font-size: 11px; font-weight: 500; color: var(--text-tertiary); } } + + .row-message-stat.total .row-message-count-value { + font-size: 13px; + } } .table-virtuoso { @@ -2317,7 +2351,7 @@ @media (max-width: 720px) { .table-wrap { - --contacts-message-col-width: 66px; + --contacts-message-col-width: 280px; --contacts-action-col-width: 148px; } @@ -2334,6 +2368,22 @@ min-width: var(--contacts-message-col-width); } + .table-wrap .row-message-stats { + gap: 6px; + } + + .table-wrap .row-message-stat { + font-size: 10px; + } + + .table-wrap .row-message-count-value { + font-size: 11px; + } + + .table-wrap .row-message-stat.total .row-message-count-value { + font-size: 12px; + } + .diag-panel-header { flex-direction: column; align-items: stretch; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index ebb7407..694f053 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -468,6 +468,9 @@ const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 const EXPORT_CARD_DIAG_MAX_FRONTEND_LOGS = 1500 const EXPORT_CARD_DIAG_STALL_MS = 3200 const EXPORT_CARD_DIAG_POLL_INTERVAL_MS = 1200 +const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000 +const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000 +const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000 type SessionDataSource = 'cache' | 'network' | null type ContactsDataSource = 'cache' | 'network' | null @@ -528,6 +531,14 @@ interface SessionExportMetric { groupMutualFriends?: number } +interface SessionContentMetric { + totalMessages?: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number +} + interface SessionExportCacheMeta { updatedAt: number stale: boolean @@ -856,6 +867,8 @@ function ExportPage() { const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState(null) const [sessionMessageCounts, setSessionMessageCounts] = useState>({}) const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false) + const [sessionContentMetrics, setSessionContentMetrics] = useState>({}) + const [isLoadingSessionContentStats, setIsLoadingSessionContentStats] = useState(false) const [contactsListScrollTop, setContactsListScrollTop] = useState(0) const [contactsListViewportHeight, setContactsListViewportHeight] = useState(480) const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) @@ -942,10 +955,16 @@ function ExportPage() { const contactsAvatarCacheRef = useRef>({}) const contactsListRef = useRef(null) const detailRequestSeqRef = useRef(0) + const sessionsRef = useRef([]) + const contactsListSizeRef = useRef(0) + const contactsUpdatedAtRef = useRef(null) + const sessionsHydratedAtRef = useRef(0) + const snsStatsHydratedAtRef = useRef(0) const inProgressSessionIdsRef = useRef([]) const activeTaskCountRef = useRef(0) const hasBaseConfigReadyRef = useRef(false) const sessionCountRequestIdRef = useRef(0) + const sessionContentStatsRequestIdRef = useRef(0) const activeTabRef = useRef('private') const appendFrontendDiagLog = useCallback((entry: ExportCardDiagLogEntry) => { @@ -1175,11 +1194,15 @@ function ExportPage() { void (async () => { const scopeKey = await ensureExportCacheScope() if (cancelled) return + let cachedContactsCount = 0 + let cachedContactsUpdatedAt = 0 try { const [cacheItem, avatarCacheItem] = await Promise.all([ configService.getContactsListCache(scopeKey), configService.getContactsAvatarCache(scopeKey) ]) + cachedContactsCount = Array.isArray(cacheItem?.contacts) ? cacheItem.contacts.length : 0 + cachedContactsUpdatedAt = Number(cacheItem?.updatedAt || 0) const avatarCacheMap = avatarCacheItem?.avatars || {} contactsAvatarCacheRef.current = avatarCacheMap setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null) @@ -1198,7 +1221,15 @@ function ExportPage() { console.error('读取导出页联系人缓存失败:', error) } - if (!cancelled) { + const latestContactsUpdatedAt = Math.max( + Number(contactsUpdatedAtRef.current || 0), + cachedContactsUpdatedAt + ) + const hasFreshContactSnapshot = (contactsListSizeRef.current > 0 || cachedContactsCount > 0) && + latestContactsUpdatedAt > 0 && + Date.now() - latestContactsUpdatedAt <= EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS + + if (!cancelled && !hasFreshContactSnapshot) { void loadContactsList({ scopeKey }) } })() @@ -1238,6 +1269,18 @@ function ExportPage() { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + sessionsRef.current = sessions + }, [sessions]) + + useEffect(() => { + contactsListSizeRef.current = contactsList.length + }, [contactsList.length]) + + useEffect(() => { + contactsUpdatedAtRef.current = contactsUpdatedAt + }, [contactsUpdatedAt]) + useEffect(() => { if (!expandedPerfTaskId) return const target = tasks.find(task => task.id === expandedPerfTaskId) @@ -1314,6 +1357,7 @@ function ExportPage() { totalPosts: cachedSnsStats.totalPosts || 0, totalFriends: cachedSnsStats.totalFriends || 0 }) + snsStatsHydratedAtRef.current = Date.now() hasSeededSnsStatsRef.current = true setHasSeededSnsStats(true) } @@ -1352,6 +1396,7 @@ function ExportPage() { totalFriends: Number.isFinite(next.totalFriends) ? Math.max(0, Math.floor(next.totalFriends)) : 0 } setSnsStats(normalized) + snsStatsHydratedAtRef.current = Date.now() hasSeededSnsStatsRef.current = true setHasSeededSnsStats(true) if (exportCacheScopeReadyRef.current) { @@ -1389,6 +1434,139 @@ function ExportPage() { } }, []) + const mergeSessionContentMetrics = useCallback((input: Record) => { + const entries = Object.entries(input) + if (entries.length === 0) return + + const nextMessageCounts: Record = {} + const nextMetrics: Record = {} + + for (const [sessionIdRaw, metricRaw] of entries) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId || !metricRaw) continue + 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' && + typeof voiceMessages !== 'number' && + typeof imageMessages !== 'number' && + typeof videoMessages !== 'number' && + typeof emojiMessages !== 'number' + ) { + continue + } + + nextMetrics[sessionId] = { + totalMessages, + voiceMessages, + imageMessages, + videoMessages, + emojiMessages + } + if (typeof totalMessages === 'number') { + nextMessageCounts[sessionId] = totalMessages + } + } + + if (Object.keys(nextMessageCounts).length > 0) { + setSessionMessageCounts(prev => { + let changed = false + const merged = { ...prev } + for (const [sessionId, count] of Object.entries(nextMessageCounts)) { + if (merged[sessionId] === count) continue + merged[sessionId] = count + changed = true + } + return changed ? merged : prev + }) + } + + if (Object.keys(nextMetrics).length > 0) { + setSessionContentMetrics(prev => { + let changed = false + const merged = { ...prev } + for (const [sessionId, metric] of Object.entries(nextMetrics)) { + const previous = merged[sessionId] || {} + const nextMetric: SessionContentMetric = { + totalMessages: typeof metric.totalMessages === 'number' ? metric.totalMessages : previous.totalMessages, + voiceMessages: typeof metric.voiceMessages === 'number' ? metric.voiceMessages : previous.voiceMessages, + imageMessages: typeof metric.imageMessages === 'number' ? metric.imageMessages : previous.imageMessages, + videoMessages: typeof metric.videoMessages === 'number' ? metric.videoMessages : previous.videoMessages, + emojiMessages: typeof metric.emojiMessages === 'number' ? metric.emojiMessages : previous.emojiMessages + } + if ( + previous.totalMessages === nextMetric.totalMessages && + previous.voiceMessages === nextMetric.voiceMessages && + previous.imageMessages === nextMetric.imageMessages && + previous.videoMessages === nextMetric.videoMessages && + previous.emojiMessages === nextMetric.emojiMessages + ) { + continue + } + merged[sessionId] = nextMetric + changed = true + } + return changed ? merged : prev + }) + } + }, []) + + const loadSessionContentStats = useCallback(async ( + sourceSessions: SessionRow[], + priorityTab: ConversationTab + ) => { + const requestId = sessionContentStatsRequestIdRef.current + 1 + sessionContentStatsRequestIdRef.current = requestId + const isStale = () => sessionContentStatsRequestIdRef.current !== requestId + + const exportableSessions = sourceSessions.filter(session => session.hasSession) + if (exportableSessions.length === 0) { + setIsLoadingSessionContentStats(false) + return + } + + const prioritizedSessionIds = exportableSessions + .filter(session => session.kind === priorityTab) + .map(session => session.username) + const prioritizedSet = new Set(prioritizedSessionIds) + const remainingSessionIds = exportableSessions + .filter(session => !prioritizedSet.has(session.username)) + .map(session => session.username) + const orderedSessionIds = [...prioritizedSessionIds, ...remainingSessionIds] + + if (orderedSessionIds.length === 0) { + setIsLoadingSessionContentStats(false) + return + } + + setIsLoadingSessionContentStats(true) + try { + const chunkSize = 80 + for (let i = 0; i < orderedSessionIds.length; i += chunkSize) { + const chunk = orderedSessionIds.slice(i, i + chunkSize) + if (chunk.length === 0) continue + const result = await window.electronAPI.chat.getExportSessionStats( + chunk, + { includeRelations: false, allowStaleCache: true } + ) + if (isStale()) return + if (result.success && result.data) { + mergeSessionContentMetrics(result.data as Record) + } + } + } catch (error) { + console.error('导出页加载会话内容统计失败:', error) + } finally { + if (!isStale()) { + setIsLoadingSessionContentStats(false) + } + } + }, [mergeSessionContentMetrics]) + const loadSessionMessageCounts = useCallback(async ( sourceSessions: SessionRow[], priorityTab: ConversationTab @@ -1406,6 +1584,14 @@ function ExportPage() { return acc }, {}) setSessionMessageCounts(seededHintCounts) + if (Object.keys(seededHintCounts).length > 0) { + mergeSessionContentMetrics( + Object.entries(seededHintCounts).reduce>((acc, [sessionId, count]) => { + acc[sessionId] = { totalMessages: count } + return acc + }, {}) + ) + } if (exportableSessions.length === 0) { setIsLoadingSessionCounts(false) @@ -1431,6 +1617,12 @@ function ExportPage() { }, {}) if (Object.keys(normalized).length === 0) return setSessionMessageCounts(prev => ({ ...prev, ...normalized })) + mergeSessionContentMetrics( + Object.entries(normalized).reduce>((acc, [sessionId, count]) => { + acc[sessionId] = { totalMessages: count } + return acc + }, {}) + ) } setIsLoadingSessionCounts(true) @@ -1457,16 +1649,20 @@ function ExportPage() { setIsLoadingSessionCounts(false) } } - }, []) + }, [mergeSessionContentMetrics]) const loadSessions = useCallback(async () => { const loadToken = Date.now() sessionLoadTokenRef.current = loadToken + sessionsHydratedAtRef.current = 0 setIsLoading(true) setIsSessionEnriching(false) sessionCountRequestIdRef.current += 1 + sessionContentStatsRequestIdRef.current += 1 setSessionMessageCounts({}) + setSessionContentMetrics({}) setIsLoadingSessionCounts(false) + setIsLoadingSessionContentStats(false) const isStale = () => sessionLoadTokenRef.current !== loadToken @@ -1508,7 +1704,12 @@ function ExportPage() { if (isStale()) return setSessions(baseSessions) - void loadSessionMessageCounts(baseSessions, activeTabRef.current) + sessionsHydratedAtRef.current = Date.now() + void (async () => { + await loadSessionMessageCounts(baseSessions, activeTabRef.current) + if (isStale()) return + await loadSessionContentStats(baseSessions, activeTabRef.current) + })() setSessionDataSource(cachedContacts.length > 0 ? 'cache' : 'network') if (cachedContacts.length === 0) { setSessionContactsUpdatedAt(Date.now()) @@ -1670,6 +1871,7 @@ function ExportPage() { const persistAt = Date.now() setSessions(nextSessions) + sessionsHydratedAtRef.current = persistAt if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) { await configService.setContactsListCache(scopeKey, contactsCachePayload) setSessionContactsUpdatedAt(persistAt) @@ -1696,17 +1898,28 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, syncContactTypeCounts]) + }, [ensureExportCacheScope, loadContactsCaches, loadSessionContentStats, loadSessionMessageCounts, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return + const now = Date.now() + const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current && + sessionsRef.current.length > 0 && + now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS + const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current && + now - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS + void loadBaseConfig() void ensureSharedTabCountsLoaded() - void loadSessions() + if (!hasFreshSessionSnapshot) { + void loadSessions() + } // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { - void loadSnsStats({ full: true }) + if (!hasFreshSnsSnapshot) { + void loadSnsStats({ full: true }) + } }, 120) return () => window.clearTimeout(timer) @@ -1726,8 +1939,10 @@ function ExportPage() { // 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。 sessionLoadTokenRef.current = Date.now() sessionCountRequestIdRef.current += 1 + sessionContentStatsRequestIdRef.current += 1 setIsSessionEnriching(false) setIsLoadingSessionCounts(false) + setIsLoadingSessionContentStats(false) }, [isExportRoute]) useEffect(() => { @@ -2840,6 +3055,7 @@ function ExportPage() { cacheMeta?: SessionExportCacheMeta, relationLoadedOverride?: boolean ) => { + mergeSessionContentMetrics({ [sessionId]: metric }) setSessionDetail((prev) => { if (!prev || prev.wxid !== sessionId) return prev const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded) @@ -2866,7 +3082,7 @@ function ExportPage() { latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime } }) - }, []) + }, [mergeSessionContentMetrics]) const loadSessionDetail = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() @@ -2875,11 +3091,17 @@ function ExportPage() { const requestSeq = ++detailRequestSeqRef.current const mappedSession = sessionRowByUsername.get(normalizedSessionId) const mappedContact = contactByUsername.get(normalizedSessionId) + const cachedMetric = sessionContentMetrics[normalizedSessionId] const countedCount = normalizeMessageCount(sessionMessageCounts[normalizedSessionId]) + const metricCount = normalizeMessageCount(cachedMetric?.totalMessages) + const metricVoice = normalizeMessageCount(cachedMetric?.voiceMessages) + const metricImage = normalizeMessageCount(cachedMetric?.imageMessages) + const metricVideo = normalizeMessageCount(cachedMetric?.videoMessages) + const metricEmoji = normalizeMessageCount(cachedMetric?.emojiMessages) const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0 ? Math.floor(mappedSession.messageCountHint) : undefined - const initialMessageCount = countedCount ?? hintedCount + const initialMessageCount = countedCount ?? metricCount ?? hintedCount setCopiedDetailField(null) setIsRefreshingSessionDetailStats(false) @@ -2894,10 +3116,10 @@ function ExportPage() { alias: sameSession ? prev?.alias : undefined, avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), messageCount: initialMessageCount ?? (sameSession ? prev.messageCount : Number.NaN), - voiceMessages: sameSession ? prev?.voiceMessages : undefined, - imageMessages: sameSession ? prev?.imageMessages : undefined, - videoMessages: sameSession ? prev?.videoMessages : undefined, - emojiMessages: sameSession ? prev?.emojiMessages : undefined, + voiceMessages: metricVoice ?? (sameSession ? prev?.voiceMessages : undefined), + imageMessages: metricImage ?? (sameSession ? prev?.imageMessages : undefined), + videoMessages: metricVideo ?? (sameSession ? prev?.videoMessages : undefined), + emojiMessages: metricEmoji ?? (sameSession ? prev?.emojiMessages : undefined), privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined, groupMemberCount: sameSession ? prev?.groupMemberCount : undefined, groupMyMessages: sameSession ? prev?.groupMyMessages : undefined, @@ -3037,7 +3259,7 @@ function ExportPage() { setIsLoadingSessionDetailExtra(false) } } - }, [applySessionDetailStats, contactByUsername, sessionMessageCounts, sessionRowByUsername]) + }, [applySessionDetailStats, contactByUsername, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername]) const loadSessionRelationStats = useCallback(async () => { const normalizedSessionId = String(sessionDetail?.wxid || '').trim() @@ -3909,6 +4131,12 @@ function ExportPage() { 消息总数统计中… )} + {isLoadingSessionContentStats && ( + + + 图片/语音/表情包/视频统计中… + + )} {contactsList.length > 0 && isContactsListLoading && ( @@ -3965,7 +4193,7 @@ function ExportPage() { <>
联系人(头像/名称/微信号) - 总消息 + 总消息 | 图片 | 语音 | 表情包 | 视频 操作
@@ -3982,14 +4210,40 @@ function ExportPage() { const isQueued = canExport && queuedSessionIds.has(contact.username) const isPaused = canExport && pausedSessionIds.has(contact.username) const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' + const contentMetric = sessionContentMetrics[contact.username] const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) + const metricMessages = normalizeMessageCount(contentMetric?.totalMessages) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) - const displayedMessageCount = countedMessages ?? hintedMessages + const displayedMessageCount = countedMessages ?? metricMessages ?? hintedMessages + const displayedImageCount = normalizeMessageCount(contentMetric?.imageMessages) + const displayedVoiceCount = normalizeMessageCount(contentMetric?.voiceMessages) + const displayedEmojiCount = normalizeMessageCount(contentMetric?.emojiMessages) + const displayedVideoCount = normalizeMessageCount(contentMetric?.videoMessages) const messageCountLabel = !canExport ? '--' : typeof displayedMessageCount === 'number' ? displayedMessageCount.toLocaleString('zh-CN') : (isLoadingSessionCounts ? '统计中…' : '--') + const imageCountLabel = !canExport + ? '--' + : typeof displayedImageCount === 'number' + ? displayedImageCount.toLocaleString('zh-CN') + : (isLoadingSessionContentStats ? '统计中…' : '0') + const voiceCountLabel = !canExport + ? '--' + : typeof displayedVoiceCount === 'number' + ? displayedVoiceCount.toLocaleString('zh-CN') + : (isLoadingSessionContentStats ? '统计中…' : '0') + const emojiCountLabel = !canExport + ? '--' + : typeof displayedEmojiCount === 'number' + ? displayedEmojiCount.toLocaleString('zh-CN') + : (isLoadingSessionContentStats ? '统计中…' : '0') + const videoCountLabel = !canExport + ? '--' + : typeof displayedVideoCount === 'number' + ? displayedVideoCount.toLocaleString('zh-CN') + : (isLoadingSessionContentStats ? '统计中…' : '0') return (
{contact.username}
- - {messageCountLabel} - +
+ + 总消息 + + {messageCountLabel} + + + + 图片 + + {imageCountLabel} + + + + 语音 + + {voiceCountLabel} + + + + 表情包 + + {emojiCountLabel} + + + + 视频 + + {videoCountLabel} + + +