import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { LivePhotoIcon } from '../components/LivePhotoIcon' import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import JumpToDateDialog from '../components/JumpToDateDialog' import * as configService from '../services/config' import './ChatPage.scss' // 系统消息类型常量 const SYSTEM_MESSAGE_TYPES = [ 10000, // 系统消息 266287972401, // 拍一拍 ] interface XmlField { key: string; value: string; type: 'attr' | 'node'; tagName?: string; path: string; } interface BatchImageDecryptCandidate { imageMd5?: string imageDatName?: string createTime?: number } // 尝试解析 XML 为可编辑字段 function parseXmlToFields(xml: string): XmlField[] { const fields: XmlField[] = [] if (!xml || !xml.includes('<')) return [] try { const parser = new DOMParser() // 包装一下确保是单一根节点 const wrappedXml = xml.trim().startsWith('${xml}` const doc = parser.parseFromString(wrappedXml, 'text/xml') const errorNode = doc.querySelector('parsererror') if (errorNode) return [] const walk = (node: Node, path: string = '') => { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element if (element.tagName === 'root') { node.childNodes.forEach((child, index) => walk(child, path)) return } const currentPath = path ? `${path} > ${element.tagName}` : element.tagName for (let i = 0; i < element.attributes.length; i++) { const attr = element.attributes[i] fields.push({ key: attr.name, value: attr.value, type: 'attr', tagName: element.tagName, path: `${currentPath}[@${attr.name}]` }) } if (element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE) { const text = element.textContent?.trim() || '' if (text) { fields.push({ key: element.tagName, value: text, type: 'node', path: currentPath }) } } else { node.childNodes.forEach((child, index) => walk(child, `${currentPath}[${index}]`)) } } } doc.childNodes.forEach((node, index) => walk(node, '')) } catch (e) { console.warn('[XML Parse] Failed:', e) } return fields } // 将编辑后的字段同步回 XML function updateXmlWithFields(xml: string, fields: XmlField[]): string { try { const parser = new DOMParser() const wrappedXml = xml.trim().startsWith('${xml}` const doc = parser.parseFromString(wrappedXml, 'text/xml') const errorNode = doc.querySelector('parsererror') if (errorNode) return xml fields.forEach(f => { if (f.type === 'attr') { const elements = doc.getElementsByTagName(f.tagName!) if (elements.length > 0) { elements[0].setAttribute(f.key, f.value) } } else { const elements = doc.getElementsByTagName(f.key) if (elements.length > 0 && (elements[0].childNodes.length <= 1)) { elements[0].textContent = f.value } } }) let result = new XMLSerializer().serializeToString(doc) if (!xml.trim().startsWith('', '').replace('', '').replace('', '') } return result } catch (e) { return xml } } // 判断是否为系统消息 function isSystemMessage(localType: number): boolean { return SYSTEM_MESSAGE_TYPES.includes(localType) } // 格式化文件大小 function formatFileSize(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] } // 清理消息内容的辅助函数 function cleanMessageContent(content: string): string { if (!content) return '' return content.trim() } interface ChatPageProps { // 保留接口以备将来扩展 } interface SessionDetail { 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 }[] } // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts import { avatarLoadQueue } from '../utils/AvatarLoadQueue' import { Avatar } from '../components/Avatar' // 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染) // 会话项组件(使用 memo 优化,避免不必要的重渲染) const SessionItem = React.memo(function SessionItem({ session, isActive, onSelect, formatTime }: { session: ChatSession isActive: boolean onSelect: (session: ChatSession) => void formatTime: (timestamp: number) => string }) { const timeText = useMemo(() => formatTime(session.lastTimestamp || session.sortTimestamp), [formatTime, session.lastTimestamp, session.sortTimestamp] ) const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup') // 折叠入口:专属名称和图标 if (isFoldEntry) { return (
onSelect(session)} >
折叠的群聊
{session.summary || ''}
) } return (
onSelect(session)} >
{session.displayName || session.username} {timeText}
{session.summary || '暂无消息'}
{session.isMuted && } {session.unreadCount > 0 && ( {session.unreadCount > 99 ? '99+' : session.unreadCount} )}
) }, (prevProps, nextProps) => { return ( prevProps.session.username === nextProps.session.username && prevProps.session.displayName === nextProps.session.displayName && prevProps.session.avatarUrl === nextProps.session.avatarUrl && prevProps.session.summary === nextProps.session.summary && prevProps.session.unreadCount === nextProps.session.unreadCount && prevProps.session.lastTimestamp === nextProps.session.lastTimestamp && prevProps.session.sortTimestamp === nextProps.session.sortTimestamp && prevProps.session.isMuted === nextProps.session.isMuted && prevProps.isActive === nextProps.isActive ) }) function ChatPage(_props: ChatPageProps) { const navigate = useNavigate() const { isConnected, isConnecting, connectionError, sessions, filteredSessions, currentSessionId, isLoadingSessions, messages, isLoadingMessages, isLoadingMore, hasMoreMessages, searchKeyword, setConnected, setConnecting, setConnectionError, setSessions, setFilteredSessions, setCurrentSession, setLoadingSessions, setMessages, appendMessages, setLoadingMessages, setLoadingMore, setHasMoreMessages, hasMoreLater, setHasMoreLater, setSearchKeyword } = useChatStore() const messageListRef = useRef(null) const searchInputRef = useRef(null) const sidebarRef = useRef(null) const getMessageKey = useCallback((msg: Message): string => { if (msg.localId && msg.localId > 0) return `l:${msg.localId}` return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}` }, []) const initialRevealTimerRef = useRef(null) const sessionListRef = useRef(null) const [currentOffset, setCurrentOffset] = useState(0) const [jumpStartTime, setJumpStartTime] = useState(0) const [jumpEndTime, setJumpEndTime] = useState(0) const [showJumpDialog, setShowJumpDialog] = useState(false) const [messageDates, setMessageDates] = useState>(new Set()) const [loadingDates, setLoadingDates] = useState(false) const messageDatesCache = useRef>>(new Map()) const [myAvatarUrl, setMyAvatarUrl] = useState(undefined) const [myWxid, setMyWxid] = useState(undefined) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [sidebarWidth, setSidebarWidth] = useState(260) const [isResizing, setIsResizing] = useState(false) const [showDetailPanel, setShowDetailPanel] = useState(false) const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingDetail, setIsLoadingDetail] = useState(false) const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图 const [hasInitialMessages, setHasInitialMessages] = useState(false) const [noMessageTable, setNoMessageTable] = useState(false) const [fallbackDisplayName, setFallbackDisplayName] = useState(null) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) // 消息右键菜单 const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null) const [editingMessage, setEditingMessage] = useState<{ message: Message, content: string } | null>(null) // 多选模式 const [isSelectionMode, setIsSelectionMode] = useState(false) const [selectedMessages, setSelectedMessages] = useState>(new Set()) // 编辑消息额外状态 const [editMode, setEditMode] = useState<'raw' | 'fields'>('raw') const [tempFields, setTempFields] = useState([]) // 批量语音转文字相关状态(进度/结果 由全局 store 管理) const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore() const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore() const [showBatchConfirm, setShowBatchConfirm] = useState(false) const [batchVoiceCount, setBatchVoiceCount] = useState(0) const [batchVoiceMessages, setBatchVoiceMessages] = useState(null) const [batchVoiceDates, setBatchVoiceDates] = useState([]) const [batchSelectedDates, setBatchSelectedDates] = useState>(new Set()) const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false) const [batchImageMessages, setBatchImageMessages] = useState(null) const [batchImageDates, setBatchImageDates] = useState([]) const [batchImageSelectedDates, setBatchImageSelectedDates] = useState>(new Set()) // 批量删除相关状态 const [isDeleting, setIsDeleting] = useState(false) const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0 }) const [cancelDeleteRequested, setCancelDeleteRequested] = useState(false) // 自定义删除确认对话框 const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; mode: 'single' | 'batch'; message?: Message; count?: number; }>({ show: false, mode: 'single' }) // 联系人信息加载控制 const isEnrichingRef = useRef(false) const enrichCancelledRef = useRef(false) const isScrollingRef = useRef(false) const sessionScrollTimeoutRef = useRef(null) const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) const messageKeySetRef = useRef>(new Set()) const lastMessageTimeRef = useRef(0) const sessionMapRef = useRef>(new Map()) const sessionsRef = useRef([]) const currentSessionRef = useRef(null) const prevSessionRef = useRef(null) const isLoadingMessagesRef = useRef(false) const isLoadingMoreRef = useRef(false) const isConnectedRef = useRef(false) const isRefreshingRef = useRef(false) const searchKeywordRef = useRef('') const preloadImageKeysRef = useRef>(new Set()) const lastPreloadSessionRef = useRef(null) // 加载当前用户头像 const loadMyAvatar = useCallback(async () => { try { const result = await window.electronAPI.chat.getMyAvatarUrl() if (result.success && result.avatarUrl) { setMyAvatarUrl(result.avatarUrl) } } catch (e) { console.error('加载用户头像失败:', e) } }, []) // 加载会话详情 const loadSessionDetail = useCallback(async (sessionId: string) => { setIsLoadingDetail(true) try { const result = await window.electronAPI.chat.getSessionDetail(sessionId) if (result.success && result.detail) { setSessionDetail(result.detail) } } catch (e) { console.error('加载会话详情失败:', e) } finally { setIsLoadingDetail(false) } }, []) // 切换详情面板 const toggleDetailPanel = useCallback(() => { if (!showDetailPanel && currentSessionId) { loadSessionDetail(currentSessionId) } setShowDetailPanel(!showDetailPanel) }, [showDetailPanel, currentSessionId, loadSessionDetail]) // 复制字段值到剪贴板 const handleCopyField = useCallback(async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) setCopiedField(field) setTimeout(() => setCopiedField(null), 1500) } catch { // fallback const textarea = document.createElement('textarea') textarea.value = text document.body.appendChild(textarea) textarea.select() document.execCommand('copy') document.body.removeChild(textarea) setCopiedField(field) setTimeout(() => setCopiedField(null), 1500) } }, []) // 连接数据库 const connect = useCallback(async () => { setConnecting(true) setConnectionError(null) try { const result = await window.electronAPI.chat.connect() if (result.success) { setConnected(true) await loadSessions() await loadMyAvatar() // 获取 myWxid 用于匹配个人头像 const wxid = await window.electronAPI.config.get('myWxid') if (wxid) setMyWxid(wxid as string) } else { setConnectionError(result.error || '连接失败') } } catch (e) { setConnectionError(String(e)) } finally { setConnecting(false) } }, [loadMyAvatar]) const handleAccountChanged = useCallback(async () => { senderAvatarCache.clear() senderAvatarLoading.clear() preloadImageKeysRef.current.clear() lastPreloadSessionRef.current = null setSessionDetail(null) setCurrentSession(null) setSessions([]) setFilteredSessions([]) setMessages([]) setSearchKeyword('') setConnectionError(null) setConnected(false) setConnecting(false) setHasMoreMessages(true) setHasMoreLater(false) await connect() }, [ connect, setConnected, setConnecting, setConnectionError, setCurrentSession, setFilteredSessions, setHasMoreLater, setHasMoreMessages, setMessages, setSearchKeyword, setSessionDetail, setSessions ]) // 同步 currentSessionId 到 ref useEffect(() => { currentSessionRef.current = currentSessionId }, [currentSessionId]) // 加载会话列表(优化:先返回基础数据,异步加载联系人信息) const loadSessions = async (options?: { silent?: boolean }) => { if (options?.silent) { setIsRefreshingSessions(true) } else { setLoadingSessions(true) } try { const result = await window.electronAPI.chat.getSessions() if (result.success && result.sessions) { // 确保 sessions 是数组 const sessionsArray = Array.isArray(result.sessions) ? result.sessions : [] const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray // 确保 nextSessions 也是数组 if (Array.isArray(nextSessions)) { setSessions(nextSessions) sessionsRef.current = nextSessions // 立即启动联系人信息加载,不再延迟 500ms void enrichSessionsContactInfo(nextSessions) } else { console.error('mergeSessions returned non-array:', nextSessions) setSessions(sessionsArray) void enrichSessionsContactInfo(sessionsArray) } } else if (!result.success) { setConnectionError(result.error || '获取会话失败') } } catch (e) { console.error('加载会话失败:', e) setConnectionError('加载会话失败') } finally { if (options?.silent) { setIsRefreshingSessions(false) } else { setLoadingSessions(false) } } } // 分批异步加载联系人信息(优化性能:防止重复加载,滚动时暂停,只在空闲时加载) const enrichSessionsContactInfo = async (sessions: ChatSession[]) => { if (sessions.length === 0) return // 防止重复加载 if (isEnrichingRef.current) { return } isEnrichingRef.current = true enrichCancelledRef.current = false const totalStart = performance.now() // 移除初始 500ms 延迟,让后台加载与 UI 渲染并行 // 检查是否被取消 if (enrichCancelledRef.current) { isEnrichingRef.current = false return } try { // 找出需要加载联系人信息的会话(没有头像或者没有显示名称的) const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username) if (needEnrich.length === 0) { isEnrichingRef.current = false return } // 进一步减少批次大小,每批3个,避免DLL调用阻塞 const batchSize = 3 let loadedCount = 0 for (let i = 0; i < needEnrich.length; i += batchSize) { // 如果正在滚动,暂停加载 if (isScrollingRef.current) { // 等待滚动结束 while (isScrollingRef.current && !enrichCancelledRef.current) { await new Promise(resolve => setTimeout(resolve, 200)) } if (enrichCancelledRef.current) break } // 检查是否被取消 if (enrichCancelledRef.current) break const batchStart = performance.now() const batch = needEnrich.slice(i, i + batchSize) const usernames = batch.map(s => s.username) // 使用 requestIdleCallback 延迟执行,避免阻塞UI await new Promise((resolve) => { if ('requestIdleCallback' in window) { window.requestIdleCallback(() => { void loadContactInfoBatch(usernames).then(() => resolve()) }, { timeout: 2000 }) } else { setTimeout(() => { void loadContactInfoBatch(usernames).then(() => resolve()) }, 300) } }) loadedCount += batch.length const batchTime = performance.now() - batchStart if (batchTime > 200) { console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`) } // 批次间延迟,给UI更多时间(DLL调用可能阻塞,需要更长的延迟) if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) { // 如果不在滚动,可以延迟短一点 const delay = isScrollingRef.current ? 1000 : 800 await new Promise(resolve => setTimeout(resolve, delay)) } } const totalTime = performance.now() - totalStart if (!enrichCancelledRef.current) { } else { } } catch (e) { console.error('加载联系人信息失败:', e) } finally { isEnrichingRef.current = false } } // 联系人信息更新队列(防抖批量更新,避免频繁重渲染) const contactUpdateQueueRef = useRef>(new Map()) const contactUpdateTimerRef = useRef(null) const lastUpdateTimeRef = useRef(0) // 批量更新联系人信息(防抖,减少重渲染次数,增加延迟避免阻塞滚动) const flushContactUpdates = useCallback(() => { if (contactUpdateTimerRef.current) { clearTimeout(contactUpdateTimerRef.current) 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) { contactUpdateTimerRef.current = window.setTimeout(() => { flushContactUpdates() }, 1000 - (now - lastUpdateTimeRef.current)) return } const { sessions: currentSessions } = useChatStore.getState() if (!Array.isArray(currentSessions)) return let hasChanges = false const updatedSessions = currentSessions.map(session => { const update = updates.get(session.username) if (update) { const newDisplayName = update.displayName || session.displayName || session.username const newAvatarUrl = update.avatarUrl || session.avatarUrl if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl) { hasChanges = true return { ...session, displayName: newDisplayName, avatarUrl: newAvatarUrl } } } return session }) if (hasChanges) { const updateStart = performance.now() setSessions(updatedSessions) lastUpdateTimeRef.current = Date.now() const updateTime = performance.now() - updateStart if (updateTime > 50) { console.warn(`[性能监控] setSessions更新耗时: ${updateTime.toFixed(2)}ms, 更新了 ${updates.size} 个联系人`) } } updates.clear() contactUpdateTimerRef.current = null }, 500) // 500ms 防抖,减少更新频率 }, [setSessions]) // 加载一批联系人信息并更新会话列表(优化:使用队列批量更新) const loadContactInfoBatch = async (usernames: string[]) => { const startTime = performance.now() try { // 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate) await new Promise(resolve => setTimeout(resolve, 0)) const dllStart = performance.now() const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) as { success: boolean contacts?: Record error?: string } const dllTime = performance.now() - dllStart // DLL 调用后再次让出控制权 await new Promise(resolve => setTimeout(resolve, 0)) const totalTime = performance.now() - startTime if (dllTime > 50 || totalTime > 100) { console.warn(`[性能监控] DLL调用耗时: ${dllTime.toFixed(2)}ms, 总耗时: ${totalTime.toFixed(2)}ms, usernames: ${usernames.length}`) } if (result.success && result.contacts) { // 将更新加入队列,用于侧边栏更新 const contacts = result.contacts || {} for (const [username, contact] of Object.entries(contacts)) { contactUpdateQueueRef.current.set(username, contact) // 如果是自己的信息且当前个人头像为空,同步更新 if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) { setMyAvatarUrl(contact.avatarUrl) } // 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用 senderAvatarCache.set(username, { avatarUrl: contact.avatarUrl, displayName: contact.displayName }) } // 触发批量更新 flushContactUpdates() } } catch (e) { console.error('加载联系人信息批次失败:', e) } } // 刷新会话列表 const handleRefresh = async () => { setJumpStartTime(0) setJumpEndTime(0) setHasMoreLater(false) await loadSessions({ silent: true }) } // 刷新当前会话消息(增量更新新消息) const [isRefreshingMessages, setIsRefreshingMessages] = useState(false) /** * 极速增量刷新:基于最后一条消息时间戳,获取后续新消息 * (由用户建议:记住上一条消息时间,自动取之后的并渲染,然后后台兜底全量同步) */ const handleIncrementalRefresh = async () => { if (!currentSessionId || isRefreshingRef.current) return isRefreshingRef.current = true setIsRefreshingMessages(true) // 找出当前已渲染消息中的最大时间戳(使用 getState 获取最新状态,避免闭包过时导致重复) const currentMessages = useChatStore.getState().messages const lastMsg = currentMessages[currentMessages.length - 1] const minTime = lastMsg?.createTime || 0 // 1. 优先执行增量查询并渲染(第一步) try { const result = await (window.electronAPI.chat as any).getNewMessages(currentSessionId, minTime) as { success: boolean; messages?: Message[]; error?: string } if (result.success && result.messages && result.messages.length > 0) { // 过滤去重:必须对比实时的状态,防止在 handleRefreshMessages 运行期间导致的冲突 const latestMessages = useChatStore.getState().messages const existingKeys = new Set(latestMessages.map(getMessageKey)) const newOnes = result.messages.filter(m => !existingKeys.has(getMessageKey(m))) if (newOnes.length > 0) { appendMessages(newOnes, false) flashNewMessages(newOnes.map(getMessageKey)) // 滚动到底部 requestAnimationFrame(() => { if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) } } } catch (e) { console.warn('[IncrementalRefresh] 失败,将依赖全量同步兜底:', e) } finally { isRefreshingRef.current = false setIsRefreshingMessages(false) } } const handleRefreshMessages = async () => { if (!currentSessionId || isRefreshingRef.current) return setJumpStartTime(0) setJumpEndTime(0) setHasMoreLater(false) setIsRefreshingMessages(true) isRefreshingRef.current = true try { // 获取最新消息并增量添加 const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) as { success: boolean; messages?: Message[]; error?: string } if (!result.success || !result.messages) { return } // 使用实时状态进行去重对比 const latestMessages = useChatStore.getState().messages const existing = new Set(latestMessages.map(getMessageKey)) const lastMsg = latestMessages[latestMessages.length - 1] const lastTime = lastMsg?.createTime ?? 0 const newMessages = result.messages.filter((msg) => { const key = getMessageKey(msg) if (existing.has(key)) return false // 这里的 lastTime 仅作参考过滤,主要的去重靠 key if (lastTime > 0 && msg.createTime < lastTime - 3600) return false // 仅过滤 1 小时之前的冗余请求 return true }) if (newMessages.length > 0) { appendMessages(newMessages, false) flashNewMessages(newMessages.map(getMessageKey)) // 滚动到底部 requestAnimationFrame(() => { if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) } } catch (e) { console.error('刷新消息失败:', e) } finally { isRefreshingRef.current = false setIsRefreshingMessages(false) } } // 动态游标批量大小控制 const currentBatchSizeRef = useRef(50) // 加载消息 const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => { const listEl = messageListRef.current const session = sessionMapRef.current.get(sessionId) const unreadCount = session?.unreadCount ?? 0 let messageLimit = 50 if (offset === 0) { // 初始加载:重置批量大小 currentBatchSizeRef.current = 50 // 首屏优化:消息过多时限制数量 messageLimit = unreadCount > 99 ? 30 : 50 } else { // 滚动加载:动态递增 (50 -> 100 -> 200) if (currentBatchSizeRef.current < 100) { currentBatchSizeRef.current = 100 } else { currentBatchSizeRef.current = 200 } messageLimit = currentBatchSizeRef.current } if (offset === 0) { setLoadingMessages(true) setMessages([]) } else { setLoadingMore(true) } // 记录加载前的第一条消息元素 const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null try { const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) as { success: boolean; messages?: Message[]; hasMore?: boolean; error?: string } if (result.success && result.messages) { if (offset === 0) { setMessages(result.messages) if (result.messages.length === 0) { setNoMessageTable(true) setHasMoreMessages(false) } // 预取发送者信息:在关闭加载遮罩前处理 const unreadCount = session?.unreadCount ?? 0 const isGroup = sessionId.includes('@chatroom') if (isGroup && result.messages.length > 0) { const unknownSenders = [...new Set(result.messages .filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername)) .map(m => m.senderUsername as string) )] if (unknownSenders.length > 0) { // 在批量请求前,先将这些发送者标记为加载中,防止 MessageBubble 触发重复请求 const batchPromise = loadContactInfoBatch(unknownSenders) unknownSenders.forEach(username => { if (!senderAvatarLoading.has(username)) { senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null)) } }) // 确保在请求完成后清理 loading 状态 batchPromise.finally(() => { unknownSenders.forEach(username => senderAvatarLoading.delete(username)) }) } } // 首次加载滚动到底部 requestAnimationFrame(() => { if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) } else { appendMessages(result.messages, true) // 加载更多也同样处理发送者信息预取 const isGroup = sessionId.includes('@chatroom') if (isGroup) { const unknownSenders = [...new Set(result.messages .filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername)) .map(m => m.senderUsername as string) )] if (unknownSenders.length > 0) { const batchPromise = loadContactInfoBatch(unknownSenders) unknownSenders.forEach(username => { if (!senderAvatarLoading.has(username)) { senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null)) } }) batchPromise.finally(() => { unknownSenders.forEach(username => senderAvatarLoading.delete(username)) }) } } // 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置 if (firstMsgEl && listEl) { requestAnimationFrame(() => { listEl.scrollTop = firstMsgEl.offsetTop - 80 }) } } setHasMoreMessages(result.hasMore ?? false) // 如果是按 endTime 跳转加载,且结果刚好满批,可能后面(更晚)还有消息 if (offset === 0) { if (endTime > 0) { setHasMoreLater(true) } else { setHasMoreLater(false) } } setCurrentOffset(offset + result.messages.length) } else if (!result.success) { setNoMessageTable(true) setHasMoreMessages(false) } } catch (e) { console.error('加载消息失败:', e) setConnectionError('加载消息失败') setHasMoreMessages(false) } finally { setLoadingMessages(false) setLoadingMore(false) } } // 加载更晚的消息 const loadLaterMessages = useCallback(async () => { if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return setLoadingMore(true) try { const lastMsg = messages[messages.length - 1] // 从最后一条消息的时间开始往后找 const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) as { success: boolean; messages?: Message[]; hasMore?: boolean; error?: string } if (result.success && result.messages) { // 过滤掉已经在列表中的重复消息 const existingKeys = messageKeySetRef.current const newMsgs = result.messages.filter(m => !existingKeys.has(getMessageKey(m))) if (newMsgs.length > 0) { appendMessages(newMsgs, false) } setHasMoreLater(result.hasMore ?? false) } } catch (e) { console.error('加载后续消息失败:', e) } finally { setLoadingMore(false) } }, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore]) // 选择会话 const handleSelectSession = (session: ChatSession) => { // 点击折叠群入口,切换到折叠群视图 if (session.username.toLowerCase().includes('placeholder_foldgroup')) { setFoldedView(true) return } if (session.username === currentSessionId) return setCurrentSession(session.username) setCurrentOffset(0) setJumpStartTime(0) setJumpEndTime(0) loadMessages(session.username, 0, 0, 0) // 重置详情面板 setSessionDetail(null) if (showDetailPanel) { loadSessionDetail(session.username) } } // 搜索过滤 const handleSearch = (keyword: string) => { setSearchKeyword(keyword) } // 关闭搜索框 const handleCloseSearch = () => { setSearchKeyword('') } // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) const scrollTimeoutRef = useRef(null) const handleScroll = useCallback(() => { if (!messageListRef.current) return // 节流:延迟执行,避免滚动时频繁计算 if (scrollTimeoutRef.current) { cancelAnimationFrame(scrollTimeoutRef.current) } scrollTimeoutRef.current = requestAnimationFrame(() => { if (!messageListRef.current) return const { scrollTop, clientHeight, scrollHeight } = messageListRef.current // 显示回到底部按钮:距离底部超过 300px const distanceFromBottom = scrollHeight - scrollTop - clientHeight setShowScrollToBottom(distanceFromBottom > 300) // 预加载:当滚动到顶部 30% 区域时开始加载 if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { const threshold = clientHeight * 0.3 if (scrollTop < threshold) { loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) } } // 预加载更晚的消息 if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) { const threshold = clientHeight * 0.3 const distanceFromBottom = scrollHeight - scrollTop - clientHeight if (distanceFromBottom < threshold) { loadLaterMessages() } } }) }, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages]) const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => { return ( prev.username === next.username && prev.type === next.type && prev.unreadCount === next.unreadCount && prev.summary === next.summary && prev.sortTimestamp === next.sortTimestamp && prev.lastTimestamp === next.lastTimestamp && prev.lastMsgType === next.lastMsgType && prev.displayName === next.displayName && prev.avatarUrl === next.avatarUrl ) }, []) const mergeSessions = useCallback((nextSessions: ChatSession[]) => { // 确保输入是数组 if (!Array.isArray(nextSessions)) { console.warn('mergeSessions: nextSessions is not an array:', nextSessions) return Array.isArray(sessionsRef.current) ? sessionsRef.current : [] } if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) { return nextSessions } const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s])) return nextSessions.map((next) => { const prev = prevMap.get(next.username) if (!prev) return next return isSameSession(prev, next) ? prev : next }) }, [isSameSession]) const flashNewMessages = useCallback((keys: string[]) => { if (keys.length === 0) return setHighlightedMessageKeys((prev) => [...prev, ...keys]) window.setTimeout(() => { setHighlightedMessageKeys((prev) => prev.filter((k) => !keys.includes(k))) }, 2500) }, []) // 滚动到底部 const scrollToBottom = useCallback(() => { if (messageListRef.current) { messageListRef.current.scrollTo({ top: messageListRef.current.scrollHeight, behavior: 'smooth' }) } }, []) // 拖动调节侧边栏宽度 const handleResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault() setIsResizing(true) const startX = e.clientX const startWidth = sidebarWidth const handleMouseMove = (e: MouseEvent) => { const delta = e.clientX - startX const newWidth = Math.min(Math.max(startWidth + delta, 200), 400) setSidebarWidth(newWidth) } const handleMouseUp = () => { setIsResizing(false) document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) }, [sidebarWidth]) // 初始化连接 useEffect(() => { if (!isConnected && !isConnecting) { connect() } // 组件卸载时清理 return () => { avatarLoadQueue.clear() if (contactUpdateTimerRef.current) { clearTimeout(contactUpdateTimerRef.current) } if (sessionScrollTimeoutRef.current) { clearTimeout(sessionScrollTimeoutRef.current) } contactUpdateQueueRef.current.clear() enrichCancelledRef.current = true isEnrichingRef.current = false } }, []) useEffect(() => { const handleChange = () => { void handleAccountChanged() } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) }, [handleAccountChanged]) useEffect(() => { const nextSet = new Set() for (const msg of messages) { nextSet.add(getMessageKey(msg)) } messageKeySetRef.current = nextSet const lastMsg = messages[messages.length - 1] lastMessageTimeRef.current = lastMsg?.createTime ?? 0 }, [messages, getMessageKey]) useEffect(() => { currentSessionRef.current = currentSessionId }, [currentSessionId]) useEffect(() => { if (currentSessionId !== lastPreloadSessionRef.current) { preloadImageKeysRef.current.clear() lastPreloadSessionRef.current = currentSessionId } }, [currentSessionId]) useEffect(() => { if (!currentSessionId || messages.length === 0) return const preloadEdgeCount = 40 const maxPreload = 30 const head = messages.slice(0, preloadEdgeCount) const tail = messages.slice(-preloadEdgeCount) const candidates = [...head, ...tail] const queued = preloadImageKeysRef.current const seen = new Set() const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = [] for (const msg of candidates) { if (payloads.length >= maxPreload) break if (msg.localType !== 3) continue const cacheKey = msg.imageMd5 || msg.imageDatName || `local:${msg.localId}` if (!msg.imageMd5 && !msg.imageDatName) continue if (imageDataUrlCache.has(cacheKey)) continue const taskKey = `${currentSessionId}|${cacheKey}` if (queued.has(taskKey) || seen.has(taskKey)) continue queued.add(taskKey) seen.add(taskKey) payloads.push({ sessionId: currentSessionId, imageMd5: msg.imageMd5 || undefined, imageDatName: msg.imageDatName }) } if (payloads.length > 0) { window.electronAPI.image.preload(payloads).catch(() => { }) } }, [currentSessionId, messages]) useEffect(() => { const nextMap = new Map() if (Array.isArray(sessions)) { for (const session of sessions) { nextMap.set(session.username, session) } } sessionMapRef.current = nextMap }, [sessions]) useEffect(() => { sessionsRef.current = Array.isArray(sessions) ? sessions : [] }, [sessions]) useEffect(() => { isLoadingMessagesRef.current = isLoadingMessages isLoadingMoreRef.current = isLoadingMore }, [isLoadingMessages, isLoadingMore]) useEffect(() => { if (initialRevealTimerRef.current !== null) { window.clearTimeout(initialRevealTimerRef.current) initialRevealTimerRef.current = null } if (!isLoadingMessages) { if (messages.length === 0) { setHasInitialMessages(true) } else { initialRevealTimerRef.current = window.setTimeout(() => { setHasInitialMessages(true) initialRevealTimerRef.current = null }, 120) } } }, [isLoadingMessages, messages.length]) useEffect(() => { if (currentSessionId !== prevSessionRef.current) { prevSessionRef.current = currentSessionId setNoMessageTable(false) if (initialRevealTimerRef.current !== null) { window.clearTimeout(initialRevealTimerRef.current) initialRevealTimerRef.current = null } if (messages.length === 0) { setHasInitialMessages(false) } else if (!isLoadingMessages) { setHasInitialMessages(true) } } }, [currentSessionId, messages.length, isLoadingMessages]) useEffect(() => { if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) { setHasInitialMessages(false) loadMessages(currentSessionId, 0) } }, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable]) useEffect(() => { return () => { if (initialRevealTimerRef.current !== null) { window.clearTimeout(initialRevealTimerRef.current) initialRevealTimerRef.current = null } } }, []) useEffect(() => { isConnectedRef.current = isConnected }, [isConnected]) useEffect(() => { searchKeywordRef.current = searchKeyword }, [searchKeyword]) // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 useEffect(() => { if (!Array.isArray(sessions)) { setFilteredSessions([]) return } const visible = sessions.filter(s => { if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false return true }) if (!searchKeyword.trim()) { setFilteredSessions(visible) return } const lower = searchKeyword.toLowerCase() setFilteredSessions(visible.filter(s => s.displayName?.toLowerCase().includes(lower) || s.username.toLowerCase().includes(lower) || s.summary.toLowerCase().includes(lower) )) }, [sessions, searchKeyword, setFilteredSessions]) // 折叠群列表(独立计算,供折叠 panel 使用) const foldedSessions = useMemo(() => { if (!Array.isArray(sessions)) return [] const folded = sessions.filter(s => s.isFolded) if (!searchKeyword.trim() || !foldedView) return folded const lower = searchKeyword.toLowerCase() return folded.filter(s => s.displayName?.toLowerCase().includes(lower) || s.username.toLowerCase().includes(lower) || s.summary.toLowerCase().includes(lower) ) }, [sessions, searchKeyword, foldedView]) // 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算 const formatSessionTime = useCallback((timestamp: number): string => { if (!Number.isFinite(timestamp) || timestamp <= 0) return '' const now = Date.now() const msgTime = timestamp * 1000 const diff = now - msgTime const minutes = Math.floor(diff / 60000) const hours = Math.floor(diff / 3600000) if (minutes < 1) return '刚刚' if (minutes < 60) return `${minutes}分钟前` if (hours < 24) return `${hours}小时前` // 超过24小时显示日期 const date = new Date(msgTime) const nowDate = new Date() if (date.getFullYear() === nowDate.getFullYear()) { return `${date.getMonth() + 1}/${date.getDate()}` } return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` }, []) // 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback) const currentSession = (() => { const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined if (found || !currentSessionId) return found return { username: currentSessionId, type: 0, unreadCount: 0, summary: '', sortTimestamp: 0, lastTimestamp: 0, lastMsgType: 0, displayName: fallbackDisplayName || currentSessionId, } as ChatSession })() // 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称 useEffect(() => { if (!currentSessionId) return const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined if (found) { setFallbackDisplayName(null) return } loadContactInfoBatch([currentSessionId]).then(() => { const cached = senderAvatarCache.get(currentSessionId) if (cached?.displayName) setFallbackDisplayName(cached.displayName) }) }, [currentSessionId, sessions]) // 判断是否为群聊 const isGroupChat = (username: string) => username.includes('@chatroom') // 渲染日期分隔 const shouldShowDateDivider = (msg: Message, prevMsg?: Message): boolean => { if (!prevMsg) return true const date = new Date(msg.createTime * 1000).toDateString() const prevDate = new Date(prevMsg.createTime * 1000).toDateString() return date !== prevDate } const formatDateDivider = (timestamp: number): string => { if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间' const date = new Date(timestamp * 1000) const now = new Date() const isToday = date.toDateString() === now.toDateString() if (isToday) return '今天' const yesterday = new Date(now) yesterday.setDate(yesterday.getDate() - 1) if (date.toDateString() === yesterday.toDateString()) return '昨天' return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) } const handleRequireModelDownload = useCallback((sessionId: string, messageId: string) => { setPendingVoiceTranscriptRequest({ sessionId, messageId }) setShowVoiceTranscribeDialog(true) }, []) // 批量语音转文字 const handleBatchTranscribe = useCallback(async () => { if (!currentSessionId) return const session = sessions.find(s => s.username === currentSessionId) if (!session) { alert('未找到当前会话') return } if (isBatchTranscribing) return const result = await window.electronAPI.chat.getAllVoiceMessages(currentSessionId) if (!result.success || !result.messages) { alert(`获取语音消息失败: ${result.error || '未知错误'}`) return } const voiceMessages: Message[] = result.messages if (voiceMessages.length === 0) { alert('当前会话没有语音消息') return } const dateSet = new Set() voiceMessages.forEach(m => dateSet.add(new Date(m.createTime * 1000).toISOString().slice(0, 10))) const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a)) setBatchVoiceMessages(voiceMessages) setBatchVoiceCount(voiceMessages.length) setBatchVoiceDates(sortedDates) setBatchSelectedDates(new Set(sortedDates)) setShowBatchConfirm(true) }, [sessions, currentSessionId, isBatchTranscribing]) const handleBatchDecrypt = useCallback(async () => { if (!currentSessionId || isBatchDecrypting) return const session = sessions.find(s => s.username === currentSessionId) if (!session) { alert('未找到当前会话') return } const result = await window.electronAPI.chat.getAllImageMessages(currentSessionId) if (!result.success || !result.images) { alert(`获取图片消息失败: ${result.error || '未知错误'}`) return } if (result.images.length === 0) { alert('当前会话没有图片消息') return } const dateSet = new Set() result.images.forEach((img: BatchImageDecryptCandidate) => { if (img.createTime) dateSet.add(new Date(img.createTime * 1000).toISOString().slice(0, 10)) }) const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a)) setBatchImageMessages(result.images) setBatchImageDates(sortedDates) setBatchImageSelectedDates(new Set(sortedDates)) setShowBatchDecryptConfirm(true) }, [currentSessionId, isBatchDecrypting, sessions]) const handleExportCurrentSession = useCallback(() => { if (!currentSessionId) return navigate('/export', { state: { preselectSessionIds: [currentSessionId] } }) }, [currentSessionId, navigate]) const handleGroupAnalytics = useCallback(() => { if (!currentSessionId || !isGroupChat(currentSessionId)) return navigate('/group-analytics', { state: { preselectGroupIds: [currentSessionId] } }) }, [currentSessionId, navigate]) // 确认批量转写 const confirmBatchTranscribe = useCallback(async () => { if (!currentSessionId) return const selected = batchSelectedDates if (selected.size === 0) { alert('请至少选择一个日期') return } const messages = batchVoiceMessages if (!messages || messages.length === 0) { setShowBatchConfirm(false) return } const voiceMessages = messages.filter(m => selected.has(new Date(m.createTime * 1000).toISOString().slice(0, 10)) ) if (voiceMessages.length === 0) { alert('所选日期下没有语音消息') return } setShowBatchConfirm(false) setBatchVoiceMessages(null) setBatchVoiceDates([]) setBatchSelectedDates(new Set()) const session = sessions.find(s => s.username === currentSessionId) if (!session) return startTranscribe(voiceMessages.length, session.displayName || session.username) // 检查模型状态 const modelStatus = await window.electronAPI.whisper.getModelStatus() if (!modelStatus?.exists) { alert('SenseVoice 模型未下载,请先在设置中下载模型') finishTranscribe(0, 0) return } let successCount = 0 let failCount = 0 let completedCount = 0 const concurrency = 10 const transcribeOne = async (msg: Message) => { try { const result = await window.electronAPI.chat.getVoiceTranscript( session.username, String(msg.localId), msg.createTime ) return { success: result.success } } catch { return { success: false } } } for (let i = 0; i < voiceMessages.length; i += concurrency) { const batch = voiceMessages.slice(i, i + concurrency) const results = await Promise.all(batch.map(msg => transcribeOne(msg))) results.forEach(result => { if (result.success) successCount++ else failCount++ completedCount++ updateProgress(completedCount, voiceMessages.length) }) } finishTranscribe(successCount, failCount) }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, startTranscribe, updateProgress, finishTranscribe]) // 批量转写:按日期的消息数量 const batchCountByDate = useMemo(() => { const map = new Map() if (!batchVoiceMessages) return map batchVoiceMessages.forEach(m => { const d = new Date(m.createTime * 1000).toISOString().slice(0, 10) map.set(d, (map.get(d) || 0) + 1) }) return map }, [batchVoiceMessages]) // 批量转写:选中日期对应的语音条数 const batchSelectedMessageCount = useMemo(() => { if (!batchVoiceMessages) return 0 return batchVoiceMessages.filter(m => batchSelectedDates.has(new Date(m.createTime * 1000).toISOString().slice(0, 10)) ).length }, [batchVoiceMessages, batchSelectedDates]) const toggleBatchDate = useCallback((date: string) => { setBatchSelectedDates(prev => { const next = new Set(prev) if (next.has(date)) next.delete(date) else next.add(date) return next }) }, []) const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates]) const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), []) const confirmBatchDecrypt = useCallback(async () => { if (!currentSessionId) return const selected = batchImageSelectedDates if (selected.size === 0) { alert('请至少选择一个日期') return } const images = (batchImageMessages || []).filter(img => img.createTime && selected.has(new Date(img.createTime * 1000).toISOString().slice(0, 10)) ) if (images.length === 0) { alert('所选日期下没有图片消息') return } const session = sessions.find(s => s.username === currentSessionId) if (!session) return setShowBatchDecryptConfirm(false) setBatchImageMessages(null) setBatchImageDates([]) setBatchImageSelectedDates(new Set()) startDecrypt(images.length, session.displayName || session.username) let successCount = 0 let failCount = 0 for (let i = 0; i < images.length; i++) { const img = images[i] try { const r = await window.electronAPI.image.decrypt({ sessionId: session.username, imageMd5: img.imageMd5, imageDatName: img.imageDatName, force: false }) if (r?.success) successCount++ else failCount++ } catch { failCount++ } updateDecryptProgress(i + 1, images.length) if (i % 5 === 0) { await new Promise(resolve => setTimeout(resolve, 0)) } } finishDecrypt(successCount, failCount) }, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress]) const batchImageCountByDate = useMemo(() => { const map = new Map() if (!batchImageMessages) return map batchImageMessages.forEach(img => { if (!img.createTime) return const d = new Date(img.createTime * 1000).toISOString().slice(0, 10) map.set(d, (map.get(d) ?? 0) + 1) }) return map }, [batchImageMessages]) const batchImageSelectedCount = useMemo(() => { if (!batchImageMessages) return 0 return batchImageMessages.filter(img => img.createTime && batchImageSelectedDates.has(new Date(img.createTime * 1000).toISOString().slice(0, 10)) ).length }, [batchImageMessages, batchImageSelectedDates]) const toggleBatchImageDate = useCallback((date: string) => { setBatchImageSelectedDates(prev => { const next = new Set(prev) if (next.has(date)) next.delete(date) else next.add(date) return next }) }, []) const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates]) const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), []) const lastSelectedIdRef = useRef(null) const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => { setSelectedMessages(prev => { const next = new Set(prev) // Range selection with Shift key if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) { const currentMsgs = useChatStore.getState().messages const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current) const idx2 = currentMsgs.findIndex(m => m.localId === localId) if (idx1 !== -1 && idx2 !== -1) { const start = Math.min(idx1, idx2) const end = Math.max(idx1, idx2) for (let i = start; i <= end; i++) { next.add(currentMsgs[i].localId) } } } else { // Normal toggle if (next.has(localId)) { next.delete(localId) lastSelectedIdRef.current = null // Reset last selection on uncheck? Or keep? Usually keep last interaction. } else { next.add(localId) lastSelectedIdRef.current = localId } } return next }) }, []) const formatBatchDateLabel = useCallback((dateStr: string) => { const [y, m, d] = dateStr.split('-').map(Number) return `${y}年${m}月${d}日` }, []) // 消息右键菜单处理 const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => { e.preventDefault() setContextMenu({ x: e.clientX, y: e.clientY, message }) }, []) // 关闭右键菜单 useEffect(() => { const handleClick = () => { setContextMenu(null) } window.addEventListener('click', handleClick) return () => { window.removeEventListener('click', handleClick) } }, []) // 删除消息 - 触发确认弹窗 const handleDelete = useCallback((target: { message: Message } | null = null) => { const msg = target?.message || contextMenu?.message if (!currentSessionId || !msg) return setDeleteConfirm({ show: true, mode: 'single', message: msg }) setContextMenu(null) }, [contextMenu, currentSessionId]) // 执行单条删除动作 const performSingleDelete = async (msg: Message) => { try { const dbPathHint = (msg as any)._db_path const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint) if (result.success) { const currentMessages = useChatStore.getState().messages const newMessages = currentMessages.filter(m => m.localId !== msg.localId) useChatStore.getState().setMessages(newMessages) } else { alert('删除失败: ' + (result.error || '原因未知')) } } catch (e) { console.error(e) alert('删除异常: ' + String(e)) } } // 修改消息 const handleEditMessage = useCallback(() => { if (contextMenu) { // 允许编辑所有类型的消息 // 如果是文本消息(1),使用 parsedContent // 如果是其他类型(如系统消息 10000),使用 rawContent 或 content 作为 XML 源码编辑 const isText = contextMenu.message.localType === 1 const rawXml = contextMenu.message.content || (contextMenu.message as any).rawContent || contextMenu.message.parsedContent || '' const contentToEdit = isText ? cleanMessageContent(contextMenu.message.parsedContent) : rawXml if (!isText) { const fields = parseXmlToFields(rawXml) setTempFields(fields) setEditMode(fields.length > 0 ? 'fields' : 'raw') } else { setEditMode('raw') setTempFields([]) } setEditingMessage({ message: contextMenu.message, content: contentToEdit }) setContextMenu(null) } }, [contextMenu]) // 确认修改消息 const handleSaveEdit = useCallback(async () => { if (editingMessage && currentSessionId) { let finalContent = editingMessage.content // 如果是字段编辑模式,先同步回 XML if (editMode === 'fields' && tempFields.length > 0) { finalContent = updateXmlWithFields(editingMessage.content, tempFields) } if (!finalContent.trim()) { handleDelete({ message: editingMessage.message }) setEditingMessage(null) return } try { const result = await (window as any).electronAPI.chat.updateMessage(currentSessionId, editingMessage.message.localId, editingMessage.message.createTime, finalContent) if (result.success) { const currentMessages = useChatStore.getState().messages const newMessages = currentMessages.map(m => { if (m.localId === editingMessage.message.localId) { return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent } } return m }) useChatStore.getState().setMessages(newMessages) setEditingMessage(null) } else { alert('修改失败: ' + result.error) } } catch (e) { alert('修改异常: ' + String(e)) } } }, [editingMessage, currentSessionId, editMode, tempFields, handleDelete]) // 用于在异步循环中获取最新的取消状态 const cancelDeleteRef = useRef(false) const handleBatchDelete = () => { if (selectedMessages.size === 0) { alert('请先选择要删除的消息') return } if (!currentSessionId) return setDeleteConfirm({ show: true, mode: 'batch', count: selectedMessages.size }) } const performBatchDelete = async () => { setIsDeleting(true) setDeleteProgress({ current: 0, total: selectedMessages.size }) setCancelDeleteRequested(false) cancelDeleteRef.current = false try { const currentMessages = useChatStore.getState().messages const selectedIds = Array.from(selectedMessages) const deletedIds = new Set() for (let i = 0; i < selectedIds.length; i++) { if (cancelDeleteRef.current) break const id = selectedIds[i] const msgObj = currentMessages.find(m => m.localId === id) const dbPathHint = (msgObj as any)?._db_path const createTime = msgObj?.createTime || 0 try { const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, id, createTime, dbPathHint) if (result.success) { deletedIds.add(id) } } catch (err) { console.error(`删除消息 ${id} 失败:`, err) } setDeleteProgress({ current: i + 1, total: selectedIds.length }) } const finalMessages = useChatStore.getState().messages.filter(m => !deletedIds.has(m.localId)) useChatStore.getState().setMessages(finalMessages) setIsSelectionMode(false) setSelectedMessages(new Set()) if (cancelDeleteRef.current) { alert(`操作已中止。已删除 ${deletedIds.size} 条,剩余记录保留。`) } } catch (e) { alert('批量删除出现错误: ' + String(e)) console.error(e) } finally { setIsDeleting(false) setCancelDeleteRequested(false) cancelDeleteRef.current = false } } return (
{/* 自定义删除确认对话框 */} {deleteConfirm.show && (

确认删除

{deleteConfirm.mode === 'single' ? '确定要删除这条消息吗?此操作不可恢复。' : `确定要删除选中的 ${deleteConfirm.count} 条消息吗?`}

)} {/* 批量删除进度遮罩 */} {isDeleting && (

正在彻底删除消息...

{deleteProgress.current} / {deleteProgress.total}

请勿关闭应用或切换会话,确保所有副本都被清理。

)} {/* 左侧会话列表 */}
{/* 普通 header */}
handleSearch(e.target.value)} /> {searchKeyword && ( )}
{/* 折叠群 header */}
折叠的群聊
{connectionError && (
{connectionError}
)} {/* ... (previous content) ... */} {isLoadingSessions ? (
{[1, 2, 3, 4, 5].map(i => (
))}
) : (
{/* 普通会话列表 */}
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
{ isScrollingRef.current = true if (sessionScrollTimeoutRef.current) { clearTimeout(sessionScrollTimeoutRef.current) } sessionScrollTimeoutRef.current = window.setTimeout(() => { isScrollingRef.current = false sessionScrollTimeoutRef.current = null }, 200) }} > {filteredSessions.map(session => ( ))}
) : (

暂无会话

请先在数据管理页面解密数据库

)}
{/* 折叠群列表 */}
{foldedSessions.length > 0 ? (
{foldedSessions.map(session => ( ))}
) : (

没有折叠的群聊

)}
)}
{/* 拖动调节条 */}
{/* 右侧消息区域 */}
{currentSession ? ( <>

{currentSession.displayName || currentSession.username}

{isGroupChat(currentSession.username) && (
群聊
)}
{isGroupChat(currentSession.username) && ( )} setShowJumpDialog(false)} onSelect={(date) => { if (!currentSessionId) return const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000) setCurrentOffset(0) setJumpStartTime(0) setJumpEndTime(end) loadMessages(currentSessionId, 0, 0, end) }} messageDates={messageDates} loadingDates={loadingDates} />
{isLoadingMessages && !hasInitialMessages && (
加载消息中...
)}
{hasMoreMessages && (
{isLoadingMore ? ( <> 加载更多... ) : ( 向上滚动加载更多 )}
)} {!isLoadingMessages && messages.length === 0 && !hasMoreMessages && (
该联系人没有聊天记录
)} {messages.map((msg, index) => { const prevMsg = index > 0 ? messages[index - 1] : undefined const showDateDivider = shouldShowDateDivider(msg, prevMsg) // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) const isSent = msg.isSend === 1 const isSystem = isSystemMessage(msg.localType) // 系统消息居中显示 const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') const messageKey = getMessageKey(msg) return (
{showDateDivider && (
{formatDateDivider(msg.createTime)}
)}
) })} {hasMoreLater && (
{isLoadingMore ? ( <> 正在加载后续消息... ) : ( 向下滚动查看更新消息 )}
)} {/* 回到底部按钮 */}
回到底部
{/* 会话详情面板 */} {showDetailPanel && (

会话详情

{isLoadingDetail ? (
加载中...
) : sessionDetail ? (
微信ID {sessionDetail.wxid}
{sessionDetail.remark && (
备注 {sessionDetail.remark}
)} {sessionDetail.nickName && (
昵称 {sessionDetail.nickName}
)} {sessionDetail.alias && (
微信号 {sessionDetail.alias}
)}
消息统计
消息总数 {Number.isFinite(sessionDetail.messageCount) ? sessionDetail.messageCount.toLocaleString() : '—'}
{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 && (
数据库分布
{sessionDetail.messageTables.map((t, i) => (
{t.dbName} {t.count.toLocaleString()} 条
))}
)}
) : (
暂无详情
)}
)}
) : (

选择一个会话开始查看聊天记录

)}
{/* 语音转文字模型下载弹窗 */} {showVoiceTranscribeDialog && ( { setShowVoiceTranscribeDialog(false) setPendingVoiceTranscriptRequest(null) }} onDownloadComplete={async () => { setShowVoiceTranscribeDialog(false) // 下载完成后,触发页面刷新让组件重新尝试转写 // 通过更新缓存触发组件重新检查 if (pendingVoiceTranscriptRequest) { // 清除缓存中的请求标记,让组件可以重新尝试 const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}` // 不直接调用转写,而是让组件自己重试 // 通过触发一个自定义事件来通知所有 MessageBubble 组件 window.dispatchEvent(new CustomEvent('model-downloaded', { detail: { messageId: pendingVoiceTranscriptRequest.messageId } })) } setPendingVoiceTranscriptRequest(null) }} /> )} {/* 批量转写确认对话框 */} {showBatchConfirm && createPortal(
setShowBatchConfirm(false)}>
e.stopPropagation()}>

批量语音转文字

选择要转写的日期(仅显示有语音的日期),然后开始转写。

{batchVoiceDates.length > 0 && (
    {batchVoiceDates.map(dateStr => { const count = batchCountByDate.get(dateStr) ?? 0 const checked = batchSelectedDates.has(dateStr) return (
  • ) })}
)}
已选: {batchSelectedDates.size} 天有语音,共 {batchSelectedMessageCount} 条语音
预计耗时: 约 {Math.ceil(batchSelectedMessageCount * 2 / 60)} 分钟
批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。
, document.body )} {/* 消息右键菜单 */} {showBatchDecryptConfirm && createPortal(
setShowBatchDecryptConfirm(false)}>
e.stopPropagation()}>

批量解密图片

选择要解密的日期(仅显示有图片的日期),然后开始解密。

{batchImageDates.length > 0 && (
    {batchImageDates.map(dateStr => { const count = batchImageCountByDate.get(dateStr) ?? 0 const checked = batchImageSelectedDates.has(dateStr) return (
  • ) })}
)}
已选: {batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片
批量解密可能需要较长时间,进行中会在右下角显示非阻塞进度浮层。
, document.body )} {contextMenu && createPortal( <>
setContextMenu(null)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9998 }} />
e.stopPropagation()} >
{contextMenu.message.localType === 1 ? '修改消息' : '编辑源码'}
{ setIsSelectionMode(true) setSelectedMessages(new Set([contextMenu.message.localId])) setContextMenu(null) }}> 多选
{ e.stopPropagation(); handleDelete() }}> 删除消息
, document.body )} {/* 修改消息弹窗 */} {editingMessage && createPortal(

{editingMessage.message.localType === 1 ? '修改消息' : '编辑消息'}

{editMode === 'raw' ? (