feat(export): add 4 media columns with visible-first staged loading

This commit is contained in:
aits2026
2026-03-05 16:28:18 +08:00
parent b3dd0e25fa
commit e050402787
2 changed files with 446 additions and 4 deletions

View File

@@ -995,6 +995,7 @@
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows)); --contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
--contacts-select-col-width: 34px; --contacts-select-col-width: 34px;
--contacts-message-col-width: 120px; --contacts-message-col-width: 120px;
--contacts-media-col-width: 72px;
--contacts-action-col-width: 280px; --contacts-action-col-width: 280px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -1167,6 +1168,16 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.contacts-list-header-media {
width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width);
text-align: center;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contacts-list-header-actions { .contacts-list-header-actions {
width: var(--contacts-action-col-width); width: var(--contacts-action-col-width);
display: flex; display: flex;
@@ -1355,6 +1366,28 @@
text-align: center; text-align: center;
} }
.row-media-metric {
width: var(--contacts-media-col-width);
min-width: var(--contacts-media-col-width);
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
text-align: center;
}
.row-media-metric-value {
margin: 0;
font-size: 12px;
line-height: 1.2;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
&.loading {
color: var(--text-tertiary);
}
}
.row-message-stats { .row-message-stats {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -2661,6 +2694,7 @@
.table-wrap { .table-wrap {
--contacts-message-col-width: 104px; --contacts-message-col-width: 104px;
--contacts-media-col-width: 62px;
--contacts-action-col-width: 236px; --contacts-action-col-width: 236px;
} }
@@ -2687,6 +2721,10 @@
min-width: var(--contacts-message-col-width); min-width: var(--contacts-message-col-width);
} }
.table-wrap .row-media-metric {
min-width: var(--contacts-media-col-width);
}
.table-wrap .row-message-stats { .table-wrap .row-message-stats {
gap: 6px; gap: 6px;
} }
@@ -2699,6 +2737,10 @@
font-size: 11px; font-size: 11px;
} }
.table-wrap .row-media-metric-value {
font-size: 11px;
}
.table-wrap .row-message-stat.total .row-message-count-value { .table-wrap .row-message-stat.total .row-message-count-value {
font-size: 12px; font-size: 12px;
} }

View File

