mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
重构与优化,旨在解决遗留的性能问题并优化用户体验,本次提交遗留了较多的待测功能
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
Reference in New Issue
Block a user