perf(export): reduce reloads when switching back

This commit is contained in:
tisonhuang
2026-03-04 16:12:24 +08:00
parent 84ef51f16b
commit 285ddeb62e
2 changed files with 358 additions and 25 deletions

View File

@@ -1115,7 +1115,7 @@
} }
.table-wrap { .table-wrap {
--contacts-message-col-width: 92px; --contacts-message-col-width: 420px;
--contacts-action-col-width: 172px; --contacts-action-col-width: 172px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -1259,6 +1259,9 @@
width: var(--contacts-message-col-width); width: var(--contacts-message-col-width);
text-align: right; text-align: right;
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.contacts-list-header-actions { .contacts-list-header-actions {
@@ -1378,26 +1381,57 @@
width: var(--contacts-message-col-width); width: var(--contacts-message-col-width);
min-width: var(--contacts-message-col-width); min-width: var(--contacts-message-col-width);
display: flex; display: flex;
align-items: flex-end; align-items: center;
justify-content: center; justify-content: flex-end;
flex-shrink: 0; flex-shrink: 0;
text-align: right; text-align: right;
} }
.row-message-stats {
width: 100%;
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 8px;
white-space: nowrap;
}
.row-message-stat {
display: inline-flex;
align-items: baseline;
gap: 3px;
font-size: 11px;
color: var(--text-secondary);
min-width: 0;
.label {
color: var(--text-tertiary);
flex-shrink: 0;
}
&.total .label {
color: var(--text-secondary);
}
}
.row-message-count-value { .row-message-count-value {
margin: 0; margin: 0;
font-size: 13px; font-size: 12px;
line-height: 1.2; line-height: 1.1;
color: var(--text-primary); color: var(--text-primary);
font-weight: 600; font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
&.muted { &.muted {
font-size: 12px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
} }
.row-message-stat.total .row-message-count-value {
font-size: 13px;
}
} }
.table-virtuoso { .table-virtuoso {
@@ -2317,7 +2351,7 @@
@media (max-width: 720px) { @media (max-width: 720px) {
.table-wrap { .table-wrap {
--contacts-message-col-width: 66px; --contacts-message-col-width: 280px;
--contacts-action-col-width: 148px; --contacts-action-col-width: 148px;
} }
@@ -2334,6 +2368,22 @@
min-width: var(--contacts-message-col-width); min-width: var(--contacts-message-col-width);
} }
.table-wrap .row-message-stats {
gap: 6px;
}
.table-wrap .row-message-stat {
font-size: 10px;
}
.table-wrap .row-message-count-value {
font-size: 11px;
}
.table-wrap .row-message-stat.total .row-message-count-value {
font-size: 12px;
}
.diag-panel-header { .diag-panel-header {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;

View File

@@ -468,6 +468,9 @@ const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
const EXPORT_CARD_DIAG_MAX_FRONTEND_LOGS = 1500 const EXPORT_CARD_DIAG_MAX_FRONTEND_LOGS = 1500
const EXPORT_CARD_DIAG_STALL_MS = 3200 const EXPORT_CARD_DIAG_STALL_MS = 3200
const EXPORT_CARD_DIAG_POLL_INTERVAL_MS = 1200 const EXPORT_CARD_DIAG_POLL_INTERVAL_MS = 1200
const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
type SessionDataSource = 'cache' | 'network' | null type SessionDataSource = 'cache' | 'network' | null
type ContactsDataSource = 'cache' | 'network' | null type ContactsDataSource = 'cache' | 'network' | null
@@ -528,6 +531,14 @@ interface SessionExportMetric {
groupMutualFriends?: number groupMutualFriends?: number
} }
interface SessionContentMetric {
totalMessages?: number
voiceMessages?: number
imageMessages?: number
videoMessages?: number
emojiMessages?: number
}
interface SessionExportCacheMeta { interface SessionExportCacheMeta {
updatedAt: number updatedAt: number
stale: boolean stale: boolean
@@ -856,6 +867,8 @@ function ExportPage() {
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null) const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
const [sessionMessageCounts, setSessionMessageCounts] = useState<Record<string, number>>({}) const [sessionMessageCounts, setSessionMessageCounts] = useState<Record<string, number>>({})
const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false) const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false)
const [sessionContentMetrics, setSessionContentMetrics] = useState<Record<string, SessionContentMetric>>({})
const [isLoadingSessionContentStats, setIsLoadingSessionContentStats] = useState(false)
const [contactsListScrollTop, setContactsListScrollTop] = useState(0) const [contactsListScrollTop, setContactsListScrollTop] = useState(0)
const [contactsListViewportHeight, setContactsListViewportHeight] = useState(480) const [contactsListViewportHeight, setContactsListViewportHeight] = useState(480)
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
@@ -942,10 +955,16 @@ function ExportPage() {
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({}) const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
const contactsListRef = useRef<HTMLDivElement>(null) const contactsListRef = useRef<HTMLDivElement>(null)
const detailRequestSeqRef = useRef(0) const detailRequestSeqRef = useRef(0)
const sessionsRef = useRef<SessionRow[]>([])
const contactsListSizeRef = useRef(0)
const contactsUpdatedAtRef = useRef<number | null>(null)
const sessionsHydratedAtRef = useRef(0)
const snsStatsHydratedAtRef = useRef(0)
const inProgressSessionIdsRef = useRef<string[]>([]) const inProgressSessionIdsRef = useRef<string[]>([])
const activeTaskCountRef = useRef(0) const activeTaskCountRef = useRef(0)
const hasBaseConfigReadyRef = useRef(false) const hasBaseConfigReadyRef = useRef(false)
const sessionCountRequestIdRef = useRef(0) const sessionCountRequestIdRef = useRef(0)
const sessionContentStatsRequestIdRef = useRef(0)
const activeTabRef = useRef<ConversationTab>('private') const activeTabRef = useRef<ConversationTab>('private')
const appendFrontendDiagLog = useCallback((entry: ExportCardDiagLogEntry) => { const appendFrontendDiagLog = useCallback((entry: ExportCardDiagLogEntry) => {
@@ -1175,11 +1194,15 @@ function ExportPage() {
void (async () => { void (async () => {
const scopeKey = await ensureExportCacheScope() const scopeKey = await ensureExportCacheScope()
if (cancelled) return if (cancelled) return
let cachedContactsCount = 0
let cachedContactsUpdatedAt = 0
try { try {
const [cacheItem, avatarCacheItem] = await Promise.all([ const [cacheItem, avatarCacheItem] = await Promise.all([
configService.getContactsListCache(scopeKey), configService.getContactsListCache(scopeKey),
configService.getContactsAvatarCache(scopeKey) configService.getContactsAvatarCache(scopeKey)
]) ])
cachedContactsCount = Array.isArray(cacheItem?.contacts) ? cacheItem.contacts.length : 0
cachedContactsUpdatedAt = Number(cacheItem?.updatedAt || 0)
const avatarCacheMap = avatarCacheItem?.avatars || {} const avatarCacheMap = avatarCacheItem?.avatars || {}
contactsAvatarCacheRef.current = avatarCacheMap contactsAvatarCacheRef.current = avatarCacheMap
setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null) setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null)
@@ -1198,7 +1221,15 @@ function ExportPage() {
console.error('读取导出页联系人缓存失败:', error) console.error('读取导出页联系人缓存失败:', error)
} }
if (!cancelled) { const latestContactsUpdatedAt = Math.max(
Number(contactsUpdatedAtRef.current || 0),
cachedContactsUpdatedAt
)
const hasFreshContactSnapshot = (contactsListSizeRef.current > 0 || cachedContactsCount > 0) &&
latestContactsUpdatedAt > 0 &&
Date.now() - latestContactsUpdatedAt <= EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS
if (!cancelled && !hasFreshContactSnapshot) {
void loadContactsList({ scopeKey }) void loadContactsList({ scopeKey })
} }
})() })()
@@ -1238,6 +1269,18 @@ function ExportPage() {
tasksRef.current = tasks tasksRef.current = tasks
}, [tasks]) }, [tasks])
useEffect(() => {
sessionsRef.current = sessions
}, [sessions])
useEffect(() => {
contactsListSizeRef.current = contactsList.length
}, [contactsList.length])
useEffect(() => {
contactsUpdatedAtRef.current = contactsUpdatedAt
}, [contactsUpdatedAt])
useEffect(() => { useEffect(() => {
if (!expandedPerfTaskId) return if (!expandedPerfTaskId) return
const target = tasks.find(task => task.id === expandedPerfTaskId) const target = tasks.find(task => task.id === expandedPerfTaskId)
@@ -1314,6 +1357,7 @@ function ExportPage() {
totalPosts: cachedSnsStats.totalPosts || 0, totalPosts: cachedSnsStats.totalPosts || 0,
totalFriends: cachedSnsStats.totalFriends || 0 totalFriends: cachedSnsStats.totalFriends || 0
}) })
snsStatsHydratedAtRef.current = Date.now()
hasSeededSnsStatsRef.current = true hasSeededSnsStatsRef.current = true
setHasSeededSnsStats(true) setHasSeededSnsStats(true)
} }
@@ -1352,6 +1396,7 @@ function ExportPage() {
totalFriends: Number.isFinite(next.totalFriends) ? Math.max(0, Math.floor(next.totalFriends)) : 0 totalFriends: Number.isFinite(next.totalFriends) ? Math.max(0, Math.floor(next.totalFriends)) : 0
} }
setSnsStats(normalized) setSnsStats(normalized)
snsStatsHydratedAtRef.current = Date.now()
hasSeededSnsStatsRef.current = true hasSeededSnsStatsRef.current = true
setHasSeededSnsStats(true) setHasSeededSnsStats(true)
if (exportCacheScopeReadyRef.current) { if (exportCacheScopeReadyRef.current) {
@@ -1389,6 +1434,139 @@ function ExportPage() {
} }
}, []) }, [])
const mergeSessionContentMetrics = useCallback((input: Record<string, SessionExportMetric | SessionContentMetric | undefined>) => {
const entries = Object.entries(input)
if (entries.length === 0) return
const nextMessageCounts: Record<string, number> = {}
const nextMetrics: Record<string, SessionContentMetric> = {}
for (const [sessionIdRaw, metricRaw] of entries) {
const sessionId = String(sessionIdRaw || '').trim()
if (!sessionId || !metricRaw) continue
const totalMessages = normalizeMessageCount(metricRaw.totalMessages)
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
if (
typeof totalMessages !== 'number' &&
typeof voiceMessages !== 'number' &&
typeof imageMessages !== 'number' &&
typeof videoMessages !== 'number' &&
typeof emojiMessages !== 'number'
) {
continue
}
nextMetrics[sessionId] = {
totalMessages,
voiceMessages,
imageMessages,
videoMessages,
emojiMessages
}
if (typeof totalMessages === 'number') {
nextMessageCounts[sessionId] = totalMessages
}
}
if (Object.keys(nextMessageCounts).length > 0) {
setSessionMessageCounts(prev => {
let changed = false
const merged = { ...prev }
for (const [sessionId, count] of Object.entries(nextMessageCounts)) {
if (merged[sessionId] === count) continue
merged[sessionId] = count
changed = true
}
return changed ? merged : prev
})
}
if (Object.keys(nextMetrics).length > 0) {
setSessionContentMetrics(prev => {
let changed = false
const merged = { ...prev }
for (const [sessionId, metric] of Object.entries(nextMetrics)) {
const previous = merged[sessionId] || {}
const nextMetric: SessionContentMetric = {
totalMessages: typeof metric.totalMessages === 'number' ? metric.totalMessages : previous.totalMessages,
voiceMessages: typeof metric.voiceMessages === 'number' ? metric.voiceMessages : previous.voiceMessages,
imageMessages: typeof metric.imageMessages === 'number' ? metric.imageMessages : previous.imageMessages,
videoMessages: typeof metric.videoMessages === 'number' ? metric.videoMessages : previous.videoMessages,
emojiMessages: typeof metric.emojiMessages === 'number' ? metric.emojiMessages : previous.emojiMessages
}
if (
previous.totalMessages === nextMetric.totalMessages &&
previous.voiceMessages === nextMetric.voiceMessages &&
previous.imageMessages === nextMetric.imageMessages &&
previous.videoMessages === nextMetric.videoMessages &&
previous.emojiMessages === nextMetric.emojiMessages
) {
continue
}
merged[sessionId] = nextMetric
changed = true
}
return changed ? merged : prev
})
}
}, [])
const loadSessionContentStats = useCallback(async (
sourceSessions: SessionRow[],
priorityTab: ConversationTab
) => {
const requestId = sessionContentStatsRequestIdRef.current + 1
sessionContentStatsRequestIdRef.current = requestId
const isStale = () => sessionContentStatsRequestIdRef.current !== requestId
const exportableSessions = sourceSessions.filter(session => session.hasSession)
if (exportableSessions.length === 0) {
setIsLoadingSessionContentStats(false)
return
}
const prioritizedSessionIds = exportableSessions
.filter(session => session.kind === priorityTab)
.map(session => session.username)
const prioritizedSet = new Set(prioritizedSessionIds)
const remainingSessionIds = exportableSessions
.filter(session => !prioritizedSet.has(session.username))
.map(session => session.username)
const orderedSessionIds = [...prioritizedSessionIds, ...remainingSessionIds]
if (orderedSessionIds.length === 0) {
setIsLoadingSessionContentStats(false)
return
}
setIsLoadingSessionContentStats(true)
try {
const chunkSize = 80
for (let i = 0; i < orderedSessionIds.length; i += chunkSize) {
const chunk = orderedSessionIds.slice(i, i + chunkSize)
if (chunk.length === 0) continue
const result = await window.electronAPI.chat.getExportSessionStats(
chunk,
{ includeRelations: false, allowStaleCache: true }
)
if (isStale()) return
if (result.success && result.data) {
mergeSessionContentMetrics(result.data as Record<string, SessionExportMetric | undefined>)
}
}
} catch (error) {
console.error('导出页加载会话内容统计失败:', error)
} finally {
if (!isStale()) {
setIsLoadingSessionContentStats(false)
}
}
}, [mergeSessionContentMetrics])
const loadSessionMessageCounts = useCallback(async ( const loadSessionMessageCounts = useCallback(async (
sourceSessions: SessionRow[], sourceSessions: SessionRow[],
priorityTab: ConversationTab priorityTab: ConversationTab
@@ -1406,6 +1584,14 @@ function ExportPage() {
return acc return acc
}, {}) }, {})
setSessionMessageCounts(seededHintCounts) setSessionMessageCounts(seededHintCounts)
if (Object.keys(seededHintCounts).length > 0) {
mergeSessionContentMetrics(
Object.entries(seededHintCounts).reduce<Record<string, SessionContentMetric>>((acc, [sessionId, count]) => {
acc[sessionId] = { totalMessages: count }
return acc
}, {})
)
}
if (exportableSessions.length === 0) { if (exportableSessions.length === 0) {
setIsLoadingSessionCounts(false) setIsLoadingSessionCounts(false)
@@ -1431,6 +1617,12 @@ function ExportPage() {
}, {}) }, {})
if (Object.keys(normalized).length === 0) return if (Object.keys(normalized).length === 0) return
setSessionMessageCounts(prev => ({ ...prev, ...normalized })) setSessionMessageCounts(prev => ({ ...prev, ...normalized }))
mergeSessionContentMetrics(
Object.entries(normalized).reduce<Record<string, SessionContentMetric>>((acc, [sessionId, count]) => {
acc[sessionId] = { totalMessages: count }
return acc
}, {})
)
} }
setIsLoadingSessionCounts(true) setIsLoadingSessionCounts(true)
@@ -1457,16 +1649,20 @@ function ExportPage() {
setIsLoadingSessionCounts(false) setIsLoadingSessionCounts(false)
} }
} }
}, []) }, [mergeSessionContentMetrics])
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
const loadToken = Date.now() const loadToken = Date.now()
sessionLoadTokenRef.current = loadToken sessionLoadTokenRef.current = loadToken
sessionsHydratedAtRef.current = 0
setIsLoading(true) setIsLoading(true)
setIsSessionEnriching(false) setIsSessionEnriching(false)
sessionCountRequestIdRef.current += 1 sessionCountRequestIdRef.current += 1
sessionContentStatsRequestIdRef.current += 1
setSessionMessageCounts({}) setSessionMessageCounts({})
setSessionContentMetrics({})
setIsLoadingSessionCounts(false) setIsLoadingSessionCounts(false)
setIsLoadingSessionContentStats(false)
const isStale = () => sessionLoadTokenRef.current !== loadToken const isStale = () => sessionLoadTokenRef.current !== loadToken
@@ -1508,7 +1704,12 @@ function ExportPage() {
if (isStale()) return if (isStale()) return
setSessions(baseSessions) setSessions(baseSessions)
void loadSessionMessageCounts(baseSessions, activeTabRef.current) sessionsHydratedAtRef.current = Date.now()
void (async () => {
await loadSessionMessageCounts(baseSessions, activeTabRef.current)
if (isStale()) return
await loadSessionContentStats(baseSessions, activeTabRef.current)
})()
setSessionDataSource(cachedContacts.length > 0 ? 'cache' : 'network') setSessionDataSource(cachedContacts.length > 0 ? 'cache' : 'network')
if (cachedContacts.length === 0) { if (cachedContacts.length === 0) {
setSessionContactsUpdatedAt(Date.now()) setSessionContactsUpdatedAt(Date.now())
@@ -1670,6 +1871,7 @@ function ExportPage() {
const persistAt = Date.now() const persistAt = Date.now()
setSessions(nextSessions) setSessions(nextSessions)
sessionsHydratedAtRef.current = persistAt
if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) { if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) {
await configService.setContactsListCache(scopeKey, contactsCachePayload) await configService.setContactsListCache(scopeKey, contactsCachePayload)
setSessionContactsUpdatedAt(persistAt) setSessionContactsUpdatedAt(persistAt)
@@ -1696,17 +1898,28 @@ function ExportPage() {
} finally { } finally {
if (!isStale()) setIsLoading(false) if (!isStale()) setIsLoading(false)
} }
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, syncContactTypeCounts]) }, [ensureExportCacheScope, loadContactsCaches, loadSessionContentStats, loadSessionMessageCounts, syncContactTypeCounts])
useEffect(() => { useEffect(() => {
if (!isExportRoute) return if (!isExportRoute) return
const now = Date.now()
const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current &&
sessionsRef.current.length > 0 &&
now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS
const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current &&
now - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS
void loadBaseConfig() void loadBaseConfig()
void ensureSharedTabCountsLoaded() void ensureSharedTabCountsLoaded()
if (!hasFreshSessionSnapshot) {
void loadSessions() void loadSessions()
}
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
if (!hasFreshSnsSnapshot) {
void loadSnsStats({ full: true }) void loadSnsStats({ full: true })
}
}, 120) }, 120)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
@@ -1726,8 +1939,10 @@ function ExportPage() {
// 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。 // 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。
sessionLoadTokenRef.current = Date.now() sessionLoadTokenRef.current = Date.now()
sessionCountRequestIdRef.current += 1 sessionCountRequestIdRef.current += 1
sessionContentStatsRequestIdRef.current += 1
setIsSessionEnriching(false) setIsSessionEnriching(false)
setIsLoadingSessionCounts(false) setIsLoadingSessionCounts(false)
setIsLoadingSessionContentStats(false)
}, [isExportRoute]) }, [isExportRoute])
useEffect(() => { useEffect(() => {
@@ -2840,6 +3055,7 @@ function ExportPage() {
cacheMeta?: SessionExportCacheMeta, cacheMeta?: SessionExportCacheMeta,
relationLoadedOverride?: boolean relationLoadedOverride?: boolean
) => { ) => {
mergeSessionContentMetrics({ [sessionId]: metric })
setSessionDetail((prev) => { setSessionDetail((prev) => {
if (!prev || prev.wxid !== sessionId) return prev if (!prev || prev.wxid !== sessionId) return prev
const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded) const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded)
@@ -2866,7 +3082,7 @@ function ExportPage() {
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime
} }
}) })
}, []) }, [mergeSessionContentMetrics])
const loadSessionDetail = useCallback(async (sessionId: string) => { const loadSessionDetail = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim() const normalizedSessionId = String(sessionId || '').trim()
@@ -2875,11 +3091,17 @@ function ExportPage() {
const requestSeq = ++detailRequestSeqRef.current const requestSeq = ++detailRequestSeqRef.current
const mappedSession = sessionRowByUsername.get(normalizedSessionId) const mappedSession = sessionRowByUsername.get(normalizedSessionId)
const mappedContact = contactByUsername.get(normalizedSessionId) const mappedContact = contactByUsername.get(normalizedSessionId)
const cachedMetric = sessionContentMetrics[normalizedSessionId]
const countedCount = normalizeMessageCount(sessionMessageCounts[normalizedSessionId]) const countedCount = normalizeMessageCount(sessionMessageCounts[normalizedSessionId])
const metricCount = normalizeMessageCount(cachedMetric?.totalMessages)
const metricVoice = normalizeMessageCount(cachedMetric?.voiceMessages)
const metricImage = normalizeMessageCount(cachedMetric?.imageMessages)
const metricVideo = normalizeMessageCount(cachedMetric?.videoMessages)
const metricEmoji = normalizeMessageCount(cachedMetric?.emojiMessages)
const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0 const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0
? Math.floor(mappedSession.messageCountHint) ? Math.floor(mappedSession.messageCountHint)
: undefined : undefined
const initialMessageCount = countedCount ?? hintedCount const initialMessageCount = countedCount ?? metricCount ?? hintedCount
setCopiedDetailField(null) setCopiedDetailField(null)
setIsRefreshingSessionDetailStats(false) setIsRefreshingSessionDetailStats(false)
@@ -2894,10 +3116,10 @@ function ExportPage() {
alias: sameSession ? prev?.alias : undefined, alias: sameSession ? prev?.alias : undefined,
avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined),
messageCount: initialMessageCount ?? (sameSession ? prev.messageCount : Number.NaN), messageCount: initialMessageCount ?? (sameSession ? prev.messageCount : Number.NaN),
voiceMessages: sameSession ? prev?.voiceMessages : undefined, voiceMessages: metricVoice ?? (sameSession ? prev?.voiceMessages : undefined),
imageMessages: sameSession ? prev?.imageMessages : undefined, imageMessages: metricImage ?? (sameSession ? prev?.imageMessages : undefined),
videoMessages: sameSession ? prev?.videoMessages : undefined, videoMessages: metricVideo ?? (sameSession ? prev?.videoMessages : undefined),
emojiMessages: sameSession ? prev?.emojiMessages : undefined, emojiMessages: metricEmoji ?? (sameSession ? prev?.emojiMessages : undefined),
privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined, privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined,
groupMemberCount: sameSession ? prev?.groupMemberCount : undefined, groupMemberCount: sameSession ? prev?.groupMemberCount : undefined,
groupMyMessages: sameSession ? prev?.groupMyMessages : undefined, groupMyMessages: sameSession ? prev?.groupMyMessages : undefined,
@@ -3037,7 +3259,7 @@ function ExportPage() {
setIsLoadingSessionDetailExtra(false) setIsLoadingSessionDetailExtra(false)
} }
} }
}, [applySessionDetailStats, contactByUsername, sessionMessageCounts, sessionRowByUsername]) }, [applySessionDetailStats, contactByUsername, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername])
const loadSessionRelationStats = useCallback(async () => { const loadSessionRelationStats = useCallback(async () => {
const normalizedSessionId = String(sessionDetail?.wxid || '').trim() const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
@@ -3909,6 +4131,12 @@ function ExportPage() {
</span> </span>
)} )}
{isLoadingSessionContentStats && (
<span className="meta-item syncing">
<Loader2 size={12} className="spin" />
///
</span>
)}
</div> </div>
{contactsList.length > 0 && isContactsListLoading && ( {contactsList.length > 0 && isContactsListLoading && (
@@ -3965,7 +4193,7 @@ function ExportPage() {
<> <>
<div className="contacts-list-header"> <div className="contacts-list-header">
<span className="contacts-list-header-main">//</span> <span className="contacts-list-header-main">//</span>
<span className="contacts-list-header-count"></span> <span className="contacts-list-header-count"> | | | | </span>
<span className="contacts-list-header-actions"></span> <span className="contacts-list-header-actions"></span>
</div> </div>
<div className="contacts-list" ref={contactsListRef} onScroll={onContactsListScroll}> <div className="contacts-list" ref={contactsListRef} onScroll={onContactsListScroll}>
@@ -3982,14 +4210,40 @@ function ExportPage() {
const isQueued = canExport && queuedSessionIds.has(contact.username) const isQueued = canExport && queuedSessionIds.has(contact.username)
const isPaused = canExport && pausedSessionIds.has(contact.username) const isPaused = canExport && pausedSessionIds.has(contact.username)
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
const contentMetric = sessionContentMetrics[contact.username]
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
const metricMessages = normalizeMessageCount(contentMetric?.totalMessages)
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
const displayedMessageCount = countedMessages ?? hintedMessages const displayedMessageCount = countedMessages ?? metricMessages ?? hintedMessages
const displayedImageCount = normalizeMessageCount(contentMetric?.imageMessages)
const displayedVoiceCount = normalizeMessageCount(contentMetric?.voiceMessages)
const displayedEmojiCount = normalizeMessageCount(contentMetric?.emojiMessages)
const displayedVideoCount = normalizeMessageCount(contentMetric?.videoMessages)
const messageCountLabel = !canExport const messageCountLabel = !canExport
? '--' ? '--'
: typeof displayedMessageCount === 'number' : typeof displayedMessageCount === 'number'
? displayedMessageCount.toLocaleString('zh-CN') ? displayedMessageCount.toLocaleString('zh-CN')
: (isLoadingSessionCounts ? '统计中…' : '--') : (isLoadingSessionCounts ? '统计中…' : '--')
const imageCountLabel = !canExport
? '--'
: typeof displayedImageCount === 'number'
? displayedImageCount.toLocaleString('zh-CN')
: (isLoadingSessionContentStats ? '统计中…' : '0')
const voiceCountLabel = !canExport
? '--'
: typeof displayedVoiceCount === 'number'
? displayedVoiceCount.toLocaleString('zh-CN')
: (isLoadingSessionContentStats ? '统计中…' : '0')
const emojiCountLabel = !canExport
? '--'
: typeof displayedEmojiCount === 'number'
? displayedEmojiCount.toLocaleString('zh-CN')
: (isLoadingSessionContentStats ? '统计中…' : '0')
const videoCountLabel = !canExport
? '--'
: typeof displayedVideoCount === 'number'
? displayedVideoCount.toLocaleString('zh-CN')
: (isLoadingSessionContentStats ? '统计中…' : '0')
return ( return (
<div <div
key={contact.username} key={contact.username}
@@ -4009,9 +4263,38 @@ function ExportPage() {
<div className="contact-remark">{contact.username}</div> <div className="contact-remark">{contact.username}</div>
</div> </div>
<div className="row-message-count"> <div className="row-message-count">
<div className="row-message-stats">
<span className="row-message-stat total">
<span className="label"></span>
<strong className={`row-message-count-value ${typeof displayedMessageCount === 'number' ? '' : 'muted'}`}> <strong className={`row-message-count-value ${typeof displayedMessageCount === 'number' ? '' : 'muted'}`}>
{messageCountLabel} {messageCountLabel}
</strong> </strong>
</span>
<span className="row-message-stat">
<span className="label"></span>
<strong className={`row-message-count-value ${typeof displayedImageCount === 'number' ? '' : 'muted'}`}>
{imageCountLabel}
</strong>
</span>
<span className="row-message-stat">
<span className="label"></span>
<strong className={`row-message-count-value ${typeof displayedVoiceCount === 'number' ? '' : 'muted'}`}>
{voiceCountLabel}
</strong>
</span>
<span className="row-message-stat">
<span className="label"></span>
<strong className={`row-message-count-value ${typeof displayedEmojiCount === 'number' ? '' : 'muted'}`}>
{emojiCountLabel}
</strong>
</span>
<span className="row-message-stat">
<span className="label"></span>
<strong className={`row-message-count-value ${typeof displayedVideoCount === 'number' ? '' : 'muted'}`}>
{videoCountLabel}
</strong>
</span>
</div>
</div> </div>
<div className="row-action-cell"> <div className="row-action-cell">
<div className="row-action-main"> <div className="row-action-main">