diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts
index 6c393a0..145f353 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 {
@@ -489,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
@@ -1114,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 = {
@@ -1125,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
@@ -1483,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,
@@ -1494,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
@@ -1528,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] = {
@@ -1548,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
}
@@ -7275,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)
@@ -7295,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()
}
@@ -7731,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
}
}
@@ -7761,7 +7777,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 +8087,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 +8122,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 +8158,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 +8177,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..0f5a962 100644
--- a/src/pages/ExportPage.tsx
+++ b/src/pages/ExportPage.tsx
@@ -232,12 +232,15 @@ 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_VISIBLE_REFRESH_LIMIT = 24
+const SESSION_MEDIA_METRIC_TAB_REFRESH_LIMIT = 96
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 +799,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 +1316,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赞/评对方'
@@ -1384,6 +1403,7 @@ interface SessionExportCacheMeta {
stale: boolean
includeRelations: boolean
source: 'memory' | 'disk' | 'fresh'
+ rangeFiltered?: boolean
}
type SessionLoadStageStatus = 'pending' | 'loading' | 'done' | 'failed'
@@ -1434,14 +1454,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
@@ -1453,7 +1506,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
@@ -1475,19 +1528,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) {
@@ -1602,6 +1656,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
@@ -2265,6 +2325,7 @@ function ExportPage() {
const [selectedSessions, setSelectedSessions] = useState>(new Set())
const [contactsList, setContactsList] = useState([])
const [isContactsListLoading, setIsContactsListLoading] = useState(true)
+ const [isRefreshingTableData, setIsRefreshingTableData] = useState(false)
const [, setContactsDataSource] = useState(null)
const [contactsUpdatedAt, setContactsUpdatedAt] = useState(null)
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState(null)
@@ -2369,27 +2430,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',
@@ -2473,6 +2513,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([])
@@ -2494,6 +2535,7 @@ function ExportPage() {
const sessionMediaMetricBackgroundFeedTimerRef = useRef(null)
const sessionMediaMetricPersistTimerRef = useRef(null)
const sessionMediaMetricPendingPersistRef = useRef>({})
+ const sessionVisibleMetricRefreshKeyRef = useRef('')
const sessionMediaMetricVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
startIndex: 0,
endIndex: -1
@@ -2513,6 +2555,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))
@@ -2626,6 +2670,10 @@ function ExportPage() {
isLoadingSessionCountsRef.current = isLoadingSessionCounts
}, [isLoadingSessionCounts])
+ useEffect(() => {
+ isExportRouteRef.current = isExportRoute
+ }, [isExportRoute])
+
useEffect(() => {
sessionContentMetricsRef.current = sessionContentMetrics
}, [sessionContentMetrics])
@@ -2909,7 +2957,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] = {
@@ -3234,7 +3282,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
@@ -3285,6 +3333,10 @@ function ExportPage() {
setSnsUserPostCountsStatus('ready')
return
}
+ if (options?.cacheOnly) {
+ setSnsUserPostCountsStatus('ready')
+ return
+ }
patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'pending', { force: true })
patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'loading')
@@ -3456,7 +3508,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) => {
@@ -3482,11 +3534,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,
@@ -3520,7 +3572,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,
@@ -3673,9 +3729,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 +3746,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 +3798,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 +3817,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,
@@ -3841,6 +3918,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()
@@ -3873,6 +3965,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[] = []
@@ -3894,13 +3987,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): {
@@ -4063,6 +4159,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[] = []
@@ -4084,7 +4181,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,25 +4193,32 @@ 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])
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
@@ -4130,6 +4237,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
@@ -4155,14 +4263,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 +4281,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
+ )
}
}
@@ -4188,6 +4302,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)
@@ -4212,17 +4330,74 @@ 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, withExportStatsRange])
+ }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage])
const scheduleSessionMediaMetricWorker = useCallback(() => {
+ if (!isExportRouteRef.current) return
if (activeTaskCountRef.current > 0) return
if (sessionMediaMetricWorkerRunningRef.current) return
const runId = sessionMediaMetricRunIdRef.current
void runSessionMediaMetricWorker(runId)
}, [runSessionMediaMetricWorker])
+ const refreshSessionMediaMetrics = useCallback(async (
+ sessionIds: string[],
+ options?: { includeRelations?: boolean; preferAccurateSpecialTypes?: boolean }
+ ): Promise => {
+ const normalizedSessionIds = Array.from(new Set(
+ (sessionIds || [])
+ .map((sessionId) => String(sessionId || '').trim())
+ .filter(Boolean)
+ ))
+ if (normalizedSessionIds.length === 0) return
+
+ const includeRelations = options?.includeRelations === true
+ const preferAccurateSpecialTypes = options?.preferAccurateSpecialTypes === true
+ sessionMediaMetricRunIdRef.current += 1
+ const runId = sessionMediaMetricRunIdRef.current
+ sessionMediaMetricQueueRef.current = sessionMediaMetricQueueRef.current.filter(
+ sessionId => !normalizedSessionIds.includes(sessionId)
+ )
+ for (const sessionId of normalizedSessionIds) {
+ sessionMediaMetricQueuedSetRef.current.delete(sessionId)
+ sessionMediaMetricReadySetRef.current.delete(sessionId)
+ }
+
+ for (let index = 0; index < normalizedSessionIds.length; index += SESSION_MEDIA_METRIC_BATCH_SIZE) {
+ if (!isExportRouteRef.current || runId !== sessionMediaMetricRunIdRef.current) return
+ const batchSessionIds = normalizedSessionIds.slice(index, index + SESSION_MEDIA_METRIC_BATCH_SIZE)
+ patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading')
+ try {
+ const result = await window.electronAPI.chat.getExportSessionStats(batchSessionIds, {
+ includeRelations,
+ forceRefresh: true,
+ preferAccurateSpecialTypes
+ })
+ if (!isExportRouteRef.current || runId !== sessionMediaMetricRunIdRef.current) return
+ if (result.success && result.data) {
+ applySessionMediaMetricsFromStats(
+ result.data as Record,
+ result.cache as Record | undefined
+ )
+ patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'done')
+ } else {
+ patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', {
+ error: result.error || '统计刷新失败'
+ })
+ }
+ } catch (error) {
+ if (!isExportRouteRef.current || runId !== sessionMediaMetricRunIdRef.current) return
+ patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', {
+ error: String(error)
+ })
+ }
+ }
+ }, [applySessionMediaMetricsFromStats, patchSessionLoadTraceStage])
+
const loadSessionMutualFriendsMetric = useCallback(async (sessionId: string): Promise => {
const normalizedSessionId = String(sessionId || '').trim()
const hasKnownTotal = Object.prototype.hasOwnProperty.call(snsUserPostCounts, normalizedSessionId)
@@ -4233,6 +4408,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,
@@ -4258,14 +4436,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
@@ -4291,6 +4471,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)
@@ -4316,6 +4500,7 @@ function ExportPage() {
])
const scheduleSessionMutualFriendsWorker = useCallback(() => {
+ if (!isExportRouteRef.current) return
if (activeTaskCountRef.current > 0) return
if (!isSessionCountStageReady) return
if (hasPendingMetricLoads()) return
@@ -4324,6 +4509,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,
@@ -4840,18 +5035,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') {
@@ -5051,9 +5236,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])
}
@@ -6785,6 +6971,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
@@ -7033,18 +7220,53 @@ function ExportPage() {
])
useEffect(() => {
+ if (!isExportRoute) return
+ if (activeTaskCount > 0) return
+ if (filteredContacts.length === 0) return
+ const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
+ .slice(0, SESSION_MEDIA_METRIC_VISIBLE_REFRESH_LIMIT)
+ if (visibleTargets.length === 0) return
+ const refreshKey = [
+ activeTab,
+ searchKeyword.trim().toLowerCase(),
+ contactsSortConfig.key,
+ contactsSortConfig.order || '',
+ visibleTargets.join(',')
+ ].join('|')
+ if (sessionVisibleMetricRefreshKeyRef.current === refreshKey) return
+ sessionVisibleMetricRefreshKeyRef.current = refreshKey
+ const timer = window.setTimeout(() => {
+ void refreshSessionMediaMetrics(visibleTargets)
+ }, 420)
+ return () => window.clearTimeout(timer)
+ }, [
+ activeTaskCount,
+ activeTab,
+ collectVisibleSessionMetricTargets,
+ contactsSortConfig.key,
+ contactsSortConfig.order,
+ filteredContacts,
+ isExportRoute,
+ refreshSessionMediaMetrics,
+ searchKeyword
+ ])
+
+ 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
@@ -7079,9 +7301,7 @@ function ExportPage() {
scheduleSessionMediaMetricWorker()
}
- if (cursor < filteredContacts.length) {
- sessionMediaMetricBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS)
- }
+ // 限制后台预热规模;继续滚动时再按可见行补齐。
}
feedNext()
@@ -7096,11 +7316,13 @@ function ExportPage() {
collectVisibleSessionMetricTargets,
enqueueSessionMediaMetricRequests,
filteredContacts,
+ isExportRoute,
scheduleSessionMediaMetricWorker,
sessionRowByUsername
])
useEffect(() => {
+ if (!isExportRoute) return
if (activeTaskCount > 0) return
const runId = sessionMediaMetricRunIdRef.current
const allTargets = [
@@ -7110,7 +7332,6 @@ function ExportPage() {
]
if (allTargets.length === 0) return
- let timer: number | null = null
let cursor = 0
const feedNext = () => {
if (runId !== sessionMediaMetricRunIdRef.current) return
@@ -7125,20 +7346,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,
@@ -7146,6 +7361,7 @@ function ExportPage() {
])
useEffect(() => {
+ if (!isExportRoute) return
if (activeTaskCount > 0) return
if (!isSessionCountStageReady || filteredContacts.length === 0) return
const runId = sessionMutualFriendsRunIdRef.current
@@ -7180,9 +7396,7 @@ function ExportPage() {
scheduleSessionMutualFriendsWorker()
}
- if (cursor < filteredContacts.length) {
- sessionMutualFriendsBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS)
- }
+ // 限制后台预热规模;继续滚动时再按可见行补齐。
}
feedNext()
@@ -7197,6 +7411,7 @@ function ExportPage() {
collectVisibleSessionMutualFriendsTargets,
enqueueSessionMutualFriendsRequests,
filteredContacts,
+ isExportRoute,
isSessionCountStageReady,
scheduleSessionMutualFriendsWorker,
sessionRowByUsername
@@ -7295,7 +7510,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') {
@@ -7326,13 +7541,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 +7587,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 +7649,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 +7671,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 +7733,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 +7760,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 +7785,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 +7820,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 +7833,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 +7851,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,81 +7871,95 @@ function ExportPage() {
setIsLoadingSessionRelationStats(false)
}
}
- }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid, withExportStatsRange])
+ }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
const handleRefreshTableData = useCallback(async () => {
+ if (isRefreshingTableData) return
const scopeKey = await ensureExportCacheScope()
+ setIsRefreshingTableData(true)
- resetSessionMutualFriendsLoader()
- sessionMutualFriendsMetricsRef.current = {}
- setSessionMutualFriendsMetrics({})
- closeSessionMutualFriendsDialog()
try {
- await configService.clearExportSessionMutualFriendsCache(scopeKey)
- } catch (error) {
- console.error('清理导出页共同好友缓存失败:', error)
- }
-
- if (isSessionCountStageReady) {
- const visibleTargetIds = collectVisibleSessionMutualFriendsTargets(filteredContacts)
- const visibleTargetSet = new Set(visibleTargetIds)
- const remainingTargetIds = sessionsRef.current
- .filter((session) => session.hasSession && isSingleContactSession(session.username) && !visibleTargetSet.has(session.username))
- .map((session) => session.username)
-
- if (visibleTargetIds.length > 0) {
- enqueueSessionMutualFriendsRequests(visibleTargetIds, { front: true })
+ resetSessionMutualFriendsLoader()
+ sessionMutualFriendsMetricsRef.current = {}
+ setSessionMutualFriendsMetrics({})
+ closeSessionMutualFriendsDialog()
+ try {
+ await configService.clearExportSessionMutualFriendsCache(scopeKey)
+ } catch (error) {
+ console.error('清理导出页共同好友缓存失败:', error)
}
- if (remainingTargetIds.length > 0) {
- enqueueSessionMutualFriendsRequests(remainingTargetIds)
+
+ if (isSessionCountStageReady) {
+ const visibleTargetIds = collectVisibleSessionMutualFriendsTargets(filteredContacts)
+ const visibleTargetSet = new Set(visibleTargetIds)
+ const remainingTargetIds = sessionsRef.current
+ .filter((session) => session.hasSession && isSingleContactSession(session.username) && !visibleTargetSet.has(session.username))
+ .map((session) => session.username)
+
+ if (visibleTargetIds.length > 0) {
+ enqueueSessionMutualFriendsRequests(visibleTargetIds, { front: true })
+ }
+ if (remainingTargetIds.length > 0) {
+ enqueueSessionMutualFriendsRequests(remainingTargetIds)
+ }
+ scheduleSessionMutualFriendsWorker()
}
- scheduleSessionMutualFriendsWorker()
- }
- // 记录刷新前的会话时间戳
- const oldTimestamps = new Map(
- sessionsRef.current.map(s => [s.username, s.lastTimestamp || s.sortTimestamp || 0])
- )
+ await Promise.all([
+ loadContactsList({ scopeKey }),
+ loadSnsStats({ full: true }),
+ loadSnsUserPostCounts({ force: true })
+ ])
- await Promise.all([
- loadContactsList({ scopeKey }),
- loadSnsStats({ full: true }),
- loadSnsUserPostCounts({ force: true })
- ])
+ const refreshedVisibleIds = collectVisibleSessionMetricTargets(filteredContacts)
+ const refreshedVisibleIdSet = new Set(refreshedVisibleIds)
+ const refreshedTabIds = sessionsRef.current
+ .filter(session => session.hasSession && session.kind === activeTabRef.current)
+ .map(session => session.username)
+ .filter((sessionId) => {
+ if (!sessionId || refreshedVisibleIdSet.has(sessionId)) return false
+ return true
+ })
+ .slice(0, SESSION_MEDIA_METRIC_TAB_REFRESH_LIMIT)
+ const refreshTargetIds = [...refreshedVisibleIds, ...refreshedTabIds]
+ .slice(0, SESSION_MEDIA_METRIC_TAB_REFRESH_LIMIT)
+ const refreshTargetSessions = refreshTargetIds
+ .map(sessionId => sessionsRef.current.find(session => session.username === sessionId))
+ .filter((session): session is SessionRow => Boolean(session))
- // 找出有变动的会话(最后消息时间变化)
- const changedSessions = sessionsRef.current.filter(session => {
- const oldTs = oldTimestamps.get(session.username) || 0
- const newTs = session.lastTimestamp || session.sortTimestamp || 0
- return newTs > oldTs
- })
+ if (refreshTargetSessions.length > 0) {
+ await loadSessionMessageCounts(refreshTargetSessions, activeTabRef.current, { scopeKey })
+ await refreshSessionMediaMetrics(refreshTargetIds)
+ }
- // 只对有变动的会话重新加载消息数量
- if (changedSessions.length > 0) {
- await loadSessionMessageCounts(changedSessions, activeTabRef.current, { scopeKey })
- }
-
- const currentDetailSessionId = showSessionDetailPanel
- ? String(sessionDetail?.wxid || '').trim()
- : ''
- if (currentDetailSessionId) {
- await loadSessionDetail(currentDetailSessionId)
- void loadSessionRelationStats({ forceRefresh: true })
+ const currentDetailSessionId = showSessionDetailPanel
+ ? String(sessionDetail?.wxid || '').trim()
+ : ''
+ if (currentDetailSessionId) {
+ await loadSessionDetail(currentDetailSessionId)
+ void loadSessionRelationStats({ forceRefresh: true })
+ }
+ } finally {
+ setIsRefreshingTableData(false)
}
}, [
closeSessionMutualFriendsDialog,
collectVisibleSessionMutualFriendsTargets,
+ collectVisibleSessionMetricTargets,
enqueueSessionMutualFriendsRequests,
ensureExportCacheScope,
filteredContacts,
+ isRefreshingTableData,
isSessionCountStageReady,
loadContactsList,
loadSessionDetail,
loadSessionRelationStats,
loadSnsStats,
loadSnsUserPostCounts,
+ refreshSessionMediaMetrics,
resetSessionMutualFriendsLoader,
scheduleSessionMutualFriendsWorker,
+ sessionRowByUsername,
showSessionDetailPanel,
sessionDetail?.wxid
])
@@ -7727,7 +7967,7 @@ function ExportPage() {
useEffect(() => {
if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return
if (snsUserPostCountsStatus === 'idle') {
- void loadSnsUserPostCounts()
+ void loadSnsUserPostCounts({ cacheOnly: true })
}
}, [
loadSnsUserPostCounts,
@@ -7740,7 +7980,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])
@@ -7755,7 +7995,7 @@ function ExportPage() {
setSessionSnsTimelineStatsLoading(false)
return
}
- if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') {
+ if (snsUserPostCountsStatus === 'loading') {
setSessionSnsTimelineStatsLoading(true)
return
}
@@ -7809,7 +8049,7 @@ function ExportPage() {
detailStatsPriorityRef.current = true
setShowSessionDetailPanel(true)
if (isSingleContactSession(sessionId)) {
- void loadSnsUserPostCounts()
+ void loadSnsUserPostCounts({ cacheOnly: true })
}
void loadSessionDetail(sessionId)
}, [loadSessionDetail, loadSnsUserPostCounts])
@@ -7828,7 +8068,7 @@ function ExportPage() {
useEffect(() => {
if (!showSessionLoadDetailModal) return
if (snsUserPostCountsStatus === 'idle') {
- void loadSnsUserPostCounts()
+ void loadSnsUserPostCounts({ cacheOnly: true })
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -8509,8 +8749,7 @@ function ExportPage() {
(
snsStageStatus === 'pending' ||
snsStageStatus === 'loading' ||
- snsUserPostCountsStatus === 'loading' ||
- snsUserPostCountsStatus === 'idle'
+ snsUserPostCountsStatus === 'loading'
)
)
const snsRawCount = Number(snsUserPostCounts[contact.username] || 0)
@@ -8663,7 +8902,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
?
@@ -9509,8 +9748,8 @@ function ExportPage() {
)}
-
+
+ 后台预热仅跟踪每类前 {SESSION_DETAIL_BACKGROUND_METRIC_LIMIT_PER_TAB} 个会话;其他行滚动到可见时按需加载。
+
needsRefresh?: string[]
error?: string