@@ -164,6 +164,11 @@ interface TimeRangeDialogDraft {
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
const SESSION_MEDIA_METRIC_BATCH_SIZE = 12
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 contentTypeLabels: Record<ContentType, string> = { const contentTypeLabels: Record<ContentType, string> = {
text: '聊天文本', text: '聊天文本',
voice: '语音', voice: '语音',
@@ -870,6 +875,40 @@ const normalizeMessageCount = (value: unknown): number | undefined => {
return Math.floor(parsed) return Math.floor(parsed)
} }
const pickSessionMediaMetric = (
metricRaw: SessionExportMetric | SessionContentMetric | undefined
): SessionContentMetric | null => {
if (!metricRaw) return null
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
if (
typeof voiceMessages !== 'number' &&
typeof imageMessages !== 'number' &&
typeof videoMessages !== 'number' &&
typeof emojiMessages !== 'number'
) {
return null
}
return {
voiceMessages,
imageMessages,
videoMessages,
emojiMessages
}
}
const hasCompleteSessionMediaMetric = (metricRaw: SessionContentMetric | undefined): boolean => {
if (!metricRaw) return false
return (
typeof normalizeMessageCount(metricRaw.voiceMessages) === 'number' &&
typeof normalizeMessageCount(metricRaw.imageMessages) === 'number' &&
typeof normalizeMessageCount(metricRaw.videoMessages) === 'number' &&
typeof normalizeMessageCount(metricRaw.emojiMessages) === 'number'
)
}
const WriteLayoutSelector = memo(function WriteLayoutSelector({ const WriteLayoutSelector = memo(function WriteLayoutSelector({
writeLayout, writeLayout,
onChange, onChange,
@@ -1209,6 +1248,7 @@ 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 [isSessionCountStageReady, setIsSessionCountStageReady] = useState(false)
const [sessionContentMetrics, setSessionContentMetrics] = useState<Record<string, SessionContentMetric>>({}) const [sessionContentMetrics, setSessionContentMetrics] = useState<Record<string, SessionContentMetric>>({})
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const [contactsLoadSession, setContactsLoadSession] = useState<ContactsLoadSession | null>(null) const [contactsLoadSession, setContactsLoadSession] = useState<ContactsLoadSession | null>(null)
@@ -1299,6 +1339,7 @@ function ExportPage() {
const sessionTableSectionRef = useRef<HTMLDivElement | null>(null) const sessionTableSectionRef = useRef<HTMLDivElement | null>(null)
const detailRequestSeqRef = useRef(0) const detailRequestSeqRef = useRef(0)
const sessionsRef = useRef<SessionRow[]>([]) const sessionsRef = useRef<SessionRow[]>([])
const sessionContentMetricsRef = useRef<Record<string, SessionContentMetric>>({})
const contactsListSizeRef = useRef(0) const contactsListSizeRef = useRef(0)
const contactsUpdatedAtRef = useRef<number | null>(null) const contactsUpdatedAtRef = useRef<number | null>(null)
const sessionsHydratedAtRef = useRef(0) const sessionsHydratedAtRef = useRef(0)
@@ -1307,9 +1348,23 @@ function ExportPage() {
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 isLoadingSessionCountsRef = useRef(false)
const activeTabRef = useRef<ConversationTab>('private') const activeTabRef = useRef<ConversationTab>('private')
const detailStatsPriorityRef = useRef(false) const detailStatsPriorityRef = useRef(false)
const sessionPreciseRefreshAtRef = useRef<Record<string, number>>({}) const sessionPreciseRefreshAtRef = useRef<Record<string, number>>({})
const sessionMediaMetricQueueRef = useRef<string[]>([])
const sessionMediaMetricQueuedSetRef = useRef<Set<string>>(new Set())
const sessionMediaMetricLoadingSetRef = useRef<Set<string>>(new Set())
const sessionMediaMetricReadySetRef = useRef<Set<string>>(new Set())
const sessionMediaMetricRunIdRef = useRef(0)
const sessionMediaMetricWorkerRunningRef = useRef(false)
const sessionMediaMetricBackgroundFeedTimerRef = useRef<number | null>(null)
const sessionMediaMetricPersistTimerRef = useRef<number | null>(null)
const sessionMediaMetricPendingPersistRef = useRef<Record<string, configService.ExportSessionContentMetricCacheEntry>>({})
const sessionMediaMetricVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
startIndex: 0,
endIndex: -1
})
const ensureExportCacheScope = useCallback(async (): Promise<string> => { const ensureExportCacheScope = useCallback(async (): Promise<string> => {
if (exportCacheScopeReadyRef.current) { if (exportCacheScopeReadyRef.current) {
@@ -1359,6 +1414,14 @@ function ExportPage() {
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
}, [contactsLoadTimeoutMs]) }, [contactsLoadTimeoutMs])
useEffect(() => {
isLoadingSessionCountsRef.current = isLoadingSessionCounts
}, [isLoadingSessionCounts])
useEffect(() => {
sessionContentMetricsRef.current = sessionContentMetrics
}, [sessionContentMetrics])
const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => { const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => {
const scopeKey = options?.scopeKey || await ensureExportCacheScope() const scopeKey = options?.scopeKey || await ensureExportCacheScope()
const loadVersion = contactsLoadVersionRef.current + 1 const loadVersion = contactsLoadVersionRef.current + 1
@@ -1821,6 +1884,184 @@ function ExportPage() {
} }
}, []) }, [])
const resetSessionMediaMetricLoader = useCallback(() => {
sessionMediaMetricRunIdRef.current += 1
sessionMediaMetricQueueRef.current = []
sessionMediaMetricQueuedSetRef.current.clear()
sessionMediaMetricLoadingSetRef.current.clear()
sessionMediaMetricReadySetRef.current.clear()
sessionMediaMetricWorkerRunningRef.current = false
sessionMediaMetricPendingPersistRef.current = {}
sessionMediaMetricVisibleRangeRef.current = { startIndex: 0, endIndex: -1 }
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
sessionMediaMetricBackgroundFeedTimerRef.current = null
}
if (sessionMediaMetricPersistTimerRef.current) {
window.clearTimeout(sessionMediaMetricPersistTimerRef.current)
sessionMediaMetricPersistTimerRef.current = null
}
}, [])
const flushSessionMediaMetricCache = useCallback(async () => {
const pendingMetrics = sessionMediaMetricPendingPersistRef.current
sessionMediaMetricPendingPersistRef.current = {}
if (Object.keys(pendingMetrics).length === 0) return
try {
const scopeKey = await ensureExportCacheScope()
const existing = await configService.getExportSessionContentMetricCache(scopeKey)
const nextMetrics = {
...(existing?.metrics || {}),
...pendingMetrics
}
await configService.setExportSessionContentMetricCache(scopeKey, nextMetrics)
} catch (error) {
console.error('写入导出页会话内容统计缓存失败:', error)
}
}, [ensureExportCacheScope])
const scheduleFlushSessionMediaMetricCache = useCallback(() => {
if (sessionMediaMetricPersistTimerRef.current) return
sessionMediaMetricPersistTimerRef.current = window.setTimeout(() => {
sessionMediaMetricPersistTimerRef.current = null
void flushSessionMediaMetricCache()
}, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS)
}, [flushSessionMediaMetricCache])
const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => {
if (!sessionId) return true
if (sessionMediaMetricReadySetRef.current.has(sessionId)) return true
const existing = sessionContentMetricsRef.current[sessionId]
if (hasCompleteSessionMediaMetric(existing)) {
sessionMediaMetricReadySetRef.current.add(sessionId)
return true
}
return false
}, [])
const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => {
const front = options?.front === true
const incoming: string[] = []
for (const sessionIdRaw of sessionIds) {
const sessionId = String(sessionIdRaw || '').trim()
if (!sessionId) continue
if (sessionMediaMetricQueuedSetRef.current.has(sessionId)) continue
if (sessionMediaMetricLoadingSetRef.current.has(sessionId)) continue
if (isSessionMediaMetricReady(sessionId)) continue
sessionMediaMetricQueuedSetRef.current.add(sessionId)
incoming.push(sessionId)
}
if (incoming.length === 0) return
if (front) {
sessionMediaMetricQueueRef.current = [...incoming, ...sessionMediaMetricQueueRef.current]
} else {
sessionMediaMetricQueueRef.current.push(...incoming)
}
}, [isSessionMediaMetricReady])
const applySessionMediaMetricsFromStats = useCallback((data?: Record<string, SessionExportMetric>) => {
if (!data) return
const nextMetrics: Record<string, SessionContentMetric> = {}
let hasPatch = false
for (const [sessionIdRaw, metricRaw] of Object.entries(data)) {
const sessionId = String(sessionIdRaw || '').trim()
if (!sessionId) continue
const metric = pickSessionMediaMetric(metricRaw)
if (!metric) continue
nextMetrics[sessionId] = metric
hasPatch = true
sessionMediaMetricPendingPersistRef.current[sessionId] = {
...sessionMediaMetricPendingPersistRef.current[sessionId],
...metric
}
if (hasCompleteSessionMediaMetric(metric)) {
sessionMediaMetricReadySetRef.current.add(sessionId)
}
}
if (hasPatch) {
mergeSessionContentMetrics(nextMetrics)
scheduleFlushSessionMediaMetricCache()
}
}, [mergeSessionContentMetrics, scheduleFlushSessionMediaMetricCache])
const runSessionMediaMetricWorker = useCallback(async (runId: number) => {
if (sessionMediaMetricWorkerRunningRef.current) return
sessionMediaMetricWorkerRunningRef.current = true
try {
while (runId === sessionMediaMetricRunIdRef.current) {
if (isLoadingSessionCountsRef.current || detailStatsPriorityRef.current) {
await new Promise(resolve => window.setTimeout(resolve, 80))
continue
}
if (sessionMediaMetricQueueRef.current.length === 0) break
const batchSessionIds: string[] = []
while (batchSessionIds.length < SESSION_MEDIA_METRIC_BATCH_SIZE && sessionMediaMetricQueueRef.current.length > 0) {
const nextId = sessionMediaMetricQueueRef.current.shift()
if (!nextId) continue
sessionMediaMetricQueuedSetRef.current.delete(nextId)
if (sessionMediaMetricLoadingSetRef.current.has(nextId)) continue
if (isSessionMediaMetricReady(nextId)) continue
sessionMediaMetricLoadingSetRef.current.add(nextId)
batchSessionIds.push(nextId)
}
if (batchSessionIds.length === 0) {
continue
}
try {
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
batchSessionIds,
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
)
if (runId !== sessionMediaMetricRunIdRef.current) return
if (cacheResult.success && cacheResult.data) {
applySessionMediaMetricsFromStats(cacheResult.data as Record<string, SessionExportMetric>)
}
const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId))
if (missingSessionIds.length > 0) {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
missingSessionIds,
{ includeRelations: false, allowStaleCache: true }
)
if (runId !== sessionMediaMetricRunIdRef.current) return
if (freshResult.success && freshResult.data) {
applySessionMediaMetricsFromStats(freshResult.data as Record<string, SessionExportMetric>)
}
}
} catch (error) {
console.error('导出页加载会话媒体统计失败:', error)
} finally {
for (const sessionId of batchSessionIds) {
sessionMediaMetricLoadingSetRef.current.delete(sessionId)
if (isSessionMediaMetricReady(sessionId)) {
sessionMediaMetricReadySetRef.current.add(sessionId)
}
}
}
await new Promise(resolve => window.setTimeout(resolve, 0))
}
} finally {
sessionMediaMetricWorkerRunningRef.current = false
if (runId === sessionMediaMetricRunIdRef.current && sessionMediaMetricQueueRef.current.length > 0) {
void runSessionMediaMetricWorker(runId)
}
}
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady])
const scheduleSessionMediaMetricWorker = useCallback(() => {
if (!isSessionCountStageReady) return
if (isLoadingSessionCountsRef.current) return
if (sessionMediaMetricWorkerRunningRef.current) return
const runId = sessionMediaMetricRunIdRef.current
void runSessionMediaMetricWorker(runId)
}, [isSessionCountStageReady, runSessionMediaMetricWorker])
const loadSessionMessageCounts = useCallback(async ( const loadSessionMessageCounts = useCallback(async (
sourceSessions: SessionRow[], sourceSessions: SessionRow[],
priorityTab: ConversationTab, priorityTab: ConversationTab,
@@ -1832,6 +2073,7 @@ function ExportPage() {
const requestId = sessionCountRequestIdRef.current + 1 const requestId = sessionCountRequestIdRef.current + 1
sessionCountRequestIdRef.current = requestId sessionCountRequestIdRef.current = requestId
const isStale = () => sessionCountRequestIdRef.current !== requestId const isStale = () => sessionCountRequestIdRef.current !== requestId
setIsSessionCountStageReady(false)
const exportableSessions = sourceSessions.filter(session => session.hasSession) const exportableSessions = sourceSessions.filter(session => session.hasSession)
const seededHintCounts = exportableSessions.reduce<Record<string, number>>((acc, session) => { const seededHintCounts = exportableSessions.reduce<Record<string, number>>((acc, session) => {
@@ -1862,6 +2104,9 @@ function ExportPage() {
if (exportableSessions.length === 0) { if (exportableSessions.length === 0) {
setIsLoadingSessionCounts(false) setIsLoadingSessionCounts(false)
if (!isStale()) {
setIsSessionCountStageReady(true)
}
return { ...accumulatedCounts } return { ...accumulatedCounts }
} }
@@ -1923,6 +2168,7 @@ function ExportPage() {
} finally { } finally {
if (!isStale()) { if (!isStale()) {
setIsLoadingSessionCounts(false) setIsLoadingSessionCounts(false)
setIsSessionCountStageReady(true)
if (options?.scopeKey && Object.keys(accumulatedCounts).length > 0) { if (options?.scopeKey && Object.keys(accumulatedCounts).length > 0) {
try { try {
await configService.setExportSessionMessageCountCache(options.scopeKey, accumulatedCounts) await configService.setExportSessionMessageCountCache(options.scopeKey, accumulatedCounts)
@@ -1940,12 +2186,14 @@ function ExportPage() {
sessionLoadTokenRef.current = loadToken sessionLoadTokenRef.current = loadToken
sessionsHydratedAtRef.current = 0 sessionsHydratedAtRef.current = 0
sessionPreciseRefreshAtRef.current = {} sessionPreciseRefreshAtRef.current = {}
resetSessionMediaMetricLoader()
setIsLoading(true) setIsLoading(true)
setIsSessionEnriching(false) setIsSessionEnriching(false)
sessionCountRequestIdRef.current += 1 sessionCountRequestIdRef.current += 1
setSessionMessageCounts({}) setSessionMessageCounts({})
setSessionContentMetrics({}) setSessionContentMetrics({})
setIsLoadingSessionCounts(false) setIsLoadingSessionCounts(false)
setIsSessionCountStageReady(false)
const isStale = () => sessionLoadTokenRef.current !== loadToken const isStale = () => sessionLoadTokenRef.current !== loadToken
@@ -1955,10 +2203,12 @@ function ExportPage() {
const [ const [
cachedContactsPayload, cachedContactsPayload,
cachedMessageCountsPayload cachedMessageCountsPayload,
cachedContentMetricsPayload
] = await Promise.all([ ] = await Promise.all([
loadContactsCaches(scopeKey), loadContactsCaches(scopeKey),
configService.getExportSessionMessageCountCache(scopeKey) configService.getExportSessionMessageCountCache(scopeKey),
configService.getExportSessionContentMetricCache(scopeKey)
]) ])
if (isStale()) return if (isStale()) return
@@ -2010,6 +2260,16 @@ function ExportPage() {
acc[sessionId] = { totalMessages: count } acc[sessionId] = { totalMessages: count }
return acc return acc
}, {}) }, {})
const cachedContentMetrics = Object.entries(cachedContentMetricsPayload?.metrics || {}).reduce<Record<string, SessionContentMetric>>((acc, [sessionId, rawMetric]) => {
if (!exportableSessionIdSet.has(sessionId)) return acc
const metric = pickSessionMediaMetric(rawMetric)
if (!metric) return acc
acc[sessionId] = metric
if (hasCompleteSessionMediaMetric(metric)) {
sessionMediaMetricReadySetRef.current.add(sessionId)
}
return acc
}, {})
if (isStale()) return if (isStale()) return
if (Object.keys(cachedMessageCounts).length > 0) { if (Object.keys(cachedMessageCounts).length > 0) {
@@ -2018,6 +2278,9 @@ function ExportPage() {
if (Object.keys(cachedCountAsMetrics).length > 0) { if (Object.keys(cachedCountAsMetrics).length > 0) {
mergeSessionContentMetrics(cachedCountAsMetrics) mergeSessionContentMetrics(cachedCountAsMetrics)
} }
if (Object.keys(cachedContentMetrics).length > 0) {
mergeSessionContentMetrics(cachedContentMetrics)
}
setSessions(baseSessions) setSessions(baseSessions)
sessionsHydratedAtRef.current = Date.now() sessionsHydratedAtRef.current = Date.now()
void (async () => { void (async () => {
@@ -2218,7 +2481,7 @@ function ExportPage() {
} finally { } finally {
if (!isStale()) setIsLoading(false) if (!isStale()) setIsLoading(false)
} }
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, syncContactTypeCounts]) }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, resetSessionMediaMetricLoader, syncContactTypeCounts])
useEffect(() => { useEffect(() => {
if (!isExportRoute) return if (!isExportRoute) return
@@ -3354,6 +3617,107 @@ function ExportPage() {
setIsContactsListAtTop(true) setIsContactsListAtTop(true)
}, [activeTab, searchKeyword]) }, [activeTab, searchKeyword])
const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => {
if (sourceContacts.length === 0) return []
const startCandidate = sessionMediaMetricVisibleRangeRef.current.startIndex
const endCandidate = sessionMediaMetricVisibleRangeRef.current.endIndex
const startIndex = Math.max(0, Math.min(sourceContacts.length - 1, startCandidate >= 0 ? startCandidate : 0))
const visibleEnd = endCandidate >= startIndex
? endCandidate
: Math.min(sourceContacts.length - 1, startIndex + 9)
const endIndex = Math.max(startIndex, Math.min(sourceContacts.length - 1, visibleEnd + SESSION_MEDIA_METRIC_PREFETCH_ROWS))
const sessionIds: string[] = []
for (let index = startIndex; index <= endIndex; index += 1) {
const contact = sourceContacts[index]
if (!contact?.username) continue
const mappedSession = sessionRowByUsername.get(contact.username)
if (!mappedSession?.hasSession) continue
sessionIds.push(contact.username)
}
return sessionIds
}, [sessionRowByUsername])
const handleContactsRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => {
const startIndex = Number.isFinite(range?.startIndex) ? Math.max(0, Math.floor(range.startIndex)) : 0
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
if (visibleTargets.length === 0) return
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
scheduleSessionMediaMetricWorker()
}, [collectVisibleSessionMetricTargets, enqueueSessionMediaMetricRequests, filteredContacts, isSessionCountStageReady, scheduleSessionMediaMetricWorker])
useEffect(() => {
if (!isSessionCountStageReady || filteredContacts.length === 0) return
const runId = sessionMediaMetricRunIdRef.current
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
if (visibleTargets.length > 0) {
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
scheduleSessionMediaMetricWorker()
}
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
sessionMediaMetricBackgroundFeedTimerRef.current = null
}
const visibleTargetSet = new Set(visibleTargets)
let cursor = 0
const feedNext = () => {
if (runId !== sessionMediaMetricRunIdRef.current) return
if (isLoadingSessionCountsRef.current) return
const batchIds: string[] = []
while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) {
const contact = filteredContacts[cursor]
cursor += 1
if (!contact?.username) continue
if (visibleTargetSet.has(contact.username)) continue
const mappedSession = sessionRowByUsername.get(contact.username)
if (!mappedSession?.hasSession) continue
batchIds.push(contact.username)
}
if (batchIds.length > 0) {
enqueueSessionMediaMetricRequests(batchIds)
scheduleSessionMediaMetricWorker()
}
if (cursor < filteredContacts.length) {
sessionMediaMetricBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS)
}
}
feedNext()
return () => {
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
sessionMediaMetricBackgroundFeedTimerRef.current = null
}
}
}, [
collectVisibleSessionMetricTargets,
enqueueSessionMediaMetricRequests,
filteredContacts,
isSessionCountStageReady,
scheduleSessionMediaMetricWorker,
sessionRowByUsername
])
useEffect(() => {
return () => {
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
sessionMediaMetricBackgroundFeedTimerRef.current = null
}
if (sessionMediaMetricPersistTimerRef.current) {
window.clearTimeout(sessionMediaMetricPersistTimerRef.current)
sessionMediaMetricPersistTimerRef.current = null
}
void flushSessionMediaMetricCache()
}
}, [flushSessionMediaMetricCache])
const contactByUsername = useMemo(() => { const contactByUsername = useMemo(() => {
const map = new Map<string, ContactInfo>() const map = new Map<string, ContactInfo>()
for (const contact of contactsList) { for (const contact of contactsList) {
@@ -3820,11 +4184,23 @@ function ExportPage() {
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
const displayedMessageCount = countedMessages ?? hintedMessages const displayedMessageCount = countedMessages ?? hintedMessages
const mediaMetric = sessionContentMetrics[contact.username]
const metricLoadingReady = canExport && isSessionCountStageReady
const messageCountLabel = !canExport const messageCountLabel = !canExport
? '--' ? '--'
: typeof displayedMessageCount === 'number' : typeof displayedMessageCount === 'number'
? displayedMessageCount.toLocaleString('zh-CN') ? displayedMessageCount.toLocaleString('zh-CN')
: '获取中' : '获取中'
const metricToLabel = (value: unknown): string => {
const normalized = normalizeMessageCount(value)
if (!canExport) return '--'
if (!metricLoadingReady) return '--'
return typeof normalized === 'number' ? normalized.toLocaleString('zh-CN') : '...'
}
const emojiLabel = metricToLabel(mediaMetric?.emojiMessages)
const voiceLabel = metricToLabel(mediaMetric?.voiceMessages)
const imageLabel = metricToLabel(mediaMetric?.imageMessages)
const videoLabel = metricToLabel(mediaMetric?.videoMessages)
const openChatLabel = contact.type === 'friend' const openChatLabel = contact.type === 'friend'
? '打开私聊' ? '打开私聊'
: contact.type === 'group' : contact.type === 'group'
@@ -3867,13 +4243,30 @@ function ExportPage() {
className="row-open-chat-link" className="row-open-chat-link"
title="在新窗口打开该会话" title="在新窗口打开该会话"
onClick={() => { onClick={() => {
void window.electronAPI.window.openSessionChatWindow(contact.username, { source: 'export' }) void window.electronAPI.window.openSessionChatWindow(contact.username, {
source: 'export',
initialDisplayName: contact.displayName || contact.username,
initialAvatarUrl: contact.avatarUrl,
initialContactType: contact.type
})
}} }}
> >
{openChatLabel} {openChatLabel}
</button> </button>
)} )}
</div> </div>
<div className="row-media-metric">
<strong className={`row-media-metric-value ${emojiLabel === '...' ? 'loading' : ''}`}>{emojiLabel}</strong>
</div>
<div className="row-media-metric">
<strong className={`row-media-metric-value ${voiceLabel === '...' ? 'loading' : ''}`}>{voiceLabel}</strong>
</div>
<div className="row-media-metric">
<strong className={`row-media-metric-value ${imageLabel === '...' ? 'loading' : ''}`}>{imageLabel}</strong>
</div>
<div className="row-media-metric">
<strong className={`row-media-metric-value ${videoLabel === '...' ? 'loading' : ''}`}>{videoLabel}</strong>
</div>
<div className="row-action-cell"> <div className="row-action-cell">
<div className="row-action-main"> <div className="row-action-main">
<button <button
@@ -3915,9 +4308,11 @@ function ExportPage() {
runningSessionIds, runningSessionIds,
selectedSessions, selectedSessions,
sessionDetail?.wxid, sessionDetail?.wxid,
sessionContentMetrics,
sessionMessageCounts, sessionMessageCounts,
sessionRowByUsername, sessionRowByUsername,
showSessionDetailPanel, showSessionDetailPanel,
isSessionCountStageReady,
toggleSelectSession toggleSelectSession
]) ])
const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => { const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {
@@ -4170,6 +4565,10 @@ function ExportPage() {
<span className="contacts-list-header-main-label">//</span> <span className="contacts-list-header-main-label">//</span>
</span> </span>
<span className="contacts-list-header-count"></span> <span className="contacts-list-header-count"></span>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-actions"> <span className="contacts-list-header-actions">
{selectedCount > 0 && ( {selectedCount > 0 && (
<> <>
@@ -4247,6 +4646,7 @@ function ExportPage() {
data={filteredContacts} data={filteredContacts}
computeItemKey={(_, contact) => contact.username} computeItemKey={(_, contact) => contact.username}
itemContent={renderContactRow} itemContent={renderContactRow}
rangeChanged={handleContactsRangeChanged}
atTopStateChange={setIsContactsListAtTop} atTopStateChange={setIsContactsListAtTop}
overscan={420} overscan={420}
/> />