From bf9b5ba5935e5325ac84fb4d1f03bcc9ca5e6a0c Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 17:27:10 +0800 Subject: [PATCH] perf(export): prioritize totals and keep table visible --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/chatService.ts | 42 ++++++ src/pages/ExportPage.scss | 4 +- src/pages/ExportPage.tsx | 219 +++++++++++++++++++++++-------- src/types/electron.d.ts | 5 + 6 files changed, 216 insertions(+), 59 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index c13c9dc..14bf1e6 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -916,6 +916,10 @@ function registerIpcHandlers() { return chatService.getExportTabCounts() }) + ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { + return chatService.getSessionMessageCounts(sessionIds) + }) + ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { return chatService.enrichSessionsContactInfo(usernames) }) diff --git a/electron/preload.ts b/electron/preload.ts index c0f76d1..43a478f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -131,6 +131,7 @@ contextBridge.exposeInMainWorld('electronAPI', { connect: () => ipcRenderer.invoke('chat:connect'), getSessions: () => ipcRenderer.invoke('chat:getSessions'), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), + getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), enrichSessionsContactInfo: (usernames: string[]) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index d55ef0f..67984e9 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -770,6 +770,48 @@ class ChatService { } } + /** + * 批量获取会话消息总数(轻量接口,用于列表优先排序) + */ + async getSessionMessageCounts(sessionIds: string[]): Promise<{ + success: boolean + counts?: Record + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedSessionIds.length === 0) { + return { success: true, counts: {} } + } + + const counts: Record = {} + await this.forEachWithConcurrency(normalizedSessionIds, 8, async (sessionId) => { + try { + const result = await wcdbService.getMessageCount(sessionId) + counts[sessionId] = result.success && typeof result.count === 'number' ? result.count : 0 + } catch { + counts[sessionId] = 0 + } + }) + + return { success: true, counts } + } catch (e) { + console.error('ChatService: 批量获取会话消息总数失败:', e) + return { success: false, error: String(e) } + } + } + /** * 获取通讯录列表 */ diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index c576959..ae7129b 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -439,6 +439,7 @@ border-radius: 12px; background: var(--card-bg); padding: 12px; + flex: 1; min-height: 0; display: flex; flex-direction: column; @@ -552,7 +553,8 @@ overflow: hidden; border: 1px solid var(--border-color); border-radius: 10px; - min-height: 0; + min-height: 320px; + height: 100%; flex: 1; } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c5338bf..671c8e2 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -237,9 +237,29 @@ const timestampOrDash = (timestamp?: number): string => { } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const METRICS_VIEWPORT_PREFETCH = 140 -const METRICS_BACKGROUND_BATCH = 60 -const METRICS_BACKGROUND_INTERVAL_MS = 180 +const MESSAGE_COUNT_VIEWPORT_PREFETCH = 220 +const MESSAGE_COUNT_BACKGROUND_BATCH = 180 +const MESSAGE_COUNT_BACKGROUND_INTERVAL_MS = 100 +const METRICS_VIEWPORT_PREFETCH = 90 +const METRICS_BACKGROUND_BATCH = 40 +const METRICS_BACKGROUND_INTERVAL_MS = 220 +const CONTACT_ENRICH_TIMEOUT_MS = 7000 + +const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { + let timer: ReturnType | null = null + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs) + }) + ]) + } finally { + if (timer) { + clearTimeout(timer) + } + } +} const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, @@ -306,6 +326,7 @@ function ExportPage() { const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) const [prefetchedTabCounts, setPrefetchedTabCounts] = useState | null>(null) + const [sessionMessageCounts, setSessionMessageCounts] = useState>({}) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') @@ -355,8 +376,10 @@ function ExportPage() { const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) + const sessionMessageCountsRef = useRef>({}) const sessionMetricsRef = useRef>({}) const sessionLoadTokenRef = useRef(0) + const loadingMessageCountsRef = useRef>(new Set()) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) const visibleSessionsRef = useRef([]) @@ -365,6 +388,10 @@ function ExportPage() { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + sessionMessageCountsRef.current = sessionMessageCounts + }, [sessionMessageCounts]) + useEffect(() => { sessionMetricsRef.current = sessionMetrics }, [sessionMetrics]) @@ -468,6 +495,12 @@ function ExportPage() { sessionLoadTokenRef.current = loadToken setIsLoading(true) setIsSessionEnriching(false) + loadingMessageCountsRef.current.clear() + loadingMetricsRef.current.clear() + sessionMessageCountsRef.current = {} + sessionMetricsRef.current = {} + setSessionMessageCounts({}) + setSessionMetrics({}) const isStale = () => sessionLoadTokenRef.current !== loadToken @@ -503,10 +536,10 @@ function ExportPage() { setIsSessionEnriching(true) void (async () => { try { - const contactsResult = await window.electronAPI.chat.getContacts() + const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) if (isStale()) return - const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] + const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] const nextContactMap = contacts.reduce>((map, contact) => { map[contact.username] = contact return map @@ -518,8 +551,11 @@ function ExportPage() { let extraContactMap: Record = {} if (needsEnrichment.length > 0) { - const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) - if (enrichResult.success && enrichResult.contacts) { + const enrichResult = await withTimeout( + window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment), + CONTACT_ENRICH_TIMEOUT_MS + ) + if (enrichResult?.success && enrichResult.contacts) { extraContactMap = enrichResult.contacts } } @@ -539,12 +575,7 @@ function ExportPage() { avatarUrl } }) - .sort((a, b) => { - const aMetric = sessionMetricsRef.current[a.username]?.totalMessages ?? 0 - const bMetric = sessionMetricsRef.current[b.username]?.totalMessages ?? 0 - if (bMetric !== aMetric) return bMetric - aMetric - return (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0) - }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) setSessions(nextSessions) } catch (enrichError) { @@ -566,10 +597,8 @@ function ExportPage() { useEffect(() => { void loadBaseConfig() - void (async () => { - await loadTabCounts() - await loadSessions() - })() + void loadTabCounts() + void loadSessions() // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { @@ -608,23 +637,74 @@ function ExportPage() { ) }) .sort((a, b) => { - const totalA = sessionMetrics[a.username]?.totalMessages ?? 0 - const totalB = sessionMetrics[b.username]?.totalMessages ?? 0 - if (totalB !== totalA) { + const totalA = sessionMessageCounts[a.username] + const totalB = sessionMessageCounts[b.username] + const hasTotalA = typeof totalA === 'number' + const hasTotalB = typeof totalB === 'number' + + if (hasTotalA && hasTotalB && totalB !== totalA) { return totalB - totalA } + if (hasTotalA !== hasTotalB) { + return hasTotalA ? -1 : 1 + } const latestA = sessionMetrics[a.username]?.lastTimestamp ?? a.lastTimestamp ?? 0 const latestB = sessionMetrics[b.username]?.lastTimestamp ?? b.lastTimestamp ?? 0 return latestB - latestA }) - }, [sessions, activeTab, searchKeyword, sessionMetrics]) + }, [sessions, activeTab, searchKeyword, sessionMessageCounts, sessionMetrics]) useEffect(() => { visibleSessionsRef.current = visibleSessions }, [visibleSessions]) + const ensureSessionMessageCounts = useCallback(async (targetSessions: SessionRow[]) => { + const loadTokenAtStart = sessionLoadTokenRef.current + const currentCounts = sessionMessageCountsRef.current + const pending = targetSessions.filter( + session => currentCounts[session.username] === undefined && !loadingMessageCountsRef.current.has(session.username) + ) + if (pending.length === 0) return + + const updates: Record = {} + for (const session of pending) { + loadingMessageCountsRef.current.add(session.username) + } + + try { + const batchSize = 220 + for (let i = 0; i < pending.length; i += batchSize) { + if (loadTokenAtStart !== sessionLoadTokenRef.current) return + const chunk = pending.slice(i, i + batchSize) + const ids = chunk.map(session => session.username) + + try { + const result = await window.electronAPI.chat.getSessionMessageCounts(ids) + for (const session of chunk) { + const value = result.success && result.counts ? result.counts[session.username] : undefined + updates[session.username] = typeof value === 'number' ? value : 0 + } + } catch (error) { + console.error('加载会话总消息数失败:', error) + for (const session of chunk) { + updates[session.username] = 0 + } + } + } + } finally { + for (const session of pending) { + loadingMessageCountsRef.current.delete(session.username) + } + } + + if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { + setSessionMessageCounts(prev => ({ ...prev, ...updates })) + } + }, []) + const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { + const loadTokenAtStart = sessionLoadTokenRef.current const currentMetrics = sessionMetricsRef.current const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) if (pending.length === 0) return @@ -637,6 +717,7 @@ function ExportPage() { try { const batchSize = 80 for (let i = 0; i < pending.length; i += batchSize) { + if (loadTokenAtStart !== sessionLoadTokenRef.current) return const chunk = pending.slice(i, i + batchSize) const ids = chunk.map(session => session.username) @@ -677,35 +758,48 @@ function ExportPage() { } } - if (Object.keys(updates).length > 0) { + if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } }, []) useEffect(() => { - const keyword = searchKeyword.trim().toLowerCase() - const targets = sessions - .filter((session) => { - if (session.kind !== activeTab) return false - if (!keyword) return true - return ( - (session.displayName || '').toLowerCase().includes(keyword) || - session.username.toLowerCase().includes(keyword) - ) - }) - .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) - .slice(0, METRICS_VIEWPORT_PREFETCH) + const targets = visibleSessions.slice(0, MESSAGE_COUNT_VIEWPORT_PREFETCH) + void ensureSessionMessageCounts(targets) + }, [visibleSessions, ensureSessionMessageCounts]) + + useEffect(() => { + const targets = visibleSessions.slice(0, METRICS_VIEWPORT_PREFETCH) void ensureSessionMetrics(targets) - }, [sessions, activeTab, searchKeyword, ensureSessionMetrics]) + }, [visibleSessions, ensureSessionMetrics]) const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { const current = visibleSessionsRef.current if (current.length === 0) return - const start = Math.max(0, range.startIndex - METRICS_VIEWPORT_PREFETCH) - const end = Math.min(current.length - 1, range.endIndex + METRICS_VIEWPORT_PREFETCH) + const prefetch = Math.max(MESSAGE_COUNT_VIEWPORT_PREFETCH, METRICS_VIEWPORT_PREFETCH) + const start = Math.max(0, range.startIndex - prefetch) + const end = Math.min(current.length - 1, range.endIndex + prefetch) if (end < start) return - void ensureSessionMetrics(current.slice(start, end + 1)) - }, [ensureSessionMetrics]) + const rangeSessions = current.slice(start, end + 1) + void ensureSessionMessageCounts(rangeSessions) + void ensureSessionMetrics(rangeSessions) + }, [ensureSessionMessageCounts, ensureSessionMetrics]) + + useEffect(() => { + if (sessions.length === 0) return + let cursor = 0 + const timer = window.setInterval(() => { + if (cursor >= sessions.length) { + window.clearInterval(timer) + return + } + const chunk = sessions.slice(cursor, cursor + MESSAGE_COUNT_BACKGROUND_BATCH) + cursor += MESSAGE_COUNT_BACKGROUND_BATCH + void ensureSessionMessageCounts(chunk) + }, MESSAGE_COUNT_BACKGROUND_INTERVAL_MS) + + return () => window.clearInterval(timer) + }, [sessions, ensureSessionMessageCounts]) useEffect(() => { if (sessions.length === 0) return @@ -1335,7 +1429,8 @@ function ExportPage() { } const renderRowCells = (session: SessionRow) => { - const metrics = sessionMetrics[session.username] || {} + const metrics = sessionMetrics[session.username] + const totalMessages = sessionMessageCounts[session.username] const checked = selectedSessions.has(session.username) return ( @@ -1351,35 +1446,43 @@ function ExportPage() { {renderSessionName(session)} - {valueOrDash(metrics.totalMessages)} - {valueOrDash(metrics.voiceMessages)} - {valueOrDash(metrics.imageMessages)} - {valueOrDash(metrics.videoMessages)} - {valueOrDash(metrics.emojiMessages)} + + {typeof totalMessages === 'number' + ? totalMessages.toLocaleString() + : ( + + 统计中 + + )} + + {valueOrDash(metrics?.voiceMessages)} + {valueOrDash(metrics?.imageMessages)} + {valueOrDash(metrics?.videoMessages)} + {valueOrDash(metrics?.emojiMessages)} {(activeTab === 'private' || activeTab === 'former_friend') && ( <> - {valueOrDash(metrics.privateMutualGroups)} - {timestampOrDash(metrics.firstTimestamp)} - {timestampOrDash(metrics.lastTimestamp)} + {valueOrDash(metrics?.privateMutualGroups)} + {timestampOrDash(metrics?.firstTimestamp)} + {timestampOrDash(metrics?.lastTimestamp)} )} {activeTab === 'group' && ( <> - {valueOrDash(metrics.groupMyMessages)} - {valueOrDash(metrics.groupMemberCount)} - {valueOrDash(metrics.groupActiveSpeakers)} - {valueOrDash(metrics.groupMutualFriends)} - {timestampOrDash(metrics.firstTimestamp)} - {timestampOrDash(metrics.lastTimestamp)} + {valueOrDash(metrics?.groupMyMessages)} + {valueOrDash(metrics?.groupMemberCount)} + {valueOrDash(metrics?.groupActiveSpeakers)} + {valueOrDash(metrics?.groupMutualFriends)} + {timestampOrDash(metrics?.firstTimestamp)} + {timestampOrDash(metrics?.lastTimestamp)} )} {activeTab === 'official' && ( <> - {timestampOrDash(metrics.firstTimestamp)} - {timestampOrDash(metrics.lastTimestamp)} + {timestampOrDash(metrics?.firstTimestamp)} + {timestampOrDash(metrics?.lastTimestamp)} )} @@ -1616,10 +1719,10 @@ function ExportPage() { - {(isLoading || isSessionEnriching) && ( + {!showInitialSkeleton && (isLoading || isSessionEnriching) && (
- {isLoading ? '正在加载会话列表…' : '正在补充头像和统计…'} + {isLoading ? '正在刷新会话列表…' : '正在补充头像和统计…'}
)} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 4d96cb9..471ac70 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -84,6 +84,11 @@ export interface ElectronAPI { } error?: string }> + getSessionMessageCounts: (sessionIds: string[]) => Promise<{ + success: boolean + counts?: Record + error?: string + }> enrichSessionsContactInfo: (usernames: string[]) => Promise<{ success: boolean contacts?: Record