From 9dd5ee236522c64c9dce4a20482c3a52f6e5cdc9 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:44:32 +0800 Subject: [PATCH] fix(export): align media load progress with visible loaded state --- src/pages/ExportPage.scss | 8 ++ src/pages/ExportPage.tsx | 165 ++++++++++++++++++++++++++++++-------- 2 files changed, 140 insertions(+), 33 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 78cb3a1..4ac3e47 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -241,6 +241,14 @@ flex-shrink: 0; } +.session-load-detail-progress-pulse { + color: var(--text-tertiary); + font-size: 11px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.1px; + flex-shrink: 0; +} + .global-export-controls { background: var(--card-bg); border: 1px solid var(--border-color); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index bd33b92..c079795 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -711,6 +711,15 @@ interface SessionLoadTraceState { mediaMetrics: SessionLoadStageState } +interface SessionLoadStageSummary { + total: number + loaded: number + statusLabel: string + startedAt?: number + finishedAt?: number + latestProgressAt?: number +} + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -1279,6 +1288,7 @@ function ExportPage() { const [isSessionCountStageReady, setIsSessionCountStageReady] = useState(false) const [sessionContentMetrics, setSessionContentMetrics] = useState>({}) const [sessionLoadTraceMap, setSessionLoadTraceMap] = useState>({}) + const [sessionLoadProgressPulseMap, setSessionLoadProgressPulseMap] = useState>({}) const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const [contactsLoadSession, setContactsLoadSession] = useState(null) const [contactsLoadIssue, setContactsLoadIssue] = useState(null) @@ -1382,6 +1392,7 @@ function ExportPage() { const activeTabRef = useRef('private') const detailStatsPriorityRef = useRef(false) const sessionPreciseRefreshAtRef = useRef>({}) + const sessionLoadProgressSnapshotRef = useRef>({}) const sessionMediaMetricQueueRef = useRef([]) const sessionMediaMetricQueuedSetRef = useRef>(new Set()) const sessionMediaMetricLoadingSetRef = useRef>(new Set()) @@ -2352,6 +2363,8 @@ function ExportPage() { setSessionMessageCounts({}) setSessionContentMetrics({}) setSessionLoadTraceMap({}) + setSessionLoadProgressPulseMap({}) + sessionLoadProgressSnapshotRef.current = {} setIsLoadingSessionCounts(false) setIsSessionCountStageReady(false) @@ -2430,9 +2443,11 @@ function ExportPage() { } return acc }, {}) - const cachedContentMetricSessionIds = Object.keys(cachedContentMetrics) - if (cachedContentMetricSessionIds.length > 0) { - patchSessionLoadTraceStage(cachedContentMetricSessionIds, 'mediaMetrics', 'done') + const cachedContentMetricReadySessionIds = Object.entries(cachedContentMetrics) + .filter(([, metric]) => hasCompleteSessionMediaMetric(metric)) + .map(([sessionId]) => sessionId) + if (cachedContentMetricReadySessionIds.length > 0) { + patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done') } if (isStale()) return @@ -3828,16 +3843,22 @@ function ExportPage() { const summarizeLoadTraceForTab = useCallback(( sessionIds: string[], stageKey: keyof SessionLoadTraceState - ) => { + ): SessionLoadStageSummary => { const total = sessionIds.length let loaded = 0 let hasStarted = false let earliestStart: number | undefined let latestFinish: number | undefined + let latestProgressAt: number | undefined for (const sessionId of sessionIds) { const stage = sessionLoadTraceMap[sessionId]?.[stageKey] if (stage?.status === 'done') { loaded += 1 + if (typeof stage.finishedAt === 'number') { + latestProgressAt = latestProgressAt === undefined + ? stage.finishedAt + : Math.max(latestProgressAt, stage.finishedAt) + } } if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') { hasStarted = true @@ -3858,7 +3879,8 @@ function ExportPage() { loaded, statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted), startedAt: earliestStart, - finishedAt: loaded >= total ? latestFinish : undefined + finishedAt: loaded >= total ? latestFinish : undefined, + latestProgressAt } }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) @@ -3875,6 +3897,67 @@ function ExportPage() { }) }, [loadDetailTargetsByTab, summarizeLoadTraceForTab]) + const formatLoadDetailPulseTime = useCallback((value?: number): string => { + if (!value || !Number.isFinite(value)) return '--' + return new Date(value).toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + }, []) + + useEffect(() => { + const previousSnapshot = sessionLoadProgressSnapshotRef.current + const nextSnapshot: Record = {} + const resetKeys: string[] = [] + const updates: Array<{ key: string; at: number; delta: number }> = [] + const stageKeys: Array = ['messageCount', 'mediaMetrics'] + + for (const row of sessionLoadDetailRows) { + for (const stageKey of stageKeys) { + const summary = row[stageKey] + const key = `${stageKey}:${row.tab}` + const loaded = Number.isFinite(summary.loaded) ? Math.max(0, Math.floor(summary.loaded)) : 0 + const total = Number.isFinite(summary.total) ? Math.max(0, Math.floor(summary.total)) : 0 + nextSnapshot[key] = { loaded, total } + + const previous = previousSnapshot[key] + if (!previous || previous.total !== total || loaded < previous.loaded) { + resetKeys.push(key) + continue + } + if (loaded > previous.loaded) { + updates.push({ + key, + at: summary.latestProgressAt || Date.now(), + delta: loaded - previous.loaded + }) + } + } + } + + sessionLoadProgressSnapshotRef.current = nextSnapshot + if (resetKeys.length === 0 && updates.length === 0) return + + setSessionLoadProgressPulseMap(prev => { + let changed = false + const next = { ...prev } + for (const key of resetKeys) { + if (!(key in next)) continue + delete next[key] + changed = true + } + for (const update of updates) { + const previous = next[update.key] + if (previous && previous.at === update.at && previous.delta === update.delta) continue + next[update.key] = { at: update.at, delta: update.delta } + changed = true + } + return changed ? next : prev + }) + }, [sessionLoadDetailRows]) + useEffect(() => { contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' }) setIsContactsListAtTop(true) @@ -4482,7 +4565,6 @@ function ExportPage() { const metricToDisplay = (value: unknown): { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } => { const normalized = normalizeMessageCount(value) if (!canExport) return { state: 'na', text: '--' } - if (!isSessionCountStageReady) return { state: 'loading' } if (typeof normalized === 'number') { return { state: 'value', text: normalized.toLocaleString('zh-CN') } } @@ -4619,7 +4701,6 @@ function ExportPage() { sessionMessageCounts, sessionRowByUsername, showSessionDetailPanel, - isSessionCountStageReady, toggleSelectSession ]) const handleContactsListWheelCapture = useCallback((event: WheelEvent) => { @@ -5011,19 +5092,28 @@ function ExportPage() { 开始时间 完成时间 - {sessionLoadDetailRows.map((row) => ( -
- {row.label} - - {row.messageCount.statusLabel} - {row.messageCount.statusLabel.startsWith('加载中') && ( - - )} - - {formatLoadDetailTime(row.messageCount.startedAt)} - {formatLoadDetailTime(row.messageCount.finishedAt)} -
- ))} + {sessionLoadDetailRows.map((row) => { + const pulse = sessionLoadProgressPulseMap[`messageCount:${row.tab}`] + const isLoading = row.messageCount.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.messageCount.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条 + + )} + + {formatLoadDetailTime(row.messageCount.startedAt)} + {formatLoadDetailTime(row.messageCount.finishedAt)} +
+ ) + })} @@ -5036,19 +5126,28 @@ function ExportPage() { 开始时间 完成时间 - {sessionLoadDetailRows.map((row) => ( -
- {row.label} - - {row.mediaMetrics.statusLabel} - {row.mediaMetrics.statusLabel.startsWith('加载中') && ( - - )} - - {formatLoadDetailTime(row.mediaMetrics.startedAt)} - {formatLoadDetailTime(row.mediaMetrics.finishedAt)} -
- ))} + {sessionLoadDetailRows.map((row) => { + const pulse = sessionLoadProgressPulseMap[`mediaMetrics:${row.tab}`] + const isLoading = row.mediaMetrics.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.mediaMetrics.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条 + + )} + + {formatLoadDetailTime(row.mediaMetrics.startedAt)} + {formatLoadDetailTime(row.mediaMetrics.finishedAt)} +
+ ) + })}