From ec10f475675b49f47e62e762cf2349b3ce6449e0 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 30 May 2026 16:58:08 +0800 Subject: [PATCH 1/3] fix(export): stats would randomly reset to zero --- electron/services/chatService.ts | 47 ++++++++++- src/pages/ExportPage.tsx | 136 +++++++++++++++++++------------ src/types/electron.d.ts | 1 + 3 files changed, 129 insertions(+), 55 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 6c393a0..c928d19 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -208,6 +208,7 @@ interface ExportSessionStatsCacheMeta { stale: boolean includeRelations: boolean source: 'memory' | 'disk' | 'fresh' + rangeFiltered?: boolean } interface ExportTabCounts { @@ -7761,7 +7762,12 @@ class ChatService { success: true, count: Math.max(0, Math.floor(messageCount as number)) }) - : wcdbService.getMessageCount(normalizedSessionId) + : this.getSessionMessageCounts([normalizedSessionId], { preferHintCache: true }) + .then((result) => ({ + success: result.success, + count: result.counts?.[normalizedSessionId], + error: result.error + })) const [contactResult, avatarResult, messageCountResult] = await Promise.allSettled([ contactPromise, @@ -8066,7 +8072,15 @@ class ChatService { endTimestamp ) resultMap[sessionId] = stats - if (!useRangeFilter) { + if (useRangeFilter) { + cacheMeta[sessionId] = { + updatedAt: Date.now(), + stale: false, + includeRelations, + source: 'fresh', + rangeFiltered: true + } + } else { const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) cacheMeta[sessionId] = { updatedAt, @@ -8093,7 +8107,15 @@ class ChatService { const stats = batchedStatsMap[sessionId] if (!stats) continue resultMap[sessionId] = stats - if (!useRangeFilter) { + if (useRangeFilter) { + cacheMeta[sessionId] = { + updatedAt: Date.now(), + stale: false, + includeRelations, + source: 'fresh', + rangeFiltered: true + } + } else { const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) cacheMeta[sessionId] = { updatedAt, @@ -8121,7 +8143,15 @@ class ChatService { endTimestamp ) resultMap[sessionId] = stats - if (!useRangeFilter) { + if (useRangeFilter) { + cacheMeta[sessionId] = { + updatedAt: Date.now(), + stale: false, + includeRelations, + source: 'fresh', + rangeFiltered: true + } + } else { const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) cacheMeta[sessionId] = { updatedAt, @@ -8132,6 +8162,15 @@ class ChatService { } } catch { resultMap[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations) + if (useRangeFilter) { + cacheMeta[sessionId] = { + updatedAt: Date.now(), + stale: true, + includeRelations, + source: 'fresh', + rangeFiltered: true + } + } } }) } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 42730bc..d486530 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1384,6 +1384,7 @@ interface SessionExportCacheMeta { stale: boolean includeRelations: boolean source: 'memory' | 'disk' | 'fresh' + rangeFiltered?: boolean } type SessionLoadStageStatus = 'pending' | 'loading' | 'done' | 'failed' @@ -1602,6 +1603,12 @@ const normalizeTimestampSeconds = (value: unknown): number | undefined => { return Math.floor(parsed) } +const mergeStableCount = (incoming: number | undefined, previous: number | undefined): number | undefined => { + if (typeof incoming !== 'number') return previous + if (incoming === 0 && typeof previous === 'number' && previous > 0) return previous + return incoming +} + const clampExportSelectionToBounds = ( selection: ExportDateRangeSelection, bounds: TimeRangeBounds | null @@ -2369,27 +2376,6 @@ function ExportPage() { exportConcurrency: 2 }) - const exportStatsRangeOptions = useMemo(() => { - if (options.useAllTime || !options.dateRange) return null - const beginTimestamp = Math.floor(options.dateRange.start.getTime() / 1000) - const endTimestamp = Math.floor(options.dateRange.end.getTime() / 1000) - if (!Number.isFinite(beginTimestamp) || !Number.isFinite(endTimestamp)) return null - if (beginTimestamp <= 0 && endTimestamp <= 0) return null - return { - beginTimestamp: Math.max(0, beginTimestamp), - endTimestamp: Math.max(0, endTimestamp) - } - }, [options.useAllTime, options.dateRange]) - - const withExportStatsRange = useCallback((statsOptions: Record): Record => { - if (!exportStatsRangeOptions) return statsOptions - return { - ...statsOptions, - beginTimestamp: exportStatsRangeOptions.beginTimestamp, - endTimestamp: exportStatsRangeOptions.endTimestamp - } - }, [exportStatsRangeOptions]) - const [exportDialog, setExportDialog] = useState({ open: false, intent: 'manual', @@ -3673,9 +3659,16 @@ function ExportPage() { return [] }, [sessionSnsCommentRankings, sessionSnsLikeRankings, sessionSnsRankMode]) - const mergeSessionContentMetrics = useCallback((input: Record) => { + const mergeSessionContentMetrics = useCallback(( + input: Record, + options?: { + mergeTotalMessages?: boolean + preserveExistingTotalOnZero?: boolean + } + ) => { const entries = Object.entries(input) if (entries.length === 0) return + const mergeTotalMessages = options?.mergeTotalMessages !== false const nextMessageCounts: Record = {} const nextMetrics: Record = {} @@ -3683,7 +3676,13 @@ function ExportPage() { for (const [sessionIdRaw, metricRaw] of entries) { const sessionId = String(sessionIdRaw || '').trim() if (!sessionId || !metricRaw) continue - const totalMessages = normalizeMessageCount(metricRaw.totalMessages) + const previous = sessionContentMetricsRef.current[sessionId] || {} + const incomingTotalMessages = normalizeMessageCount(metricRaw.totalMessages) + const totalMessages = mergeTotalMessages + ? (options?.preserveExistingTotalOnZero + ? mergeStableCount(incomingTotalMessages, previous.totalMessages) + : incomingTotalMessages) + : undefined const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages) const imageMessages = normalizeMessageCount(metricRaw.imageMessages) const videoMessages = normalizeMessageCount(metricRaw.videoMessages) @@ -3729,8 +3728,12 @@ function ExportPage() { let changed = false const merged = { ...prev } for (const [sessionId, count] of Object.entries(nextMessageCounts)) { - if (merged[sessionId] === count) continue - merged[sessionId] = count + const previousCount = normalizeMessageCount(merged[sessionId]) + const nextCount = options?.preserveExistingTotalOnZero + ? mergeStableCount(count, previousCount) + : count + if (typeof nextCount !== 'number' || previousCount === nextCount) continue + merged[sessionId] = nextCount changed = true } return changed ? merged : prev @@ -3744,7 +3747,11 @@ function ExportPage() { for (const [sessionId, metric] of Object.entries(nextMetrics)) { const previous = merged[sessionId] || {} const nextMetric: SessionContentMetric = { - totalMessages: typeof metric.totalMessages === 'number' ? metric.totalMessages : previous.totalMessages, + totalMessages: mergeTotalMessages + ? (options?.preserveExistingTotalOnZero + ? mergeStableCount(metric.totalMessages, previous.totalMessages) + : (typeof metric.totalMessages === 'number' ? metric.totalMessages : previous.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, @@ -4084,7 +4091,10 @@ function ExportPage() { } }, [isSessionMediaMetricReady, patchSessionLoadTraceStage]) - const applySessionMediaMetricsFromStats = useCallback((data?: Record) => { + const applySessionMediaMetricsFromStats = useCallback(( + data?: Record, + cache?: Record + ) => { if (!data) return const nextMetrics: Record = {} let hasPatch = false @@ -4093,19 +4103,25 @@ function ExportPage() { if (!sessionId) continue const metric = pickSessionMediaMetric(metricRaw) if (!metric) continue - nextMetrics[sessionId] = metric + const metricForMerge = cache?.[sessionId]?.rangeFiltered + ? (() => { + const { totalMessages: _totalMessages, ...rest } = metric + return rest + })() + : metric + nextMetrics[sessionId] = metricForMerge hasPatch = true sessionMediaMetricPendingPersistRef.current[sessionId] = { ...sessionMediaMetricPendingPersistRef.current[sessionId], - ...metric + ...metricForMerge } - if (hasCompleteSessionMediaMetric(metric)) { + if (hasCompleteSessionMediaMetric(metricForMerge)) { sessionMediaMetricReadySetRef.current.add(sessionId) } } if (hasPatch) { - mergeSessionContentMetrics(nextMetrics) + mergeSessionContentMetrics(nextMetrics, { preserveExistingTotalOnZero: true }) scheduleFlushSessionMediaMetricCache() } }, [mergeSessionContentMetrics, scheduleFlushSessionMediaMetricCache]) @@ -4155,14 +4171,17 @@ function ExportPage() { const cacheResult = await withTimeout( window.electronAPI.chat.getExportSessionStats( batchSessionIds, - withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true }) + { includeRelations: false, allowStaleCache: true, cacheOnly: true } ), 12000, 'cacheOnly' ) if (runId !== sessionMediaMetricRunIdRef.current) return if (cacheResult.success && cacheResult.data) { - applySessionMediaMetricsFromStats(cacheResult.data as Record) + applySessionMediaMetricsFromStats( + cacheResult.data as Record, + cacheResult.cache as Record | undefined + ) } const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId)) @@ -4170,14 +4189,17 @@ function ExportPage() { const freshResult = await withTimeout( window.electronAPI.chat.getExportSessionStats( missingSessionIds, - withExportStatsRange({ includeRelations: false, allowStaleCache: true }) + { includeRelations: false, allowStaleCache: true } ), 45000, 'fresh' ) if (runId !== sessionMediaMetricRunIdRef.current) return if (freshResult.success && freshResult.data) { - applySessionMediaMetricsFromStats(freshResult.data as Record) + applySessionMediaMetricsFromStats( + freshResult.data as Record, + freshResult.cache as Record | undefined + ) } } @@ -4214,7 +4236,7 @@ function ExportPage() { void runSessionMediaMetricWorker(runId) } } - }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage, withExportStatsRange]) + }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage]) const scheduleSessionMediaMetricWorker = useCallback(() => { if (activeTaskCountRef.current > 0) return @@ -5051,9 +5073,10 @@ function ExportPage() { const applyStatsResult = (result?: { success: boolean data?: Record + cache?: Record } | null) => { if (!result?.success || !result.data) return - applySessionMediaMetricsFromStats(result.data) + applySessionMediaMetricsFromStats(result.data, result.cache) for (const sessionId of normalizedSessionIds) { absorbMetric(sessionId, result.data[sessionId]) } @@ -7326,13 +7349,21 @@ function ExportPage() { cacheMeta?: SessionExportCacheMeta, relationLoadedOverride?: boolean ) => { - mergeSessionContentMetrics({ [sessionId]: metric }) + const isRangeFilteredMetric = cacheMeta?.rangeFiltered === true + mergeSessionContentMetrics({ [sessionId]: metric }, { + mergeTotalMessages: !isRangeFilteredMetric, + preserveExistingTotalOnZero: true + }) setSessionDetail((prev) => { if (!prev || prev.wxid !== sessionId) return prev const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded) + const messageCount = mergeStableCount( + !isRangeFilteredMetric && Number.isFinite(metric.totalMessages) ? metric.totalMessages : undefined, + prev.messageCount + ) return { ...prev, - messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount, + messageCount: Number.isFinite(messageCount) ? messageCount as number : prev.messageCount, voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages, imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages, videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages, @@ -7364,8 +7395,6 @@ function ExportPage() { const preciseCacheKey = `${exportCacheScopeRef.current}::${normalizedSessionId}` detailStatsPriorityRef.current = true - sessionCountRequestIdRef.current += 1 - setIsLoadingSessionCounts(false) const requestSeq = ++detailRequestSeqRef.current const mappedSession = sessionRowByUsername.get(normalizedSessionId) @@ -7428,16 +7457,19 @@ function ExportPage() { const fastMessageCount = normalizeMessageCount(result.detail.messageCount) if (typeof fastMessageCount === 'number') { setSessionMessageCounts((prev) => { - if (prev[normalizedSessionId] === fastMessageCount) return prev + const nextCount = mergeStableCount(fastMessageCount, normalizeMessageCount(prev[normalizedSessionId])) + if (typeof nextCount !== 'number' || prev[normalizedSessionId] === nextCount) return prev return { ...prev, - [normalizedSessionId]: fastMessageCount + [normalizedSessionId]: nextCount } }) mergeSessionContentMetrics({ [normalizedSessionId]: { totalMessages: fastMessageCount } + }, { + preserveExistingTotalOnZero: true }) } setSessionDetail((prev) => ({ @@ -7447,7 +7479,9 @@ function ExportPage() { nickName: result.detail!.nickName ?? prev?.nickName, alias: result.detail!.alias ?? prev?.alias, avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, - messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, + messageCount: Number.isFinite(result.detail!.messageCount) + ? mergeStableCount(result.detail!.messageCount, prev?.messageCount) ?? Number.NaN + : prev?.messageCount ?? Number.NaN, voiceMessages: prev?.voiceMessages, imageMessages: prev?.imageMessages, videoMessages: prev?.videoMessages, @@ -7507,7 +7541,7 @@ function ExportPage() { try { const quickStatsResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true }) + { includeRelations: false, allowStaleCache: true, cacheOnly: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (quickStatsResult.success) { @@ -7534,7 +7568,7 @@ function ExportPage() { try { const relationCacheResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - withExportStatsRange({ includeRelations: true, allowStaleCache: true, cacheOnly: true }) + { includeRelations: true, allowStaleCache: true, cacheOnly: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (relationCacheResult.success && relationCacheResult.data) { @@ -7559,7 +7593,7 @@ function ExportPage() { // 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。 const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - withExportStatsRange({ includeRelations: false, forceRefresh: true }) + { includeRelations: false, forceRefresh: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (freshResult.success && freshResult.data) { @@ -7594,7 +7628,7 @@ function ExportPage() { setIsLoadingSessionDetailExtra(false) } } - }, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername, withExportStatsRange]) + }, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername]) const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => { const normalizedSessionId = String(sessionDetail?.wxid || '').trim() @@ -7607,7 +7641,7 @@ function ExportPage() { if (!forceRefresh) { const relationCacheResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - withExportStatsRange({ includeRelations: true, allowStaleCache: true, cacheOnly: true }) + { includeRelations: true, allowStaleCache: true, cacheOnly: true } ) if (requestSeq !== detailRequestSeqRef.current) return @@ -7625,7 +7659,7 @@ function ExportPage() { const relationResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - withExportStatsRange({ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true }) + { includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true } ) if (requestSeq !== detailRequestSeqRef.current) return @@ -7645,7 +7679,7 @@ function ExportPage() { setIsLoadingSessionRelationStats(false) } } - }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid, withExportStatsRange]) + }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid]) const handleRefreshTableData = useCallback(async () => { const scopeKey = await ensureExportCacheScope() diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index b927e4f..b29454e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -654,6 +654,7 @@ export interface ElectronAPI { stale: boolean includeRelations: boolean source: 'memory' | 'disk' | 'fresh' + rangeFiltered?: boolean }> needsRefresh?: string[] error?: string From b063ed299bf27946d7ff1da3be0abadd13c3430f Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 30 May 2026 18:10:59 +0800 Subject: [PATCH 2/3] fix(export): stop background stats when hidden, fix loading state, optimize cache and memory usage --- electron/services/chatService.ts | 37 ++++-- src/pages/ExportPage.tsx | 212 +++++++++++++++++++++++-------- 2 files changed, 184 insertions(+), 65 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index c928d19..145f353 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -490,6 +490,13 @@ class ChatService { return true } + private shouldPersistAvatarUrl(avatarUrl?: string): avatarUrl is string { + const normalized = String(avatarUrl || '').trim() + if (!this.isValidAvatarUrl(normalized)) return false + if (!normalized.startsWith('data:')) return true + return normalized.length <= 4096 + } + private extractErrorCode(message?: string | null): number | null { const text = String(message || '').trim() if (!text) return null @@ -1115,6 +1122,7 @@ class ChatService { const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '') const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10) const cached = this.avatarCache.get(username) + const cachedAvatarUrl = this.shouldPersistAvatarUrl(cached?.avatarUrl) ? cached?.avatarUrl : undefined const contact = contactMap.get(username) const session: ChatSession = { @@ -1126,7 +1134,7 @@ class ChatService { lastTimestamp: lastTs, lastMsgType, displayName: contact?.displayName || cached?.displayName || username, - avatarUrl: cached?.avatarUrl, + avatarUrl: cachedAvatarUrl, lastMsgSender: row.last_msg_sender, lastSenderDisplayName: row.last_sender_display_name, selfWxid: myWxid @@ -1484,8 +1492,7 @@ class ChatService { // 检查缓存 for (const username of normalizedUsernames) { const cached = this.avatarCache.get(username) - const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl) - const cachedAvatarUrl = isValidAvatar ? cached?.avatarUrl : undefined + const cachedAvatarUrl = this.shouldPersistAvatarUrl(cached?.avatarUrl) ? cached?.avatarUrl : undefined if (onlyMissingAvatar && cachedAvatarUrl) { result[username] = { displayName: skipDisplayName ? undefined : cached?.displayName, @@ -1495,7 +1502,7 @@ class ChatService { } // 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取 // 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取 - if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) { + if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && cachedAvatarUrl) { result[username] = { displayName: skipDisplayName ? undefined : cached.displayName, avatarUrl: cachedAvatarUrl @@ -1529,7 +1536,11 @@ class ChatService { const cacheEntry: ContactCacheEntry = { displayName: displayName || previous?.displayName || username, - avatarUrl, + avatarUrl: this.shouldPersistAvatarUrl(avatarUrl) + ? avatarUrl + : this.shouldPersistAvatarUrl(previous?.avatarUrl) + ? previous?.avatarUrl + : undefined, updatedAt: now } result[username] = { @@ -1549,7 +1560,7 @@ class ChatService { if (avatarUrl) { result[username].avatarUrl = avatarUrl const cached = this.avatarCache.get(username) - if (cached) { + if (cached && this.shouldPersistAvatarUrl(avatarUrl)) { cached.avatarUrl = avatarUrl updatedEntries[username] = cached } @@ -7276,9 +7287,9 @@ class ChatService { if (!connectResult.success) return null const cached = this.avatarCache.get(username) // 检查缓存是否有效,且头像不是错误的 hex 格式 - const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl) - if (cached && isValidAvatar && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) { - return { avatarUrl: cached.avatarUrl, displayName: cached.displayName } + const cachedAvatarUrl = this.shouldPersistAvatarUrl(cached?.avatarUrl) ? cached?.avatarUrl : undefined + if (cached && cachedAvatarUrl && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) { + return { avatarUrl: cachedAvatarUrl, displayName: cached.displayName } } const contact = await this.getContact(username) @@ -7296,7 +7307,11 @@ class ChatService { } const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username const cacheEntry: ContactCacheEntry = { - avatarUrl, + avatarUrl: this.shouldPersistAvatarUrl(avatarUrl) + ? avatarUrl + : this.shouldPersistAvatarUrl(cached?.avatarUrl) + ? cached?.avatarUrl + : undefined, displayName, updatedAt: Date.now() } @@ -7732,7 +7747,7 @@ class ChatService { const cachedContact = this.avatarCache.get(normalizedSessionId) if (cachedContact) { displayName = cachedContact.displayName || normalizedSessionId - if (this.isValidAvatarUrl(cachedContact.avatarUrl)) { + if (this.shouldPersistAvatarUrl(cachedContact.avatarUrl)) { avatarUrl = cachedContact.avatarUrl } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index d486530..edef3fb 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -232,12 +232,13 @@ const EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS = 320 const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 const SESSION_MEDIA_METRIC_BATCH_SIZE = 8 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 -const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120 const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200 +const SESSION_DETAIL_BACKGROUND_METRIC_LIMIT_PER_TAB = 96 const SNS_USER_POST_COUNT_BATCH_SIZE = 12 const SNS_USER_POST_COUNT_BATCH_INTERVAL_MS = 120 const SNS_RANK_PAGE_SIZE = 50 const SNS_RANK_DISPLAY_LIMIT = 15 +const INLINE_AVATAR_CACHE_MAX_LENGTH = 4096 const contentTypeLabels: Record = { text: '聊天文本', voice: '语音', @@ -796,6 +797,13 @@ const normalizeExportAvatarUrl = (value?: string | null): string | undefined => return normalized } +const shouldPersistExportAvatarUrl = (value?: string | null): value is string => { + const normalized = normalizeExportAvatarUrl(value) + if (!normalized) return false + if (!normalized.startsWith('data:')) return true + return normalized.length <= INLINE_AVATAR_CACHE_MAX_LENGTH +} + const toComparableNameSet = (values: Array): Set => { const set = new Set() for (const value of values) { @@ -1306,6 +1314,15 @@ const buildSessionMutualFriendsMetric = ( } } +const cloneCompactSessionMutualFriendsMetric = ( + metric: SessionMutualFriendsMetric, + limit = SNS_RANK_DISPLAY_LIMIT +): SessionMutualFriendsMetric => ({ + ...metric, + items: metric.items.slice(0, Math.max(0, limit)), + count: metric.count +}) + const getSessionMutualFriendDirectionLabel = (direction: SessionMutualFriendDirection): string => { if (direction === 'incoming') return '对方赞/评TA' if (direction === 'outgoing') return 'TA赞/评对方' @@ -1435,14 +1452,47 @@ const toContactMapFromCaches = ( const map: Record = {} for (const contact of contacts || []) { if (!contact?.username) continue + const cachedAvatarUrl = avatarEntries[contact.username]?.avatarUrl map[contact.username] = { ...contact, - avatarUrl: avatarEntries[contact.username]?.avatarUrl + avatarUrl: shouldPersistExportAvatarUrl(cachedAvatarUrl) ? cachedAvatarUrl : undefined } } return map } +const compactExportAvatarEntries = ( + avatarEntries: Record +): { + avatarEntries: Record + changed: boolean +} => { + const nextCache: Record = {} + let changed = false + for (const [username, entry] of Object.entries(avatarEntries || {})) { + const normalizedUsername = String(username || '').trim() + const avatarUrl = normalizeExportAvatarUrl(entry?.avatarUrl) + if (!normalizedUsername || !shouldPersistExportAvatarUrl(avatarUrl)) { + changed = true + continue + } + nextCache[normalizedUsername] = { + avatarUrl, + updatedAt: Number(entry?.updatedAt || 0) || Date.now(), + checkedAt: Number(entry?.checkedAt || 0) || Date.now() + } + if ( + normalizedUsername !== username || + avatarUrl !== entry?.avatarUrl || + nextCache[normalizedUsername].updatedAt !== entry?.updatedAt || + nextCache[normalizedUsername].checkedAt !== entry?.checkedAt + ) { + changed = true + } + } + return { avatarEntries: nextCache, changed } +} + const mergeAvatarCacheIntoContacts = ( sourceContacts: ContactInfo[], avatarEntries: Record @@ -1454,7 +1504,7 @@ const mergeAvatarCacheIntoContacts = ( let changed = false const merged = sourceContacts.map((contact) => { const cachedAvatar = avatarEntries[contact.username]?.avatarUrl - if (!cachedAvatar || contact.avatarUrl) { + if (!shouldPersistExportAvatarUrl(cachedAvatar) || contact.avatarUrl) { return contact } changed = true @@ -1476,19 +1526,20 @@ const upsertAvatarCacheFromContacts = ( changed: boolean updatedAt: number | null } => { - const nextCache = { ...avatarEntries } + const compactedCache = compactExportAvatarEntries(avatarEntries) + const nextCache = { ...compactedCache.avatarEntries } const now = options?.now || Date.now() const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean)) const usernamesInSource = new Set() - let changed = false + let changed = compactedCache.changed for (const contact of sourceContacts) { const username = String(contact.username || '').trim() if (!username) continue usernamesInSource.add(username) const prev = nextCache[username] - const avatarUrl = String(contact.avatarUrl || '').trim() - if (!avatarUrl) continue + const avatarUrl = normalizeExportAvatarUrl(contact.avatarUrl) + if (!shouldPersistExportAvatarUrl(avatarUrl)) continue const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now) if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) { @@ -2459,6 +2510,7 @@ function ExportPage() { const hasBaseConfigReadyRef = useRef(false) const sessionCountRequestIdRef = useRef(0) const isLoadingSessionCountsRef = useRef(false) + const isExportRouteRef = useRef(isExportRoute) const activeTabRef = useRef('private') const detailStatsPriorityRef = useRef(false) const sessionSnsTimelinePostsRef = useRef([]) @@ -2499,6 +2551,8 @@ function ExportPage() { startIndex: 0, endIndex: -1 }) + const enqueueSessionMutualFriendsRequestsRef = useRef<(sessionIds: string[], options?: { front?: boolean }) => void>(() => {}) + const scheduleSessionMutualFriendsWorkerRef = useRef<() => void>(() => {}) const handleContactsListScrollParentRef = useCallback((node: HTMLDivElement | null) => { setContactsListScrollParent(prev => (prev === node ? prev : node)) @@ -2612,6 +2666,10 @@ function ExportPage() { isLoadingSessionCountsRef.current = isLoadingSessionCounts }, [isLoadingSessionCounts]) + useEffect(() => { + isExportRouteRef.current = isExportRoute + }, [isExportRoute]) + useEffect(() => { sessionContentMetricsRef.current = sessionContentMetrics }, [sessionContentMetrics]) @@ -2895,7 +2953,7 @@ function ExportPage() { let avatarCacheChanged = false for (const [username, patch] of avatarPatches.entries()) { - if (!patch.avatarUrl) continue + if (!shouldPersistExportAvatarUrl(patch.avatarUrl)) continue const previous = contactsAvatarCacheRef.current[username] if (previous?.avatarUrl === patch.avatarUrl) continue contactsAvatarCacheRef.current[username] = { @@ -3220,7 +3278,7 @@ function ExportPage() { } }, []) - const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => { + const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean; cacheOnly?: boolean }) => { if (snsUserPostCountsStatus === 'loading') return if (!options?.force && snsUserPostCountsStatus === 'ready') return @@ -3271,6 +3329,10 @@ function ExportPage() { setSnsUserPostCountsStatus('ready') return } + if (options?.cacheOnly) { + setSnsUserPostCountsStatus('ready') + return + } patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'pending', { force: true }) patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'loading') @@ -3442,7 +3504,7 @@ function ExportPage() { const username = String(sessionSnsTimelineTarget?.username || '').trim() if (!username) return false if (Object.prototype.hasOwnProperty.call(snsUserPostCounts, username)) return false - return snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle' + return snsUserPostCountsStatus === 'loading' }, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus]) const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => { @@ -3468,11 +3530,11 @@ function ExportPage() { setSessionSnsRankTotalPosts(normalizedCount) } else { setSessionSnsTimelineTotalPosts(null) - setSessionSnsTimelineStatsLoading(snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') + setSessionSnsTimelineStatsLoading(snsUserPostCountsStatus === 'loading') setSessionSnsRankTotalPosts(null) } - void loadSnsUserPostCounts() + void loadSnsUserPostCounts({ cacheOnly: true }) }, [ loadSnsUserPostCounts, snsUserPostCounts, @@ -3506,7 +3568,11 @@ function ExportPage() { const normalizedSessionId = String(contact?.username || '').trim() if (!normalizedSessionId || !isSingleContactSession(normalizedSessionId)) return const metric = sessionMutualFriendsMetricsRef.current[normalizedSessionId] - if (!metric) return + if (!metric) { + enqueueSessionMutualFriendsRequestsRef.current([normalizedSessionId], { front: true }) + scheduleSessionMutualFriendsWorkerRef.current() + return + } setSessionMutualFriendsSearch('') setSessionMutualFriendsDialogTarget({ username: normalizedSessionId, @@ -3848,6 +3914,21 @@ function ExportPage() { } }, []) + const stopExportPageBackgroundLoaders = useCallback(() => { + sessionLoadTokenRef.current = Date.now() + sessionCountRequestIdRef.current += 1 + snsUserPostCountsHydrationTokenRef.current += 1 + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } + resetSessionMediaMetricLoader() + resetSessionMutualFriendsLoader() + setIsSessionEnriching(false) + setIsLoadingSessionCounts(false) + setSnsUserPostCountsStatus(prev => (prev === 'loading' ? 'idle' : prev)) + }, [resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader]) + const flushSessionMutualFriendsCache = useCallback(async () => { try { const scopeKey = await ensureExportCacheScope() @@ -3880,6 +3961,7 @@ function ExportPage() { }, []) const enqueueSessionMutualFriendsRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { + if (!isExportRouteRef.current) return if (activeTaskCountRef.current > 0) return const front = options?.front === true const incoming: string[] = [] @@ -3901,13 +3983,16 @@ function ExportPage() { } }, [isSessionMutualFriendsReady, patchSessionLoadTraceStage]) + useEffect(() => { + enqueueSessionMutualFriendsRequestsRef.current = enqueueSessionMutualFriendsRequests + }, [enqueueSessionMutualFriendsRequests]) + const hasPendingMetricLoads = useCallback((): boolean => ( isLoadingSessionCountsRef.current || sessionMediaMetricQueuedSetRef.current.size > 0 || sessionMediaMetricLoadingSetRef.current.size > 0 || sessionMediaMetricWorkerRunningRef.current || - snsUserPostCountsStatus === 'loading' || - snsUserPostCountsStatus === 'idle' + snsUserPostCountsStatus === 'loading' ), [snsUserPostCountsStatus]) const getSessionMutualFriendProfile = useCallback((sessionId: string): { @@ -4070,6 +4155,7 @@ function ExportPage() { }, []) const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { + if (!isExportRouteRef.current) return if (activeTaskCountRef.current > 0) return const front = options?.front === true const incoming: string[] = [] @@ -4128,6 +4214,7 @@ function ExportPage() { const runSessionMediaMetricWorker = useCallback(async (runId: number) => { if (sessionMediaMetricWorkerRunningRef.current) return + if (!isExportRouteRef.current) return sessionMediaMetricWorkerRunningRef.current = true const withTimeout = async (promise: Promise, timeoutMs: number, stage: string): Promise => { let timer: number | null = null @@ -4146,6 +4233,7 @@ function ExportPage() { } try { while (runId === sessionMediaMetricRunIdRef.current) { + if (!isExportRouteRef.current) break if (activeTaskCountRef.current > 0) { await new Promise(resolve => window.setTimeout(resolve, 150)) continue @@ -4210,6 +4298,10 @@ function ExportPage() { }) } } catch (error) { + if (!isExportRouteRef.current || runId !== sessionMediaMetricRunIdRef.current) { + patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'pending', { force: true }) + break + } console.error('导出页加载会话媒体统计失败:', error) patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', { error: String(error) @@ -4234,11 +4326,14 @@ function ExportPage() { sessionMediaMetricWorkerRunningRef.current = false if (runId === sessionMediaMetricRunIdRef.current && sessionMediaMetricQueueRef.current.length > 0) { void runSessionMediaMetricWorker(runId) + } else if (runId === sessionMediaMetricRunIdRef.current && sessionMutualFriendsQueueRef.current.length > 0) { + scheduleSessionMutualFriendsWorkerRef.current() } } }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage]) const scheduleSessionMediaMetricWorker = useCallback(() => { + if (!isExportRouteRef.current) return if (activeTaskCountRef.current > 0) return if (sessionMediaMetricWorkerRunningRef.current) return const runId = sessionMediaMetricRunIdRef.current @@ -4255,6 +4350,9 @@ function ExportPage() { let hasMore = true while (hasMore) { + if (!isExportRouteRef.current) { + throw new Error('导出页已隐藏,已停止共同好友统计') + } const result = await window.electronAPI.sns.getTimeline( SNS_RANK_PAGE_SIZE, 0, @@ -4280,14 +4378,16 @@ function ExportPage() { hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE } - return buildSessionMutualFriendsMetric(allPosts, knownTotal) + return cloneCompactSessionMutualFriendsMetric(buildSessionMutualFriendsMetric(allPosts, knownTotal)) }, [snsUserPostCounts]) const runSessionMutualFriendsWorker = useCallback(async (runId: number) => { if (sessionMutualFriendsWorkerRunningRef.current) return + if (!isExportRouteRef.current) return sessionMutualFriendsWorkerRunningRef.current = true try { while (runId === sessionMutualFriendsRunIdRef.current) { + if (!isExportRouteRef.current) break if (activeTaskCountRef.current > 0) { await new Promise(resolve => window.setTimeout(resolve, 150)) continue @@ -4313,6 +4413,10 @@ function ExportPage() { sessionMutualFriendsReadySetRef.current.add(sessionId) patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'done') } catch (error) { + if (!isExportRouteRef.current || runId !== sessionMutualFriendsRunIdRef.current) { + patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'pending', { force: true }) + break + } console.error('导出页加载共同好友统计失败:', error) patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'failed', { error: error instanceof Error ? error.message : String(error) @@ -4338,6 +4442,7 @@ function ExportPage() { ]) const scheduleSessionMutualFriendsWorker = useCallback(() => { + if (!isExportRouteRef.current) return if (activeTaskCountRef.current > 0) return if (!isSessionCountStageReady) return if (hasPendingMetricLoads()) return @@ -4346,6 +4451,16 @@ function ExportPage() { void runSessionMutualFriendsWorker(runId) }, [hasPendingMetricLoads, isSessionCountStageReady, runSessionMutualFriendsWorker]) + useEffect(() => { + scheduleSessionMutualFriendsWorkerRef.current = scheduleSessionMutualFriendsWorker + }, [scheduleSessionMutualFriendsWorker]) + + useEffect(() => { + if (snsUserPostCountsStatus === 'loading') return + if (sessionMutualFriendsQueueRef.current.length === 0) return + scheduleSessionMutualFriendsWorker() + }, [scheduleSessionMutualFriendsWorker, snsUserPostCountsStatus]) + const loadSessionMessageCounts = useCallback(async ( sourceSessions: SessionRow[], priorityTab: ConversationTab, @@ -4862,18 +4977,8 @@ function ExportPage() { if (isExportRoute) return // 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。 setIsAutomationCreateMode(false) - sessionLoadTokenRef.current = Date.now() - sessionCountRequestIdRef.current += 1 - snsUserPostCountsHydrationTokenRef.current += 1 - if (snsUserPostCountsBatchTimerRef.current) { - window.clearTimeout(snsUserPostCountsBatchTimerRef.current) - snsUserPostCountsBatchTimerRef.current = null - } - resetSessionMutualFriendsLoader() - setIsSessionEnriching(false) - setIsLoadingSessionCounts(false) - setSnsUserPostCountsStatus(prev => (prev === 'loading' ? 'idle' : prev)) - }, [isExportRoute, resetSessionMutualFriendsLoader]) + stopExportPageBackgroundLoaders() + }, [isExportRoute, stopExportPageBackgroundLoaders]) useEffect(() => { if (activeTab === 'official') { @@ -6808,6 +6913,7 @@ function ExportPage() { for (const session of sessions) { if (!session.hasSession) continue if (!keywordMatchedContactUsernameSet.has(session.username)) continue + if (targets[session.kind].length >= SESSION_DETAIL_BACKGROUND_METRIC_LIMIT_PER_TAB) continue targets[session.kind].push(session.username) } return targets @@ -7056,18 +7162,21 @@ function ExportPage() { ]) useEffect(() => { + if (!isExportRoute) return if (filteredContacts.length === 0) return const bootstrapTargets = filteredContacts.slice(0, 24).map((contact) => contact.username) void hydrateVisibleContactAvatars(bootstrapTargets) - }, [filteredContacts, hydrateVisibleContactAvatars]) + }, [filteredContacts, hydrateVisibleContactAvatars, isExportRoute]) useEffect(() => { + if (!isExportRoute) return const sessionId = String(sessionDetail?.wxid || '').trim() if (!sessionId) return void hydrateVisibleContactAvatars([sessionId]) - }, [hydrateVisibleContactAvatars, sessionDetail?.wxid]) + }, [hydrateVisibleContactAvatars, isExportRoute, sessionDetail?.wxid]) useEffect(() => { + if (!isExportRoute) return if (activeTaskCount > 0) return if (filteredContacts.length === 0) return const runId = sessionMediaMetricRunIdRef.current @@ -7102,9 +7211,7 @@ function ExportPage() { scheduleSessionMediaMetricWorker() } - if (cursor < filteredContacts.length) { - sessionMediaMetricBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS) - } + // 限制后台预热规模;继续滚动时再按可见行补齐。 } feedNext() @@ -7119,11 +7226,13 @@ function ExportPage() { collectVisibleSessionMetricTargets, enqueueSessionMediaMetricRequests, filteredContacts, + isExportRoute, scheduleSessionMediaMetricWorker, sessionRowByUsername ]) useEffect(() => { + if (!isExportRoute) return if (activeTaskCount > 0) return const runId = sessionMediaMetricRunIdRef.current const allTargets = [ @@ -7133,7 +7242,6 @@ function ExportPage() { ] if (allTargets.length === 0) return - let timer: number | null = null let cursor = 0 const feedNext = () => { if (runId !== sessionMediaMetricRunIdRef.current) return @@ -7148,20 +7256,14 @@ function ExportPage() { enqueueSessionMediaMetricRequests(batchIds) scheduleSessionMediaMetricWorker() } - if (cursor < allTargets.length) { - timer = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS) - } + // 数据详情只展示受限目标;更多行由可见行加载按需补齐。 } feedNext() - return () => { - if (timer !== null) { - window.clearTimeout(timer) - } - } }, [ activeTaskCount, enqueueSessionMediaMetricRequests, + isExportRoute, loadDetailTargetsByTab.former_friend, loadDetailTargetsByTab.group, loadDetailTargetsByTab.private, @@ -7169,6 +7271,7 @@ function ExportPage() { ]) useEffect(() => { + if (!isExportRoute) return if (activeTaskCount > 0) return if (!isSessionCountStageReady || filteredContacts.length === 0) return const runId = sessionMutualFriendsRunIdRef.current @@ -7203,9 +7306,7 @@ function ExportPage() { scheduleSessionMutualFriendsWorker() } - if (cursor < filteredContacts.length) { - sessionMutualFriendsBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS) - } + // 限制后台预热规模;继续滚动时再按可见行补齐。 } feedNext() @@ -7220,6 +7321,7 @@ function ExportPage() { collectVisibleSessionMutualFriendsTargets, enqueueSessionMutualFriendsRequests, filteredContacts, + isExportRoute, isSessionCountStageReady, scheduleSessionMutualFriendsWorker, sessionRowByUsername @@ -7318,7 +7420,7 @@ function ExportPage() { const sessionId = String(sessionDetail?.wxid || '').trim() if (!sessionId || !sessionDetailSupportsSnsTimeline) return '朋友圈:0条' - if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { + if (snsUserPostCountsStatus === 'loading') { return '朋友圈:统计中...' } if (snsUserPostCountsStatus === 'error') { @@ -7761,7 +7863,7 @@ function ExportPage() { useEffect(() => { if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return if (snsUserPostCountsStatus === 'idle') { - void loadSnsUserPostCounts() + void loadSnsUserPostCounts({ cacheOnly: true }) } }, [ loadSnsUserPostCounts, @@ -7774,7 +7876,7 @@ function ExportPage() { if (!isExportRoute || !isSessionCountStageReady) return if (snsUserPostCountsStatus !== 'idle') return const timer = window.setTimeout(() => { - void loadSnsUserPostCounts() + void loadSnsUserPostCounts({ cacheOnly: true }) }, 260) return () => window.clearTimeout(timer) }, [isExportRoute, isSessionCountStageReady, loadSnsUserPostCounts, snsUserPostCountsStatus]) @@ -7789,7 +7891,7 @@ function ExportPage() { setSessionSnsTimelineStatsLoading(false) return } - if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { + if (snsUserPostCountsStatus === 'loading') { setSessionSnsTimelineStatsLoading(true) return } @@ -7843,7 +7945,7 @@ function ExportPage() { detailStatsPriorityRef.current = true setShowSessionDetailPanel(true) if (isSingleContactSession(sessionId)) { - void loadSnsUserPostCounts() + void loadSnsUserPostCounts({ cacheOnly: true }) } void loadSessionDetail(sessionId) }, [loadSessionDetail, loadSnsUserPostCounts]) @@ -7862,7 +7964,7 @@ function ExportPage() { useEffect(() => { if (!showSessionLoadDetailModal) return if (snsUserPostCountsStatus === 'idle') { - void loadSnsUserPostCounts() + void loadSnsUserPostCounts({ cacheOnly: true }) } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -8543,8 +8645,7 @@ function ExportPage() { ( snsStageStatus === 'pending' || snsStageStatus === 'loading' || - snsUserPostCountsStatus === 'loading' || - snsUserPostCountsStatus === 'idle' + snsUserPostCountsStatus === 'loading' ) ) const snsRawCount = Number(snsUserPostCounts[contact.username] || 0) @@ -8697,7 +8798,7 @@ function ExportPage() { className={`row-sns-metric-btn row-mutual-friends-btn ${isMutualFriendsLoading ? 'loading' : ''} ${hasMutualFriendsMetric ? 'ready' : ''}`} title={`查看 ${contact.displayName || contact.username} 的共同好友`} onClick={() => openSessionMutualFriendsDialog(contact)} - disabled={!hasMutualFriendsMetric} + disabled={isMutualFriendsLoading} > {isMutualFriendsLoading ? @@ -9771,6 +9872,9 @@ function ExportPage() { ? new Date(sessionLoadDetailUpdatedAt).toLocaleString('zh-CN') : '暂无'}

+

+ 后台预热仅跟踪每类前 {SESSION_DETAIL_BACKGROUND_METRIC_LIMIT_PER_TAB} 个会话;其他行滚动到可见时按需加载。 +

)} -