perf(export): prioritize totals and keep table visible

This commit is contained in:
tisonhuang
2026-03-01 17:27:10 +08:00
parent d12c111684
commit bf9b5ba593
6 changed files with 216 additions and 59 deletions

View File

@@ -916,6 +916,10 @@ function registerIpcHandlers() {
return chatService.getExportTabCounts() return chatService.getExportTabCounts()
}) })
ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => {
return chatService.getSessionMessageCounts(sessionIds)
})
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => {
return chatService.enrichSessionsContactInfo(usernames) return chatService.enrichSessionsContactInfo(usernames)
}) })

View File

@@ -131,6 +131,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
connect: () => ipcRenderer.invoke('chat:connect'), connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'), getSessions: () => ipcRenderer.invoke('chat:getSessions'),
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
enrichSessionsContactInfo: (usernames: string[]) => enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>

View File

@@ -770,6 +770,48 @@ class ChatService {
} }
} }
/**
* 批量获取会话消息总数(轻量接口,用于列表优先排序)
*/
async getSessionMessageCounts(sessionIds: string[]): Promise<{
success: boolean
counts?: Record<string, number>
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<string, number> = {}
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) }
}
}
/** /**
* 获取通讯录列表 * 获取通讯录列表
*/ */

View File

@@ -439,6 +439,7 @@
border-radius: 12px; border-radius: 12px;
background: var(--card-bg); background: var(--card-bg);
padding: 12px; padding: 12px;
flex: 1;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -552,7 +553,8 @@
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 10px;
min-height: 0; min-height: 320px;
height: 100%;
flex: 1; flex: 1;
} }

View File

