From a5ae22d2a5cd11fb3496f166453c6bc7351f02ff Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 18:41:06 +0800 Subject: [PATCH] perf(chat): split session detail into fast and extra loading --- electron/main.ts | 8 + electron/preload.ts | 2 + electron/services/chatService.ts | 254 ++++++++++++++++++++++++------- src/pages/ChatPage.scss | 8 + src/pages/ChatPage.tsx | 146 +++++++++++++----- src/types/electron.d.ts | 22 +++ 6 files changed, 344 insertions(+), 96 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index a1ac68f..e73a715 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -986,6 +986,14 @@ function registerIpcHandlers() { return chatService.getSessionDetail(sessionId) }) + ipcMain.handle('chat:getSessionDetailFast', async (_, sessionId: string) => { + return chatService.getSessionDetailFast(sessionId) + }) + + ipcMain.handle('chat:getSessionDetailExtra', async (_, sessionId: string) => { + return chatService.getSessionDetailExtra(sessionId) + }) + ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[]) => { return chatService.getExportSessionStats(sessionIds) }) diff --git a/electron/preload.ts b/electron/preload.ts index 7a3c0af..999486f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -154,6 +154,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), close: () => ipcRenderer.invoke('chat:close'), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), + getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId), + getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), getExportSessionStats: (sessionIds: string[]) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index d5db221..0e655d3 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -159,6 +159,24 @@ interface ExportTabCounts { former_friend: number } +interface SessionDetailFast { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number +} + +interface SessionDetailExtra { + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] +} + +type SessionDetail = SessionDetailFast & SessionDetailExtra + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() @@ -201,6 +219,10 @@ class ChatService { private sessionMessageCountHintCache = new Map() private sessionMessageCountCacheScope = '' private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000 + private sessionDetailFastCache = new Map() + private sessionDetailExtraCache = new Map() + private readonly sessionDetailFastCacheTtlMs = 60 * 1000 + private readonly sessionDetailExtraCacheTtlMs = 5 * 60 * 1000 private sessionStatusCache = new Map() private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000 @@ -1565,6 +1587,8 @@ class ChatService { this.sessionMessageCountCacheScope = scope this.sessionMessageCountCache.clear() this.sessionMessageCountHintCache.clear() + this.sessionDetailFastCache.clear() + this.sessionDetailExtraCache.clear() this.sessionStatusCache.clear() } @@ -3819,20 +3843,9 @@ class ChatService { /** * 获取会话详情信息 */ - async getSessionDetail(sessionId: string): Promise<{ + async getSessionDetailFast(sessionId: string): Promise<{ success: boolean - detail?: { - wxid: string - displayName: string - remark?: string - nickName?: string - alias?: string - avatarUrl?: string - messageCount: number - firstMessageTime?: number - latestMessageTime?: number - messageTables: { dbName: string; tableName: string; count: number }[] - } + detail?: SessionDetailFast error?: string }> { try { @@ -3840,53 +3853,152 @@ class ChatService { if (!connectResult.success) { return { success: false, error: connectResult.error || '数据库未连接' } } + this.refreshSessionMessageCountCacheScope() - let displayName = sessionId + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) { + return { success: false, error: '会话ID不能为空' } + } + + const now = Date.now() + const cachedDetail = this.sessionDetailFastCache.get(normalizedSessionId) + if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailFastCacheTtlMs) { + return { success: true, detail: cachedDetail.detail } + } + + let displayName = normalizedSessionId let remark: string | undefined let nickName: string | undefined let alias: string | undefined let avatarUrl: string | undefined - - const contactResult = await wcdbService.getContact(sessionId) - if (contactResult.success && contactResult.contact) { - remark = contactResult.contact.remark || undefined - nickName = contactResult.contact.nickName || undefined - alias = contactResult.contact.alias || undefined - displayName = remark || nickName || alias || sessionId - } - const avatarResult = await wcdbService.getAvatarUrls([sessionId]) - if (avatarResult.success && avatarResult.map) { - avatarUrl = avatarResult.map[sessionId] + const cachedContact = this.avatarCache.get(normalizedSessionId) + if (cachedContact) { + displayName = cachedContact.displayName || normalizedSessionId + avatarUrl = cachedContact.avatarUrl } - const countResult = await wcdbService.getMessageCount(sessionId) - const totalMessageCount = countResult.success && countResult.count ? countResult.count : 0 + const [contactResult, avatarResult] = await Promise.allSettled([ + wcdbService.getContact(normalizedSessionId), + avatarUrl ? Promise.resolve({ success: true, map: { [normalizedSessionId]: avatarUrl } }) : wcdbService.getAvatarUrls([normalizedSessionId]) + ]) - let firstMessageTime: number | undefined - let latestMessageTime: number | undefined + if (contactResult.status === 'fulfilled' && contactResult.value.success && contactResult.value.contact) { + remark = contactResult.value.contact.remark || undefined + nickName = contactResult.value.contact.nickName || undefined + alias = contactResult.value.contact.alias || undefined + displayName = remark || nickName || alias || displayName + } - const earliestCursor = await wcdbService.openMessageCursor(sessionId, 1, true, 0, 0) - if (earliestCursor.success && earliestCursor.cursor) { - const batch = await wcdbService.fetchMessageBatch(earliestCursor.cursor) - if (batch.success && batch.rows && batch.rows.length > 0) { - firstMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined + if (avatarResult.status === 'fulfilled' && avatarResult.value.success && avatarResult.value.map) { + avatarUrl = avatarResult.value.map[normalizedSessionId] + } + + let messageCount: number | undefined + const cachedCount = this.sessionMessageCountCache.get(normalizedSessionId) + if (cachedCount && now - cachedCount.updatedAt <= this.sessionMessageCountCacheTtlMs) { + messageCount = cachedCount.count + } else { + const hintCount = this.sessionMessageCountHintCache.get(normalizedSessionId) + if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { + messageCount = Math.floor(hintCount) + this.sessionMessageCountCache.set(normalizedSessionId, { + count: messageCount, + updatedAt: now + }) } - await wcdbService.closeMessageCursor(earliestCursor.cursor) } - const latestCursor = await wcdbService.openMessageCursor(sessionId, 1, false, 0, 0) - if (latestCursor.success && latestCursor.cursor) { - const batch = await wcdbService.fetchMessageBatch(latestCursor.cursor) - if (batch.success && batch.rows && batch.rows.length > 0) { - latestMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined - } - await wcdbService.closeMessageCursor(latestCursor.cursor) + if (!Number.isFinite(messageCount)) { + const countResult = await wcdbService.getMessageCount(normalizedSessionId) + messageCount = countResult.success && Number.isFinite(countResult.count) + ? Math.max(0, Math.floor(countResult.count || 0)) + : 0 + this.sessionMessageCountCache.set(normalizedSessionId, { + count: messageCount, + updatedAt: Date.now() + }) } + const detail: SessionDetailFast = { + wxid: normalizedSessionId, + displayName, + remark, + nickName, + alias, + avatarUrl, + messageCount: Math.max(0, Math.floor(messageCount || 0)) + } + + this.sessionDetailFastCache.set(normalizedSessionId, { + detail, + updatedAt: Date.now() + }) + + return { success: true, detail } + } catch (e) { + console.error('ChatService: 获取会话详情快速信息失败:', e) + return { success: false, error: String(e) } + } + } + + private async getBoundaryMessageTime(sessionId: string, ascending: boolean): Promise { + const cursorResult = await wcdbService.openMessageCursor(sessionId, 1, ascending, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return undefined + } + + const cursor = cursorResult.cursor + try { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success || !batch.rows || batch.rows.length === 0) { + return undefined + } + const ts = parseInt(batch.rows[0].create_time || '0', 10) + return Number.isFinite(ts) && ts > 0 ? ts : undefined + } finally { + await wcdbService.closeMessageCursor(cursor) + } + } + + async getSessionDetailExtra(sessionId: string): Promise<{ + success: boolean + detail?: SessionDetailExtra + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + this.refreshSessionMessageCountCacheScope() + + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) { + return { success: false, error: '会话ID不能为空' } + } + + const now = Date.now() + const cachedDetail = this.sessionDetailExtraCache.get(normalizedSessionId) + if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailExtraCacheTtlMs) { + return { success: true, detail: cachedDetail.detail } + } + + const [firstMessageTimeResult, latestMessageTimeResult, tableStatsResult] = await Promise.allSettled([ + this.getBoundaryMessageTime(normalizedSessionId, true), + this.getBoundaryMessageTime(normalizedSessionId, false), + wcdbService.getMessageTableStats(normalizedSessionId) + ]) + + const firstMessageTime = firstMessageTimeResult.status === 'fulfilled' + ? firstMessageTimeResult.value + : undefined + const latestMessageTime = latestMessageTimeResult.status === 'fulfilled' + ? latestMessageTimeResult.value + : undefined + const messageTables: { dbName: string; tableName: string; count: number }[] = [] - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (tableStats.success && tableStats.tables) { - for (const row of tableStats.tables) { + if (tableStatsResult.status === 'fulfilled' && tableStatsResult.value.success && tableStatsResult.value.tables) { + for (const row of tableStatsResult.value.tables) { messageTables.push({ dbName: basename(row.db_path || ''), tableName: row.table_name || '', @@ -3895,21 +4007,49 @@ class ChatService { } } + const detail: SessionDetailExtra = { + firstMessageTime, + latestMessageTime, + messageTables + } + + this.sessionDetailExtraCache.set(normalizedSessionId, { + detail, + updatedAt: Date.now() + }) + return { success: true, - detail: { - wxid: sessionId, - displayName, - remark, - nickName, - alias, - avatarUrl, - messageCount: totalMessageCount, - firstMessageTime, - latestMessageTime, - messageTables - } + detail } + } catch (e) { + console.error('ChatService: 获取会话详情补充统计失败:', e) + return { success: false, error: String(e) } + } + } + + async getSessionDetail(sessionId: string): Promise<{ + success: boolean + detail?: SessionDetail + error?: string + }> { + try { + const fastResult = await this.getSessionDetailFast(sessionId) + if (!fastResult.success || !fastResult.detail) { + return { success: false, error: fastResult.error || '获取会话详情失败' } + } + + const extraResult = await this.getSessionDetailExtra(sessionId) + const detail: SessionDetail = { + ...fastResult.detail, + firstMessageTime: extraResult.success ? extraResult.detail?.firstMessageTime : undefined, + latestMessageTime: extraResult.success ? extraResult.detail?.latestMessageTime : undefined, + messageTables: extraResult.success && extraResult.detail?.messageTables + ? extraResult.detail.messageTables + : [] + } + + return { success: true, detail } } catch (e) { console.error('ChatService: 获取会话详情失败:', e) return { success: false, error: String(e) } diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 01bb85e..d54b2c4 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2766,6 +2766,14 @@ gap: 8px; } + .detail-table-placeholder { + padding: 10px 12px; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 12px; + color: var(--text-secondary); + } + .table-item { display: flex; align-items: center; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index cc351f6..6e4f282 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -312,6 +312,7 @@ function ChatPage(_props: ChatPageProps) { const [showDetailPanel, setShowDetailPanel] = useState(false) const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingDetail, setIsLoadingDetail] = useState(false) + const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false) const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) @@ -386,6 +387,7 @@ function ChatPage(_props: ChatPageProps) { const searchKeywordRef = useRef('') const preloadImageKeysRef = useRef>(new Set()) const lastPreloadSessionRef = useRef(null) + const detailRequestSeqRef = useRef(0) // 加载当前用户头像 const loadMyAvatar = useCallback(async () => { @@ -401,25 +403,91 @@ function ChatPage(_props: ChatPageProps) { // 加载会话详情 const loadSessionDetail = useCallback(async (sessionId: string) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + + const requestSeq = ++detailRequestSeqRef.current + const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId) + const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0 + ? Math.floor(mappedSession.messageCountHint) + : undefined + + setSessionDetail((prev) => { + const sameSession = prev?.wxid === normalizedSessionId + return { + wxid: normalizedSessionId, + displayName: mappedSession?.displayName || prev?.displayName || normalizedSessionId, + remark: sameSession ? prev?.remark : undefined, + nickName: sameSession ? prev?.nickName : undefined, + alias: sameSession ? prev?.alias : undefined, + avatarUrl: mappedSession?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), + messageCount: hintedCount ?? (sameSession ? prev.messageCount : Number.NaN), + firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, + latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, + messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] + } + }) setIsLoadingDetail(true) + setIsLoadingDetailExtra(true) + try { - const result = await window.electronAPI.chat.getSessionDetail(sessionId) + const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) + if (requestSeq !== detailRequestSeqRef.current) return if (result.success && result.detail) { - setSessionDetail(result.detail) + setSessionDetail((prev) => ({ + wxid: normalizedSessionId, + displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId, + remark: result.detail!.remark, + nickName: result.detail!.nickName, + alias: result.detail!.alias, + avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, + messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, + firstMessageTime: prev?.firstMessageTime, + latestMessageTime: prev?.latestMessageTime, + messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] + })) } } catch (e) { console.error('加载会话详情失败:', e) } finally { - setIsLoadingDetail(false) + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingDetail(false) + } + } + + try { + const result = await window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId) + if (requestSeq !== detailRequestSeqRef.current) return + if (result.success && result.detail) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + firstMessageTime: result.detail!.firstMessageTime, + latestMessageTime: result.detail!.latestMessageTime, + messageTables: Array.isArray(result.detail!.messageTables) ? result.detail!.messageTables : [] + } + }) + } + } catch (e) { + console.error('加载会话详情补充统计失败:', e) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingDetailExtra(false) + } } }, []) // 切换详情面板 const toggleDetailPanel = useCallback(() => { - if (!showDetailPanel && currentSessionId) { - loadSessionDetail(currentSessionId) + if (showDetailPanel) { + setShowDetailPanel(false) + return + } + setShowDetailPanel(true) + if (currentSessionId) { + void loadSessionDetail(currentSessionId) } - setShowDetailPanel(!showDetailPanel) }, [showDetailPanel, currentSessionId, loadSessionDetail]) // 复制字段值到剪贴板 @@ -1107,7 +1175,7 @@ function ChatPage(_props: ChatPageProps) { // 重置详情面板 setSessionDetail(null) if (showDetailPanel) { - loadSessionDetail(session.username) + void loadSessionDetail(session.username) } } @@ -2475,7 +2543,7 @@ function ChatPage(_props: ChatPageProps) { - {isLoadingDetail ? ( + {isLoadingDetail && !sessionDetail ? (
加载中... @@ -2530,39 +2598,35 @@ function ChatPage(_props: ChatPageProps) { {Number.isFinite(sessionDetail.messageCount) ? sessionDetail.messageCount.toLocaleString() - : '—'} + : (isLoadingDetail ? '统计中...' : '—')} + +
+
+ + 首条消息 + + {Number.isFinite(sessionDetail.firstMessageTime) + ? new Date((sessionDetail.firstMessageTime as number) * 1000).toLocaleDateString('zh-CN') + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ + 最新消息 + + {Number.isFinite(sessionDetail.latestMessageTime) + ? new Date((sessionDetail.latestMessageTime as number) * 1000).toLocaleDateString('zh-CN') + : (isLoadingDetailExtra ? '统计中...' : '—')}
- {sessionDetail.firstMessageTime && ( -
- - 首条消息 - - {Number.isFinite(sessionDetail.firstMessageTime) - ? new Date(sessionDetail.firstMessageTime * 1000).toLocaleDateString('zh-CN') - : '—'} - -
- )} - {sessionDetail.latestMessageTime && ( -
- - 最新消息 - - {Number.isFinite(sessionDetail.latestMessageTime) - ? new Date(sessionDetail.latestMessageTime * 1000).toLocaleDateString('zh-CN') - : '—'} - -
- )} - {Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 && ( -
-
- - 数据库分布 -
+
+
+ + 数据库分布 +
+ {Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? (
{sessionDetail.messageTables.map((t, i) => (
@@ -2571,8 +2635,12 @@ function ChatPage(_props: ChatPageProps) {
))}
-
- )} + ) : ( +
+ {isLoadingDetailExtra ? '统计中...' : '暂无统计数据'} +
+ )} +
) : (
暂无详情
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 8f8f6f1..88bc819 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -144,6 +144,28 @@ export interface ElectronAPI { } error?: string }> + getSessionDetailFast: (sessionId: string) => Promise<{ + success: boolean + detail?: { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number + } + error?: string + }> + getSessionDetailExtra: (sessionId: string) => Promise<{ + success: boolean + detail?: { + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] + } + error?: string + }> getExportSessionStats: (sessionIds: string[]) => Promise<{ success: boolean data?: Record