diff --git a/electron/main.ts b/electron/main.ts index 4638662..a1ac68f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -912,6 +912,10 @@ function registerIpcHandlers() { return chatService.getSessions() }) + ipcMain.handle('chat:getSessionStatuses', async (_, usernames: string[]) => { + return chatService.getSessionStatuses(usernames) + }) + ipcMain.handle('chat:getExportTabCounts', async () => { return chatService.getExportTabCounts() }) diff --git a/electron/preload.ts b/electron/preload.ts index a26c46b..7a3c0af 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -130,6 +130,7 @@ contextBridge.exposeInMainWorld('electronAPI', { chat: { connect: () => ipcRenderer.invoke('chat:connect'), getSessions: () => ipcRenderer.invoke('chat:getSessions'), + getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), enrichSessionsContactInfo: (usernames: string[]) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index fed5bbe..d5db221 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -201,6 +201,8 @@ class ChatService { private sessionMessageCountHintCache = new Map() private sessionMessageCountCacheScope = '' private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000 + private sessionStatusCache = new Map() + private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000 constructor() { this.configService = new ConfigService() @@ -386,7 +388,7 @@ class ChatService { return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` } } - // 转换为 ChatSession(先加载缓存,但不等待数据库查询) + // 转换为 ChatSession(先加载缓存,但不等待额外状态查询) const sessions: ChatSession[] = [] const now = Date.now() const myWxid = this.configService.get('myWxid') @@ -449,7 +451,7 @@ class ChatService { avatarUrl = cached.avatarUrl } - sessions.push({ + const nextSession: ChatSession = { username, type: parseInt(row.type || '0', 10), unreadCount: parseInt(row.unread_count || row.unreadCount || row.unreadcount || '0', 10), @@ -463,7 +465,15 @@ class ChatService { lastMsgSender: row.last_msg_sender, lastSenderDisplayName: row.last_sender_display_name, selfWxid: myWxid - }) + } + + const cachedStatus = this.sessionStatusCache.get(username) + if (cachedStatus && now - cachedStatus.updatedAt <= this.sessionStatusCacheTtlMs) { + nextSession.isFolded = cachedStatus.isFolded + nextSession.isMuted = cachedStatus.isMuted + } + + sessions.push(nextSession) if (typeof messageCountHint === 'number') { this.sessionMessageCountHintCache.set(username, messageCountHint) @@ -474,23 +484,6 @@ class ChatService { } } - // 批量拉取 extra_buffer 状态(isFolded/isMuted),不阻塞主流程 - const allUsernames = sessions.map(s => s.username) - try { - const statusResult = await wcdbService.getContactStatus(allUsernames) - if (statusResult.success && statusResult.map) { - for (const s of sessions) { - const st = statusResult.map[s.username] - if (st) { - s.isFolded = st.isFolded - s.isMuted = st.isMuted - } - } - } - } catch { - // 状态获取失败不影响会话列表返回 - } - // 不等待联系人信息加载,直接返回基础会话列表 // 前端可以异步调用 enrichSessionsWithContacts 来补充信息 return { success: true, sessions } @@ -500,6 +493,46 @@ class ChatService { } } + async getSessionStatuses(usernames: string[]): Promise<{ + success: boolean + map?: Record + error?: string + }> { + try { + if (!Array.isArray(usernames) || usernames.length === 0) { + return { success: true, map: {} } + } + + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error } + } + + const result = await wcdbService.getContactStatus(usernames) + if (!result.success || !result.map) { + return { success: false, error: result.error || '获取会话状态失败' } + } + + const now = Date.now() + for (const username of usernames) { + const state = result.map[username] + if (!state) continue + this.sessionStatusCache.set(username, { + isFolded: state.isFolded, + isMuted: state.isMuted, + updatedAt: now + }) + } + + return { + success: true, + map: result.map as Record + } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 异步补充会话列表的联系人信息(公开方法,供前端调用) */ @@ -1532,6 +1565,7 @@ class ChatService { this.sessionMessageCountCacheScope = scope this.sessionMessageCountCache.clear() this.sessionMessageCountHintCache.clear() + this.sessionStatusCache.clear() } private async collectSessionExportStats( diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 4ccdd2b..01bb85e 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -815,6 +815,24 @@ min-width: 0; } + .session-sync-indicator { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 999px; + background: var(--bg-primary); + color: var(--text-tertiary); + font-size: 11px; + white-space: nowrap; + border: 1px solid var(--border-color); + flex-shrink: 0; + + .spin { + animation: spin 0.9s linear infinite; + } + } + .search-box { flex: 1; display: flex; @@ -1651,6 +1669,18 @@ opacity: 0; pointer-events: none; } + + &.switching .message-list { + opacity: 0.42; + transform: scale(0.995); + filter: saturate(0.72) blur(1px); + pointer-events: none; + } + + &.switching .loading-overlay { + background: rgba(127, 127, 127, 0.18); + backdrop-filter: blur(4px); + } } .message-list { @@ -1666,7 +1696,7 @@ background-color: var(--bg-tertiary); position: relative; -webkit-app-region: no-drag !important; - transition: opacity 240ms ease, transform 240ms ease; + transition: opacity 240ms ease, transform 240ms ease, filter 220ms ease; // 滚动条样式 &::-webkit-scrollbar { @@ -4133,7 +4163,6 @@ font-weight: 500; } } - // 消息信息弹窗 .message-info-overlay { position: fixed; @@ -4298,4 +4327,4 @@ user-select: text; } } -} \ No newline at end of file +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b795fe4..cc351f6 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -317,6 +317,7 @@ function ChatPage(_props: ChatPageProps) { const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图 const [hasInitialMessages, setHasInitialMessages] = useState(false) + const [isSessionSwitching, setIsSessionSwitching] = useState(false) const [noMessageTable, setNoMessageTable] = useState(false) const [fallbackDisplayName, setFallbackDisplayName] = useState(null) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) @@ -376,6 +377,7 @@ function ChatPage(_props: ChatPageProps) { const sessionMapRef = useRef>(new Map()) const sessionsRef = useRef([]) const currentSessionRef = useRef(null) + const pendingSessionLoadRef = useRef(null) const prevSessionRef = useRef(null) const isLoadingMessagesRef = useRef(false) const isLoadingMoreRef = useRef(false) @@ -447,10 +449,10 @@ function ChatPage(_props: ChatPageProps) { const result = await window.electronAPI.chat.connect() if (result.success) { setConnected(true) - await loadSessions() - await loadMyAvatar() + const wxidPromise = window.electronAPI.config.get('myWxid') + await Promise.all([loadSessions(), loadMyAvatar()]) // 获取 myWxid 用于匹配个人头像 - const wxid = await window.electronAPI.config.get('myWxid') + const wxid = await wxidPromise if (wxid) setMyWxid(wxid as string) } else { setConnectionError(result.error || '连接失败') @@ -467,6 +469,8 @@ function ChatPage(_props: ChatPageProps) { senderAvatarLoading.clear() preloadImageKeysRef.current.clear() lastPreloadSessionRef.current = null + pendingSessionLoadRef.current = null + setIsSessionSwitching(false) setSessionDetail(null) setCurrentSession(null) setSessions([]) @@ -499,6 +503,45 @@ function ChatPage(_props: ChatPageProps) { currentSessionRef.current = currentSessionId }, [currentSessionId]) + const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => { + const usernames = sessionList.map((s) => s.username).filter(Boolean) + if (usernames.length === 0) return + + try { + const result = await window.electronAPI.chat.getSessionStatuses(usernames) + if (!result.success || !result.map) return + + const statusMap = result.map + const { sessions: latestSessions } = useChatStore.getState() + if (!Array.isArray(latestSessions) || latestSessions.length === 0) return + + let hasChanges = false + const updatedSessions = latestSessions.map((session) => { + const status = statusMap[session.username] + if (!status) return session + + const nextIsFolded = status.isFolded ?? session.isFolded + const nextIsMuted = status.isMuted ?? session.isMuted + if (nextIsFolded === session.isFolded && nextIsMuted === session.isMuted) { + return session + } + + hasChanges = true + return { + ...session, + isFolded: nextIsFolded, + isMuted: nextIsMuted + } + }) + + if (hasChanges) { + setSessions(updatedSessions) + } + } catch (e) { + console.warn('会话状态补齐失败:', e) + } + }, [setSessions]) + // 加载会话列表(优化:先返回基础数据,异步加载联系人信息) const loadSessions = async (options?: { silent?: boolean }) => { if (options?.silent) { @@ -518,11 +561,13 @@ function ChatPage(_props: ChatPageProps) { setSessions(nextSessions) sessionsRef.current = nextSessions + void hydrateSessionStatuses(nextSessions) // 立即启动联系人信息加载,不再延迟 500ms void enrichSessionsContactInfo(nextSessions) } else { console.error('mergeSessions returned non-array:', nextSessions) setSessions(sessionsArray) + void hydrateSessionStatuses(sessionsArray) void enrichSessionsContactInfo(sessionsArray) } } else if (!result.success) { @@ -575,8 +620,8 @@ function ChatPage(_props: ChatPageProps) { - // 进一步减少批次大小,每批3个,避免DLL调用阻塞 - const batchSize = 3 + // 批量补齐联系人,平衡吞吐和 UI 流畅性 + const batchSize = 8 let loadedCount = 0 for (let i = 0; i < needEnrich.length; i += batchSize) { @@ -585,7 +630,7 @@ function ChatPage(_props: ChatPageProps) { // 等待滚动结束 while (isScrollingRef.current && !enrichCancelledRef.current) { - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise(resolve => setTimeout(resolve, 120)) } if (enrichCancelledRef.current) break } @@ -602,11 +647,11 @@ function ChatPage(_props: ChatPageProps) { if ('requestIdleCallback' in window) { window.requestIdleCallback(() => { void loadContactInfoBatch(usernames).then(() => resolve()) - }, { timeout: 2000 }) + }, { timeout: 700 }) } else { setTimeout(() => { void loadContactInfoBatch(usernames).then(() => resolve()) - }, 300) + }, 80) } }) @@ -618,8 +663,7 @@ function ChatPage(_props: ChatPageProps) { // 批次间延迟,给UI更多时间(DLL调用可能阻塞,需要更长的延迟) if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) { - // 如果不在滚动,可以延迟短一点 - const delay = isScrollingRef.current ? 1000 : 800 + const delay = isScrollingRef.current ? 260 : 120 await new Promise(resolve => setTimeout(resolve, delay)) } } @@ -649,17 +693,17 @@ function ChatPage(_props: ChatPageProps) { contactUpdateTimerRef.current = null } - // 增加防抖延迟到500ms,避免在滚动时频繁更新 + // 使用短防抖,让头像和昵称更快补齐但依然避免频繁重渲染 contactUpdateTimerRef.current = window.setTimeout(() => { const updates = contactUpdateQueueRef.current if (updates.size === 0) return const now = Date.now() - // 如果距离上次更新太近(小于1秒),继续延迟 - if (now - lastUpdateTimeRef.current < 1000) { + // 如果距离上次更新太近(小于250ms),继续延迟 + if (now - lastUpdateTimeRef.current < 250) { contactUpdateTimerRef.current = window.setTimeout(() => { flushContactUpdates() - }, 1000 - (now - lastUpdateTimeRef.current)) + }, 250 - (now - lastUpdateTimeRef.current)) return } @@ -696,7 +740,7 @@ function ChatPage(_props: ChatPageProps) { updates.clear() contactUpdateTimerRef.current = null - }, 500) // 500ms 防抖,减少更新频率 + }, 120) }, [setSessions]) // 加载一批联系人信息并更新会话列表(优化:使用队列批量更新) @@ -885,7 +929,8 @@ function ChatPage(_props: ChatPageProps) { if (offset === 0) { setLoadingMessages(true) - setMessages([]) + // 切会话时保留旧内容作为过渡,避免大面积闪烁 + setHasInitialMessages(true) } else { setLoadingMore(true) } @@ -900,6 +945,9 @@ function ChatPage(_props: ChatPageProps) { hasMore?: boolean; error?: string } + if (currentSessionRef.current !== sessionId) { + return + } if (result.success && result.messages) { if (offset === 0) { setMessages(result.messages) @@ -996,9 +1044,16 @@ function ChatPage(_props: ChatPageProps) { console.error('加载消息失败:', e) setConnectionError('加载消息失败') setHasMoreMessages(false) + if (offset === 0 && currentSessionRef.current === sessionId) { + setMessages([]) + } } finally { setLoadingMessages(false) setLoadingMore(false) + if (offset === 0 && pendingSessionLoadRef.current === sessionId) { + pendingSessionLoadRef.current = null + setIsSessionSwitching(false) + } } } @@ -1042,11 +1097,13 @@ function ChatPage(_props: ChatPageProps) { return } if (session.username === currentSessionId) return - setCurrentSession(session.username) + pendingSessionLoadRef.current = session.username + setIsSessionSwitching(true) + setCurrentSession(session.username, { preserveMessages: true }) setCurrentOffset(0) setJumpStartTime(0) setJumpEndTime(0) - loadMessages(session.username, 0, 0, 0) + void loadMessages(session.username, 0, 0, 0) // 重置详情面板 setSessionDetail(null) if (showDetailPanel) { @@ -1368,6 +1425,10 @@ function ChatPage(_props: ChatPageProps) { ) }, [sessions, searchKeyword, foldedView]) + const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0 + const shouldShowSessionsSkeleton = isLoadingSessions && !hasSessionRecords + const isSessionListSyncing = (isLoadingSessions || isRefreshingSessions) && hasSessionRecords + // 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算 const formatSessionTime = useCallback((timestamp: number): string => { @@ -2068,6 +2129,12 @@ function ChatPage(_props: ChatPageProps) { + {isSessionListSyncing && ( +
+ + 同步中 +
+ )} {/* 折叠群 header */} @@ -2093,7 +2160,7 @@ function ChatPage(_props: ChatPageProps) { )} {/* ... (previous content) ... */} - {isLoadingSessions ? ( + {shouldShowSessionsSkeleton ? (
{[1, 2, 3, 4, 5].map(i => (
@@ -2311,11 +2378,11 @@ function ChatPage(_props: ChatPageProps) {
-
- {isLoadingMessages && !hasInitialMessages && ( +
+ {isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
- 加载消息中... + {isSessionSwitching ? '切换会话中...' : '加载消息中...'}
)}
void setSessions: (sessions: ChatSession[]) => void setFilteredSessions: (sessions: ChatSession[]) => void - setCurrentSession: (sessionId: string | null) => void + setCurrentSession: (sessionId: string | null, options?: { preserveMessages?: boolean }) => void setLoadingSessions: (loading: boolean) => void setMessages: (messages: Message[]) => void appendMessages: (messages: Message[], prepend?: boolean) => void @@ -69,12 +69,12 @@ export const useChatStore = create((set, get) => ({ setSessions: (sessions) => set({ sessions, filteredSessions: sessions }), setFilteredSessions: (sessions) => set({ filteredSessions: sessions }), - setCurrentSession: (sessionId) => set({ + setCurrentSession: (sessionId, options) => set((state) => ({ currentSessionId: sessionId, - messages: [], + messages: options?.preserveMessages ? state.messages : [], hasMoreMessages: true, hasMoreLater: false - }), + })), setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e638331..8f8f6f1 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -74,6 +74,11 @@ export interface ElectronAPI { chat: { connect: () => Promise<{ success: boolean; error?: string }> getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> + getSessionStatuses: (usernames: string[]) => Promise<{ + success: boolean + map?: Record + error?: string + }> getExportTabCounts: () => Promise<{ success: boolean counts?: {