重构与优化,旨在解决遗留的性能问题并优化用户体验,本次提交遗留了较多的待测功能

This commit is contained in:
cc
2026-03-18 23:49:50 +08:00
parent 4c32bf5934
commit 48c4197b16
22 changed files with 2726 additions and 1598 deletions

View File

@@ -1443,7 +1443,7 @@ function ChatPage(props: ChatPageProps) {
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true }
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
)
])
@@ -1476,6 +1476,7 @@ function ChatPage(props: ChatPageProps) {
}
let refreshIncludeRelations = false
let shouldRefreshStatsInBackground = false
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) {
const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined
const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
@@ -1493,11 +1494,49 @@ function ChatPage(props: ChatPageProps) {
}
})
}
shouldRefreshStatsInBackground = !metric || Boolean(cacheMeta?.stale)
} else {
shouldRefreshStatsInBackground = true
}
finishBackgroundTask(taskId, 'completed', {
detail: '聊天页会话详情统计完成',
progressText: '已完成'
})
if (shouldRefreshStatsInBackground) {
setIsRefreshingDetailStats(true)
void (async () => {
try {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: false, forceRefresh: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
if (freshResult.success && freshResult.data) {
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
if (freshMetric) {
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, false)
} else if (freshMeta) {
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
return {
...prev,
statsUpdatedAt: freshMeta.updatedAt,
statsStale: freshMeta.stale
}
})
}
}
} catch (error) {
console.error('聊天页后台刷新会话统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsRefreshingDetailStats(false)
}
}
})()
}
} catch (e) {
console.error('加载会话详情补充统计失败:', e)
finishBackgroundTask(taskId, 'failed', {
@@ -5778,12 +5817,13 @@ function ChatPage(props: ChatPageProps) {
// 下载完成后,触发页面刷新让组件重新尝试转写
// 通过更新缓存触发组件重新检查
if (pendingVoiceTranscriptRequest) {
// 清除缓存中的请求标记,让组件可以重新尝试
const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}`
// 不直接调用转写,而是让组件自己重试
// 通过触发一个自定义事件来通知所有 MessageBubble 组件
window.dispatchEvent(new CustomEvent('model-downloaded', {
detail: { messageId: pendingVoiceTranscriptRequest.messageId }
detail: {
sessionId: pendingVoiceTranscriptRequest.sessionId,
messageId: pendingVoiceTranscriptRequest.messageId
}
}))
}
setPendingVoiceTranscriptRequest(null)
@@ -6298,6 +6338,20 @@ const voiceTranscriptCache = new Map<string, string>()
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
const buildVoiceCacheIdentity = (
sessionId: string,
message: Pick<Message, 'localId' | 'createTime' | 'serverId'>
): string => {
const normalizedSessionId = String(sessionId || '').trim()
const localId = Math.max(0, Math.floor(Number(message?.localId || 0)))
const createTime = Math.max(0, Math.floor(Number(message?.createTime || 0)))
const serverIdRaw = String(message?.serverId ?? '').trim()
const serverId = /^\d+$/.test(serverIdRaw)
? serverIdRaw.replace(/^0+(?=\d)/, '')
: String(Math.max(0, Math.floor(Number(serverIdRaw || 0))))
return `${normalizedSessionId}:${localId}:${createTime}:${serverId || '0'}`
}
// 引用消息中的动画表情组件
function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
const cacheKey = md5 || cdnUrl
@@ -6372,11 +6426,12 @@ function MessageBubble({
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
() => imageDataUrlCache.get(imageCacheKey)
)
const voiceCacheKey = `voice:${message.localId}`
const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message)
const voiceCacheKey = `voice:${voiceIdentityKey}`
const [voiceDataUrl, setVoiceDataUrl] = useState<string | undefined>(
() => voiceDataUrlCache.get(voiceCacheKey)
)
const voiceTranscriptCacheKey = `voice-transcript:${message.localId}`
const voiceTranscriptCacheKey = `voice-transcript:${voiceIdentityKey}`
const [voiceTranscript, setVoiceTranscript] = useState<string | undefined>(
() => voiceTranscriptCache.get(voiceTranscriptCacheKey)
)
@@ -6938,14 +6993,16 @@ function MessageBubble({
// 监听流式转写结果
useEffect(() => {
if (!isVoice) return
const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { msgId: string; text: string }) => {
if (payload.msgId === String(message.localId)) {
setVoiceTranscript(payload.text)
voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text)
}
const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => {
const sameSession = !payload.sessionId || payload.sessionId === session.username
const sameMsgId = payload.msgId === String(message.localId)
const sameCreateTime = payload.createTime == null || Number(payload.createTime) === Number(message.createTime || 0)
if (!sameSession || !sameMsgId || !sameCreateTime) return
setVoiceTranscript(payload.text)
voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text)
})
return () => removeListener?.()
}, [isVoice, message.localId, voiceTranscriptCacheKey])
}, [isVoice, message.createTime, message.localId, session.username, voiceTranscriptCacheKey])
const requestVoiceTranscript = useCallback(async () => {
if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return
@@ -6999,14 +7056,17 @@ function MessageBubble({
} finally {
setVoiceTranscriptLoading(false)
}
}, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload])
}, [message.createTime, message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload])
// 监听模型下载完成事件
useEffect(() => {
if (!isVoice) return
const handleModelDownloaded = (event: CustomEvent) => {
if (event.detail?.messageId === String(message.localId)) {
if (
event.detail?.messageId === String(message.localId) &&
(!event.detail?.sessionId || event.detail?.sessionId === session.username)
) {
// 重置状态,允许重新尝试转写
voiceTranscriptRequestedRef.current = false
setVoiceTranscriptError(false)
@@ -7019,7 +7079,7 @@ function MessageBubble({
return () => {
window.removeEventListener('model-downloaded', handleModelDownloaded as EventListener)
}
}, [isVoice, message.localId, requestVoiceTranscript])
}, [isVoice, message.localId, requestVoiceTranscript, session.username])
// 视频懒加载
const videoAutoLoadTriggered = useRef(false)

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type UIEvent, type WheelEvent } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { createPortal } from 'react-dom'
import {
@@ -44,6 +44,7 @@ import {
subscribeBackgroundTasks
} from '../services/backgroundTaskMonitor'
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import { useChatStore } from '../stores/chatStore'
import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
@@ -104,6 +105,10 @@ interface TaskProgress {
phaseLabel: string
phaseProgress: number
phaseTotal: number
exportedMessages: number
estimatedTotalMessages: number
collectedMessages: number
writtenFiles: number
}
type TaskPerfStage = 'collect' | 'build' | 'write' | 'other'
@@ -166,7 +171,7 @@ interface ExportDialogState {
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
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_BATCH_SIZE = 8
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120
const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200
@@ -254,7 +259,11 @@ const createEmptyProgress = (): TaskProgress => ({
phase: '',
phaseLabel: '',
phaseProgress: 0,
phaseTotal: 0
phaseTotal: 0,
exportedMessages: 0,
estimatedTotalMessages: 0,
collectedMessages: 0,
writtenFiles: 0
})
const createEmptyTaskPerformance = (): TaskPerformance => ({
@@ -1280,6 +1289,14 @@ const TaskCenterModal = memo(function TaskCenterModal({
completedSessionTotal,
(task.settledSessionIds || []).length
)
const exportedMessages = Math.max(0, Math.floor(task.progress.exportedMessages || 0))
const estimatedTotalMessages = Math.max(0, Math.floor(task.progress.estimatedTotalMessages || 0))
const messageProgressLabel = estimatedTotalMessages > 0
? `已导出 ${Math.min(exportedMessages, estimatedTotalMessages)}/${estimatedTotalMessages}`
: `已导出 ${exportedMessages}`
const sessionProgressLabel = completedSessionTotal > 0
? `会话 ${completedSessionCount}/${completedSessionTotal}`
: '会话处理中'
const currentSessionRatio = task.progress.phaseTotal > 0
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
: null
@@ -1300,9 +1317,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
/>
</div>
<div className="task-progress-text">
{completedSessionTotal > 0
? `已完成 ${completedSessionCount} / ${completedSessionTotal}`
: '处理中'}
{`${sessionProgressLabel} · ${messageProgressLabel}`}
{task.status === 'running' && currentSessionRatio !== null
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%`
: ''}
@@ -1387,6 +1402,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
})
function ExportPage() {
const navigate = useNavigate()
const { setCurrentSession } = useChatStore()
const location = useLocation()
const isExportRoute = location.pathname === '/export'
@@ -2787,6 +2804,7 @@ function ExportPage() {
}, [])
const enqueueSessionMutualFriendsRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => {
if (activeTaskCountRef.current > 0) return
const front = options?.front === true
const incoming: string[] = []
for (const sessionIdRaw of sessionIds) {
@@ -2976,6 +2994,7 @@ function ExportPage() {
}, [])
const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => {
if (activeTaskCountRef.current > 0) return
const front = options?.front === true
const incoming: string[] = []
for (const sessionIdRaw of sessionIds) {
@@ -3025,13 +3044,27 @@ function ExportPage() {
const runSessionMediaMetricWorker = useCallback(async (runId: number) => {
if (sessionMediaMetricWorkerRunningRef.current) return
sessionMediaMetricWorkerRunningRef.current = true
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number, stage: string): Promise<T> => {
let timer: number | null = null
try {
const timeoutPromise = new Promise<never>((_, reject) => {
timer = window.setTimeout(() => {
reject(new Error(`会话多媒体统计超时(${stage}, ${timeoutMs}ms)`))
}, timeoutMs)
})
return await Promise.race([promise, timeoutPromise])
} finally {
if (timer !== null) {
window.clearTimeout(timer)
}
}
}
try {
while (runId === sessionMediaMetricRunIdRef.current) {
if (isLoadingSessionCountsRef.current || detailStatsPriorityRef.current) {
await new Promise(resolve => window.setTimeout(resolve, 80))
if (activeTaskCountRef.current > 0) {
await new Promise(resolve => window.setTimeout(resolve, 150))
continue
}
if (sessionMediaMetricQueueRef.current.length === 0) break
const batchSessionIds: string[] = []
@@ -3050,9 +3083,13 @@ function ExportPage() {
patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading')
try {
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
batchSessionIds,
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
const cacheResult = await withTimeout(
window.electronAPI.chat.getExportSessionStats(
batchSessionIds,
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
),
12000,
'cacheOnly'
)
if (runId !== sessionMediaMetricRunIdRef.current) return
if (cacheResult.success && cacheResult.data) {
@@ -3061,15 +3098,26 @@ function ExportPage() {
const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId))
if (missingSessionIds.length > 0) {
const freshResult = await window.electronAPI.chat.getExportSessionStats(
missingSessionIds,
{ includeRelations: false, allowStaleCache: true }
const freshResult = await withTimeout(
window.electronAPI.chat.getExportSessionStats(
missingSessionIds,
{ includeRelations: false, allowStaleCache: true }
),
45000,
'fresh'
)
if (runId !== sessionMediaMetricRunIdRef.current) return
if (freshResult.success && freshResult.data) {
applySessionMediaMetricsFromStats(freshResult.data as Record<string, SessionExportMetric>)
}
}
const unresolvedSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId))
if (unresolvedSessionIds.length > 0) {
patchSessionLoadTraceStage(unresolvedSessionIds, 'mediaMetrics', 'failed', {
error: '统计结果缺失,已跳过当前批次'
})
}
} catch (error) {
console.error('导出页加载会话媒体统计失败:', error)
patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', {
@@ -3100,12 +3148,11 @@ function ExportPage() {
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage])
const scheduleSessionMediaMetricWorker = useCallback(() => {
if (!isSessionCountStageReady) return
if (isLoadingSessionCountsRef.current) return
if (activeTaskCountRef.current > 0) return
if (sessionMediaMetricWorkerRunningRef.current) return
const runId = sessionMediaMetricRunIdRef.current
void runSessionMediaMetricWorker(runId)
}, [isSessionCountStageReady, runSessionMediaMetricWorker])
}, [runSessionMediaMetricWorker])
const loadSessionMutualFriendsMetric = useCallback(async (sessionId: string): Promise<SessionMutualFriendsMetric> => {
const normalizedSessionId = String(sessionId || '').trim()
@@ -3150,6 +3197,10 @@ function ExportPage() {
sessionMutualFriendsWorkerRunningRef.current = true
try {
while (runId === sessionMutualFriendsRunIdRef.current) {
if (activeTaskCountRef.current > 0) {
await new Promise(resolve => window.setTimeout(resolve, 150))
continue
}
if (hasPendingMetricLoads()) {
await new Promise(resolve => window.setTimeout(resolve, 120))
continue
@@ -3196,6 +3247,7 @@ function ExportPage() {
])
const scheduleSessionMutualFriendsWorker = useCallback(() => {
if (activeTaskCountRef.current > 0) return
if (!isSessionCountStageReady) return
if (hasPendingMetricLoads()) return
if (sessionMutualFriendsWorkerRunningRef.current) return
@@ -3291,9 +3343,6 @@ function ExportPage() {
setIsLoadingSessionCounts(true)
try {
if (detailStatsPriorityRef.current) {
return { ...accumulatedCounts }
}
if (prioritizedSessionIds.length > 0) {
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading')
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
@@ -3311,9 +3360,6 @@ function ExportPage() {
}
}
if (detailStatsPriorityRef.current) {
return { ...accumulatedCounts }
}
if (remainingSessionIds.length > 0) {
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading')
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
@@ -4135,6 +4181,126 @@ function ExportPage() {
progressUnsubscribeRef.current?.()
const settledSessionIdsFromProgress = new Set<string>()
const sessionMessageProgress = new Map<string, { exported: number; total: number; knownTotal: boolean }>()
let queuedProgressPayload: ExportProgress | null = null
let queuedProgressRaf: number | null = null
let queuedProgressTimer: number | null = null
const clearQueuedProgress = () => {
if (queuedProgressRaf !== null) {
window.cancelAnimationFrame(queuedProgressRaf)
queuedProgressRaf = null
}
if (queuedProgressTimer !== null) {
window.clearTimeout(queuedProgressTimer)
queuedProgressTimer = null
}
}
const updateSessionMessageProgress = (payload: ExportProgress) => {
const sessionId = String(payload.currentSessionId || '').trim()
if (!sessionId) return
const prev = sessionMessageProgress.get(sessionId) || { exported: 0, total: 0, knownTotal: false }
const nextExported = Number.isFinite(payload.exportedMessages)
? Math.max(prev.exported, Math.max(0, Math.floor(Number(payload.exportedMessages || 0))))
: prev.exported
const hasEstimatedTotal = Number.isFinite(payload.estimatedTotalMessages)
const nextTotal = hasEstimatedTotal
? Math.max(prev.total, Math.max(0, Math.floor(Number(payload.estimatedTotalMessages || 0))))
: prev.total
const knownTotal = prev.knownTotal || hasEstimatedTotal
sessionMessageProgress.set(sessionId, {
exported: nextExported,
total: nextTotal,
knownTotal
})
}
const resolveAggregatedMessageProgress = () => {
let exported = 0
let estimated = 0
let allKnown = true
for (const sessionId of next.payload.sessionIds) {
const entry = sessionMessageProgress.get(sessionId)
if (!entry) {
allKnown = false
continue
}
exported += entry.exported
estimated += entry.total
if (!entry.knownTotal) {
allKnown = false
}
}
return {
exported: Math.max(0, Math.floor(exported)),
estimated: allKnown ? Math.max(0, Math.floor(estimated)) : 0
}
}
const flushQueuedProgress = () => {
if (!queuedProgressPayload) return
const payload = queuedProgressPayload
queuedProgressPayload = null
const now = Date.now()
const currentSessionId = String(payload.currentSessionId || '').trim()
updateTask(next.id, task => {
if (task.status !== 'running') return task
const performance = applyProgressToTaskPerformance(task, payload, now)
const settledSessionIds = task.settledSessionIds || []
const nextSettledSessionIds = (
payload.phase === 'complete' &&
currentSessionId &&
!settledSessionIds.includes(currentSessionId)
)
? [...settledSessionIds, currentSessionId]
: settledSessionIds
const aggregatedMessageProgress = resolveAggregatedMessageProgress()
const collectedMessages = Number.isFinite(payload.collectedMessages)
? Math.max(0, Math.floor(Number(payload.collectedMessages || 0)))
: task.progress.collectedMessages
const writtenFiles = Number.isFinite(payload.writtenFiles)
? Math.max(task.progress.writtenFiles, Math.max(0, Math.floor(Number(payload.writtenFiles || 0))))
: task.progress.writtenFiles
return {
...task,
progress: {
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phase: payload.phase,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0,
exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported),
estimatedTotalMessages: aggregatedMessageProgress.estimated > 0
? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated)
: (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0),
collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages),
writtenFiles
},
settledSessionIds: nextSettledSessionIds,
performance
}
})
}
const queueProgressUpdate = (payload: ExportProgress) => {
queuedProgressPayload = payload
if (payload.phase === 'complete') {
clearQueuedProgress()
flushQueuedProgress()
return
}
if (queuedProgressRaf !== null || queuedProgressTimer !== null) return
queuedProgressRaf = window.requestAnimationFrame(() => {
queuedProgressRaf = null
queuedProgressTimer = window.setTimeout(() => {
queuedProgressTimer = null
flushQueuedProgress()
}, 100)
})
}
if (next.payload.scope === 'sns') {
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
updateTask(next.id, task => {
@@ -4148,7 +4314,11 @@ function ExportPage() {
phase: 'exporting',
phaseLabel: payload.status || '',
phaseProgress: payload.total > 0 ? payload.current : 0,
phaseTotal: payload.total || 0
phaseTotal: payload.total || 0,
exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages,
estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages,
collectedMessages: task.progress.collectedMessages,
writtenFiles: task.progress.writtenFiles
}
}
})
@@ -4157,6 +4327,7 @@ function ExportPage() {
progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => {
const now = Date.now()
const currentSessionId = String(payload.currentSessionId || '').trim()
updateSessionMessageProgress(payload)
if (payload.phase === 'complete' && currentSessionId && !settledSessionIdsFromProgress.has(currentSessionId)) {
settledSessionIdsFromProgress.add(currentSessionId)
const phaseLabel = String(payload.phaseLabel || '')
@@ -4172,33 +4343,7 @@ function ExportPage() {
markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now)
}
}
updateTask(next.id, task => {
if (task.status !== 'running') return task
const performance = applyProgressToTaskPerformance(task, payload, now)
const settledSessionIds = task.settledSessionIds || []
const nextSettledSessionIds = (
payload.phase === 'complete' &&
currentSessionId &&
!settledSessionIds.includes(currentSessionId)
)
? [...settledSessionIds, currentSessionId]
: settledSessionIds
return {
...task,
progress: {
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phase: payload.phase,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0
},
settledSessionIds: nextSettledSessionIds,
performance
}
})
queueProgressUpdate(payload)
})
}
@@ -4310,6 +4455,8 @@ function ExportPage() {
performance: finalizeTaskPerformance(task, doneAt)
}))
} finally {
clearQueuedProgress()
flushQueuedProgress()
progressUnsubscribeRef.current?.()
progressUnsubscribeRef.current = null
runningTaskIdRef.current = null
@@ -4715,10 +4862,22 @@ function ExportPage() {
return new Date(value).toLocaleTimeString('zh-CN', { hour12: false })
}, [])
const getLoadDetailStatusLabel = useCallback((loaded: number, total: number, hasStarted: boolean): string => {
const getLoadDetailStatusLabel = useCallback((
loaded: number,
total: number,
hasStarted: boolean,
hasLoading: boolean,
failedCount: number
): string => {
if (total <= 0) return '待加载'
if (loaded >= total) return `已完成 ${total}`
if (hasStarted) return `加载中 ${loaded}/${total}`
const terminalCount = loaded + failedCount
if (terminalCount >= total) {
if (failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount}`
return `已完成 ${total}`
}
if (hasLoading) return `加载中 ${loaded}/${total}`
if (hasStarted && failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount}`
if (hasStarted) return `已完成 ${loaded}/${total}`
return '待加载'
}, [])
@@ -4728,7 +4887,9 @@ function ExportPage() {
): SessionLoadStageSummary => {
const total = sessionIds.length
let loaded = 0
let failedCount = 0
let hasStarted = false
let hasLoading = false
let earliestStart: number | undefined
let latestFinish: number | undefined
let latestProgressAt: number | undefined
@@ -4742,6 +4903,12 @@ function ExportPage() {
: Math.max(latestProgressAt, stage.finishedAt)
}
}
if (stage?.status === 'failed') {
failedCount += 1
}
if (stage?.status === 'loading') {
hasLoading = true
}
if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') {
hasStarted = true
}
@@ -4759,9 +4926,9 @@ function ExportPage() {
return {
total,
loaded,
statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted),
statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted, hasLoading, failedCount),
startedAt: earliestStart,
finishedAt: loaded >= total ? latestFinish : undefined,
finishedAt: (loaded + failedCount) >= total ? latestFinish : undefined,
latestProgressAt
}
}, [getLoadDetailStatusLabel, sessionLoadTraceMap])
@@ -4907,7 +5074,6 @@ function ExportPage() {
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex }
if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
if (visibleTargets.length === 0) return
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
@@ -4923,13 +5089,13 @@ function ExportPage() {
enqueueSessionMediaMetricRequests,
enqueueSessionMutualFriendsRequests,
filteredContacts,
isSessionCountStageReady,
scheduleSessionMediaMetricWorker,
scheduleSessionMutualFriendsWorker
])
useEffect(() => {
if (!isSessionCountStageReady || filteredContacts.length === 0) return
if (activeTaskCount > 0) return
if (filteredContacts.length === 0) return
const runId = sessionMediaMetricRunIdRef.current
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
if (visibleTargets.length > 0) {
@@ -4946,7 +5112,6 @@ function ExportPage() {
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]
@@ -4976,15 +5141,61 @@ function ExportPage() {
}
}
}, [
activeTaskCount,
collectVisibleSessionMetricTargets,
enqueueSessionMediaMetricRequests,
filteredContacts,
isSessionCountStageReady,
scheduleSessionMediaMetricWorker,
sessionRowByUsername
])
useEffect(() => {
if (activeTaskCount > 0) return
const runId = sessionMediaMetricRunIdRef.current
const allTargets = [
...(loadDetailTargetsByTab.private || []),
...(loadDetailTargetsByTab.group || []),
...(loadDetailTargetsByTab.former_friend || [])
]
if (allTargets.length === 0) return
let timer: number | null = null
let cursor = 0
const feedNext = () => {
if (runId !== sessionMediaMetricRunIdRef.current) return
const batchIds: string[] = []
while (cursor < allTargets.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) {
const sessionId = allTargets[cursor]
cursor += 1
if (!sessionId) continue
batchIds.push(sessionId)
}
if (batchIds.length > 0) {
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,
loadDetailTargetsByTab.former_friend,
loadDetailTargetsByTab.group,
loadDetailTargetsByTab.private,
scheduleSessionMediaMetricWorker
])
useEffect(() => {
if (activeTaskCount > 0) return
if (!isSessionCountStageReady || filteredContacts.length === 0) return
const runId = sessionMutualFriendsRunIdRef.current
const visibleTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts)
@@ -5031,6 +5242,7 @@ function ExportPage() {
}
}
}, [
activeTaskCount,
collectVisibleSessionMutualFriendsTargets,
enqueueSessionMutualFriendsRequests,
filteredContacts,
@@ -5348,16 +5560,16 @@ function ExportPage() {
const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
const shouldRunBackgroundRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
if (shouldRunPreciseRefresh) {
if (shouldRunBackgroundRefresh) {
setIsRefreshingSessionDetailStats(true)
void (async () => {
try {
// 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示
// 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列
const freshResult = await window.electronAPI.chat.getExportSessionStats(
[normalizedSessionId],
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true }
{ includeRelations: false, forceRefresh: true }
)
if (requestSeq !== detailRequestSeqRef.current) return
if (freshResult.success && freshResult.data) {
@@ -6083,14 +6295,10 @@ function ExportPage() {
<button
type="button"
className="row-open-chat-link"
title="在新窗口打开该会话"
title="切换到聊天页查看该会话"
onClick={() => {
void window.electronAPI.window.openSessionChatWindow(contact.username, {
source: 'export',
initialDisplayName: contact.displayName || contact.username,
initialAvatarUrl: contact.avatarUrl,
initialContactType: contact.type
})
setCurrentSession(contact.username)
navigate('/chat')
}}
>
{openChatLabel}
@@ -6198,6 +6406,7 @@ function ExportPage() {
)
}, [
lastExportBySession,
navigate,
nowTick,
openContactSnsTimeline,
openSessionDetail,
@@ -6219,6 +6428,7 @@ function ExportPage() {
shouldShowSnsColumn,
snsUserPostCounts,
snsUserPostCountsStatus,
setCurrentSession,
toggleSelectSession
])
const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {

View File

@@ -319,8 +319,7 @@ export interface ElectronAPI {
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => () => void
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
}
@@ -862,6 +861,10 @@ export interface ExportProgress {
phaseProgress?: number
phaseTotal?: number
phaseLabel?: string
collectedMessages?: number
exportedMessages?: number
estimatedTotalMessages?: number
writtenFiles?: number
}
export interface WxidInfo {