@@ -237,9 +237,29 @@ const timestampOrDash = (timestamp?: number): string => {
} }
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const METRICS_VIEWPORT_PREFETCH = 140 const MESSAGE_COUNT_VIEWPORT_PREFETCH = 220
const METRICS_BACKGROUND_BATCH = 60 const MESSAGE_COUNT_BACKGROUND_BATCH = 180
const METRICS_BACKGROUND_INTERVAL_MS = 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 <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
let timer: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
promise,
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs)
})
])
} finally {
if (timer) {
clearTimeout(timer)
}
}
}
const WriteLayoutSelector = memo(function WriteLayoutSelector({ const WriteLayoutSelector = memo(function WriteLayoutSelector({
writeLayout, writeLayout,
@@ -306,6 +326,7 @@ function ExportPage() {
const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false)
const [sessions, setSessions] = useState<SessionRow[]>([]) const [sessions, setSessions] = useState<SessionRow[]>([])
const [prefetchedTabCounts, setPrefetchedTabCounts] = useState<Record<ConversationTab, number> | null>(null) const [prefetchedTabCounts, setPrefetchedTabCounts] = useState<Record<ConversationTab, number> | null>(null)
const [sessionMessageCounts, setSessionMessageCounts] = useState<Record<string, number>>({})
const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({}) const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({})
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [activeTab, setActiveTab] = useState<ConversationTab>('private') const [activeTab, setActiveTab] = useState<ConversationTab>('private')
@@ -355,8 +376,10 @@ function ExportPage() {
const progressUnsubscribeRef = useRef<(() => void) | null>(null) const progressUnsubscribeRef = useRef<(() => void) | null>(null)
const runningTaskIdRef = useRef<string | null>(null) const runningTaskIdRef = useRef<string | null>(null)
const tasksRef = useRef<ExportTask[]>([]) const tasksRef = useRef<ExportTask[]>([])
const sessionMessageCountsRef = useRef<Record<string, number>>({})
const sessionMetricsRef = useRef<Record<string, SessionMetrics>>({}) const sessionMetricsRef = useRef<Record<string, SessionMetrics>>({})
const sessionLoadTokenRef = useRef(0) const sessionLoadTokenRef = useRef(0)
const loadingMessageCountsRef = useRef<Set<string>>(new Set())
const loadingMetricsRef = useRef<Set<string>>(new Set()) const loadingMetricsRef = useRef<Set<string>>(new Set())
const preselectAppliedRef = useRef(false) const preselectAppliedRef = useRef(false)
const visibleSessionsRef = useRef<SessionRow[]>([]) const visibleSessionsRef = useRef<SessionRow[]>([])
@@ -365,6 +388,10 @@ function ExportPage() {
tasksRef.current = tasks tasksRef.current = tasks
}, [tasks]) }, [tasks])
useEffect(() => {
sessionMessageCountsRef.current = sessionMessageCounts
}, [sessionMessageCounts])
useEffect(() => { useEffect(() => {
sessionMetricsRef.current = sessionMetrics sessionMetricsRef.current = sessionMetrics
}, [sessionMetrics]) }, [sessionMetrics])
@@ -468,6 +495,12 @@ function ExportPage() {
sessionLoadTokenRef.current = loadToken sessionLoadTokenRef.current = loadToken
setIsLoading(true) setIsLoading(true)
setIsSessionEnriching(false) setIsSessionEnriching(false)
loadingMessageCountsRef.current.clear()
loadingMetricsRef.current.clear()
sessionMessageCountsRef.current = {}
sessionMetricsRef.current = {}
setSessionMessageCounts({})
setSessionMetrics({})
const isStale = () => sessionLoadTokenRef.current !== loadToken const isStale = () => sessionLoadTokenRef.current !== loadToken
@@ -503,10 +536,10 @@ function ExportPage() {
setIsSessionEnriching(true) setIsSessionEnriching(true)
void (async () => { void (async () => {
try { try {
const contactsResult = await window.electronAPI.chat.getContacts() const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
if (isStale()) return 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<Record<string, ContactInfo>>((map, contact) => { const nextContactMap = contacts.reduce<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact map[contact.username] = contact
return map return map
@@ -518,8 +551,11 @@ function ExportPage() {
let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {} let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {}
if (needsEnrichment.length > 0) { if (needsEnrichment.length > 0) {
const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) const enrichResult = await withTimeout(
if (enrichResult.success && enrichResult.contacts) { window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment),
CONTACT_ENRICH_TIMEOUT_MS
)
if (enrichResult?.success && enrichResult.contacts) {
extraContactMap = enrichResult.contacts extraContactMap = enrichResult.contacts
} }
} }
@@ -539,12 +575,7 @@ function ExportPage() {
avatarUrl avatarUrl
} }
}) })
.sort((a, b) => { .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
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)
})
setSessions(nextSessions) setSessions(nextSessions)
} catch (enrichError) { } catch (enrichError) {
@@ -566,10 +597,8 @@ function ExportPage() {
useEffect(() => { useEffect(() => {
void loadBaseConfig() void loadBaseConfig()
void (async () => { void loadTabCounts()
await loadTabCounts() void loadSessions()
await loadSessions()
})()
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
@@ -608,23 +637,74 @@ function ExportPage() {
) )
}) })
.sort((a, b) => { .sort((a, b) => {
const totalA = sessionMetrics[a.username]?.totalMessages ?? 0 const totalA = sessionMessageCounts[a.username]
const totalB = sessionMetrics[b.username]?.totalMessages ?? 0 const totalB = sessionMessageCounts[b.username]
if (totalB !== totalA) { const hasTotalA = typeof totalA === 'number'
const hasTotalB = typeof totalB === 'number'
if (hasTotalA && hasTotalB && totalB !== totalA) {
return totalB - totalA return totalB - totalA
} }
if (hasTotalA !== hasTotalB) {
return hasTotalA ? -1 : 1
}
const latestA = sessionMetrics[a.username]?.lastTimestamp ?? a.lastTimestamp ?? 0 const latestA = sessionMetrics[a.username]?.lastTimestamp ?? a.lastTimestamp ?? 0
const latestB = sessionMetrics[b.username]?.lastTimestamp ?? b.lastTimestamp ?? 0 const latestB = sessionMetrics[b.username]?.lastTimestamp ?? b.lastTimestamp ?? 0
return latestB - latestA return latestB - latestA
}) })
}, [sessions, activeTab, searchKeyword, sessionMetrics]) }, [sessions, activeTab, searchKeyword, sessionMessageCounts, sessionMetrics])
useEffect(() => { useEffect(() => {
visibleSessionsRef.current = visibleSessions visibleSessionsRef.current = visibleSessions
}, [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<string, number> = {}
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 ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => {
const loadTokenAtStart = sessionLoadTokenRef.current
const currentMetrics = sessionMetricsRef.current const currentMetrics = sessionMetricsRef.current
const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username))
if (pending.length === 0) return if (pending.length === 0) return
@@ -637,6 +717,7 @@ function ExportPage() {
try { try {
const batchSize = 80 const batchSize = 80
for (let i = 0; i < pending.length; i += batchSize) { for (let i = 0; i < pending.length; i += batchSize) {
if (loadTokenAtStart !== sessionLoadTokenRef.current) return
const chunk = pending.slice(i, i + batchSize) const chunk = pending.slice(i, i + batchSize)
const ids = chunk.map(session => session.username) 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 })) setSessionMetrics(prev => ({ ...prev, ...updates }))
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
const keyword = searchKeyword.trim().toLowerCase() const targets = visibleSessions.slice(0, MESSAGE_COUNT_VIEWPORT_PREFETCH)
const targets = sessions void ensureSessionMessageCounts(targets)
.filter((session) => { }, [visibleSessions, ensureSessionMessageCounts])
if (session.kind !== activeTab) return false
if (!keyword) return true useEffect(() => {
return ( const targets = visibleSessions.slice(0, METRICS_VIEWPORT_PREFETCH)
(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)
void ensureSessionMetrics(targets) void ensureSessionMetrics(targets)
}, [sessions, activeTab, searchKeyword, ensureSessionMetrics]) }, [visibleSessions, ensureSessionMetrics])
const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => {
const current = visibleSessionsRef.current const current = visibleSessionsRef.current
if (current.length === 0) return if (current.length === 0) return
const start = Math.max(0, range.startIndex - METRICS_VIEWPORT_PREFETCH) const prefetch = Math.max(MESSAGE_COUNT_VIEWPORT_PREFETCH, METRICS_VIEWPORT_PREFETCH)
const end = Math.min(current.length - 1, range.endIndex + 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 if (end < start) return
void ensureSessionMetrics(current.slice(start, end + 1)) const rangeSessions = current.slice(start, end + 1)
}, [ensureSessionMetrics]) 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(() => { useEffect(() => {
if (sessions.length === 0) return if (sessions.length === 0) return
@@ -1335,7 +1429,8 @@ function ExportPage() {
} }
const renderRowCells = (session: SessionRow) => { 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) const checked = selectedSessions.has(session.username)
return ( return (
@@ -1351,35 +1446,43 @@ function ExportPage() {
</td> </td>
<td>{renderSessionName(session)}</td> <td>{renderSessionName(session)}</td>
<td>{valueOrDash(metrics.totalMessages)}</td> <td>
<td>{valueOrDash(metrics.voiceMessages)}</td> {typeof totalMessages === 'number'
<td>{valueOrDash(metrics.imageMessages)}</td> ? totalMessages.toLocaleString()
<td>{valueOrDash(metrics.videoMessages)}</td> : (
<td>{valueOrDash(metrics.emojiMessages)}</td> <span className="count-loading">
<span className="animated-ellipsis" aria-hidden="true">...</span>
</span>
)}
</td>
<td>{valueOrDash(metrics?.voiceMessages)}</td>
<td>{valueOrDash(metrics?.imageMessages)}</td>
<td>{valueOrDash(metrics?.videoMessages)}</td>
<td>{valueOrDash(metrics?.emojiMessages)}</td>
{(activeTab === 'private' || activeTab === 'former_friend') && ( {(activeTab === 'private' || activeTab === 'former_friend') && (
<> <>
<td>{valueOrDash(metrics.privateMutualGroups)}</td> <td>{valueOrDash(metrics?.privateMutualGroups)}</td>
<td>{timestampOrDash(metrics.firstTimestamp)}</td> <td>{timestampOrDash(metrics?.firstTimestamp)}</td>
<td>{timestampOrDash(metrics.lastTimestamp)}</td> <td>{timestampOrDash(metrics?.lastTimestamp)}</td>
</> </>
)} )}
{activeTab === 'group' && ( {activeTab === 'group' && (
<> <>
<td>{valueOrDash(metrics.groupMyMessages)}</td> <td>{valueOrDash(metrics?.groupMyMessages)}</td>
<td>{valueOrDash(metrics.groupMemberCount)}</td> <td>{valueOrDash(metrics?.groupMemberCount)}</td>
<td>{valueOrDash(metrics.groupActiveSpeakers)}</td> <td>{valueOrDash(metrics?.groupActiveSpeakers)}</td>
<td>{valueOrDash(metrics.groupMutualFriends)}</td> <td>{valueOrDash(metrics?.groupMutualFriends)}</td>
<td>{timestampOrDash(metrics.firstTimestamp)}</td> <td>{timestampOrDash(metrics?.firstTimestamp)}</td>
<td>{timestampOrDash(metrics.lastTimestamp)}</td> <td>{timestampOrDash(metrics?.lastTimestamp)}</td>
</> </>
)} )}
{activeTab === 'official' && ( {activeTab === 'official' && (
<> <>
<td>{timestampOrDash(metrics.firstTimestamp)}</td> <td>{timestampOrDash(metrics?.firstTimestamp)}</td>
<td>{timestampOrDash(metrics.lastTimestamp)}</td> <td>{timestampOrDash(metrics?.lastTimestamp)}</td>
</> </>
)} )}
@@ -1616,10 +1719,10 @@ function ExportPage() {
</div> </div>
</div> </div>
{(isLoading || isSessionEnriching) && ( {!showInitialSkeleton && (isLoading || isSessionEnriching) && (
<div className="table-stage-hint"> <div className="table-stage-hint">
<Loader2 size={14} className="spin" /> <Loader2 size={14} className="spin" />
{isLoading ? '正在加载会话列表…' : '正在补充头像和统计…'} {isLoading ? '正在刷新会话列表…' : '正在补充头像和统计…'}
</div> </div>
)} )}

View File

@@ -84,6 +84,11 @@ export interface ElectronAPI {
} }
error?: string error?: string
}> }>
getSessionMessageCounts: (sessionIds: string[]) => Promise<{
success: boolean
counts?: Record<string, number>
error?: string
}>
enrichSessionsContactInfo: (usernames: string[]) => Promise<{ enrichSessionsContactInfo: (usernames: string[]) => Promise<{
success: boolean success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }> contacts?: Record<string, { displayName?: string; avatarUrl?: string }>