import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react' import { useChatStore } from '../stores/chatStore' import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import './ChatPage.scss' 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 }[] } // 头像组件 - 支持骨架屏加载 function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) { const [imageLoaded, setImageLoaded] = useState(false) const [imageError, setImageError] = useState(false) const imgRef = useRef(null) const isGroup = session.username.includes('@chatroom') const getAvatarLetter = (): string => { const name = session.displayName || session.username if (!name) return '?' const chars = [...name] return chars[0] || '?' } // 当 avatarUrl 变化时重置状态 useEffect(() => { setImageLoaded(false) setImageError(false) }, [session.avatarUrl]) // 检查图片是否已经从缓存加载完成 useEffect(() => { if (imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { setImageLoaded(true) } }, [session.avatarUrl]) const hasValidUrl = session.avatarUrl && !imageError return (
{hasValidUrl ? ( <> {!imageLoaded &&
} setImageLoaded(true)} onError={() => setImageError(true)} /> ) : ( {getAvatarLetter()} )}
) } function ChatPage(_props: ChatPageProps) { 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, setSearchKeyword } = useChatStore() const messageListRef = useRef(null) const searchInputRef = useRef(null) const sidebarRef = useRef(null) const [currentOffset, setCurrentOffset] = useState(0) const [myAvatarUrl, setMyAvatarUrl] = 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 [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) 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 isLoadingMessagesRef = useRef(false) const isLoadingMoreRef = useRef(false) const isConnectedRef = 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 connect = useCallback(async () => { setConnecting(true) setConnectionError(null) try { const result = await window.electronAPI.chat.connect() if (result.success) { setConnected(true) await loadSessions() await loadMyAvatar() } else { setConnectionError(result.error || '连接失败') } } catch (e) { setConnectionError(String(e)) } finally { setConnecting(false) } }, [loadMyAvatar]) // 加载会话列表 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) { const nextSessions = options?.silent ? mergeSessions(result.sessions) : result.sessions setSessions(nextSessions) } else if (!result.success) { setConnectionError(result.error || '获取会话失败') } } catch (e) { console.error('加载会话失败:', e) setConnectionError('加载会话失败') } finally { if (options?.silent) { setIsRefreshingSessions(false) } else { setLoadingSessions(false) } } } // 刷新会话列表 const handleRefresh = async () => { await loadSessions({ silent: true }) } // 刷新当前会话消息(增量更新新消息) const [isRefreshingMessages, setIsRefreshingMessages] = useState(false) const handleRefreshMessages = async () => { if (!currentSessionId || isRefreshingMessages) return setIsRefreshingMessages(true) try { // 获取最新消息并增量添加 const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) if (!result.success || !result.messages) { return } const existing = new Set(messages.map(getMessageKey)) const lastMsg = messages[messages.length - 1] const lastTime = lastMsg?.createTime ?? 0 const newMessages = result.messages.filter((msg) => { const key = getMessageKey(msg) if (existing.has(key)) return false if (lastTime > 0 && msg.createTime < lastTime) return false 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 { setIsRefreshingMessages(false) } } // 加载消息 const loadMessages = async (sessionId: string, offset = 0) => { const listEl = messageListRef.current const session = sessionMapRef.current.get(sessionId) const unreadCount = session?.unreadCount ?? 0 const messageLimit = offset === 0 && unreadCount > 99 ? 30 : 50 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) if (result.success && result.messages) { if (offset === 0) { setMessages(result.messages) // 首次加载滚动到底部 requestAnimationFrame(() => { if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) } else { appendMessages(result.messages, true) // 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置 if (firstMsgEl && listEl) { requestAnimationFrame(() => { listEl.scrollTop = firstMsgEl.offsetTop - 80 }) } } setHasMoreMessages(result.hasMore ?? false) setCurrentOffset(offset + result.messages.length) } else if (!result.success) { setConnectionError(result.error || '加载消息失败') setHasMoreMessages(false) } } catch (e) { console.error('加载消息失败:', e) setConnectionError('加载消息失败') setHasMoreMessages(false) } finally { setLoadingMessages(false) setLoadingMore(false) } } // 选择会话 const handleSelectSession = (session: ChatSession) => { if (session.username === currentSessionId) return setCurrentSession(session.username) setCurrentOffset(0) loadMessages(session.username, 0) // 重置详情面板 setSessionDetail(null) if (showDetailPanel) { loadSessionDetail(session.username) } } // 搜索过滤 const handleSearch = (keyword: string) => { setSearchKeyword(keyword) if (!keyword.trim()) { setFilteredSessions(sessions) return } const lower = keyword.toLowerCase() const filtered = sessions.filter(s => s.displayName?.toLowerCase().includes(lower) || s.username.toLowerCase().includes(lower) || s.summary.toLowerCase().includes(lower) ) setFilteredSessions(filtered) } // 关闭搜索框 const handleCloseSearch = () => { setSearchKeyword('') setFilteredSessions(sessions) } // 滚动加载更多 + 显示/隐藏回到底部按钮 const handleScroll = useCallback(() => { 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) } } }, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset]) 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 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 (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() } }, []) 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() for (const session of sessions) { nextMap.set(session.username, session) } sessionMapRef.current = nextMap }, [sessions]) useEffect(() => { sessionsRef.current = sessions }, [sessions]) useEffect(() => { isLoadingMessagesRef.current = isLoadingMessages isLoadingMoreRef.current = isLoadingMore }, [isLoadingMessages, isLoadingMore]) useEffect(() => { isConnectedRef.current = isConnected }, [isConnected]) useEffect(() => { searchKeywordRef.current = searchKeyword }, [searchKeyword]) useEffect(() => { if (!searchKeyword.trim()) return const lower = searchKeyword.toLowerCase() const filtered = sessions.filter(s => s.displayName?.toLowerCase().includes(lower) || s.username.toLowerCase().includes(lower) || s.summary.toLowerCase().includes(lower) ) setFilteredSessions(filtered) }, [sessions, searchKeyword, setFilteredSessions]) // 格式化会话时间(相对时间)- 与原项目一致 const formatSessionTime = (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()}` } // 获取当前会话信息 const currentSession = sessions.find(s => s.username === currentSessionId) // 判断是否为群聊 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' }) } return (
{/* 左侧会话列表 */}
handleSearch(e.target.value)} /> {searchKeyword && ( )}
{connectionError && (
{connectionError}
)} {isLoadingSessions ? (
{[1, 2, 3, 4, 5].map(i => (
))}
) : filteredSessions.length > 0 ? (
{filteredSessions.map(session => (
handleSelectSession(session)} >
{session.displayName || session.username} {formatSessionTime(session.lastTimestamp || session.sortTimestamp)}
{session.summary || '暂无消息'} {session.unreadCount > 0 && ( {session.unreadCount > 99 ? '99+' : session.unreadCount} )}
))}
) : (

暂无会话

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

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

{currentSession.displayName || currentSession.username}

{isGroupChat(currentSession.username) && (
群聊
)}
{isLoadingMessages ? (
加载消息中...
) : (
{hasMoreMessages && (
{isLoadingMore ? ( <> 加载更多... ) : ( 向上滚动加载更多 )}
)} {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 = msg.localType === 10000 // 系统消息居中显示 const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') const messageKey = getMessageKey(msg) return (
{showDateDivider && (
{formatDateDivider(msg.createTime)}
)}
) })} {/* 回到底部按钮 */}
回到底部
)} {/* 会话详情面板 */} {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()} 条
))}
)}
) : (
暂无详情
)}
)}
) : (

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

)}
) } // 前端表情包缓存 const emojiDataUrlCache = new Map() const imageDataUrlCache = new Map() const voiceDataUrlCache = new Map() const senderAvatarCache = new Map() const senderAvatarLoading = new Map>() // 消息气泡组件 function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: { message: Message; session: ChatSession; showTime?: boolean; myAvatarUrl?: string; isGroupChat?: boolean; }) { const isSystem = message.localType === 10000 const isEmoji = message.localType === 47 const isImage = message.localType === 3 const isVoice = message.localType === 34 const isSent = message.isSend === 1 const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) const [senderName, setSenderName] = useState(undefined) const [emojiError, setEmojiError] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false) const [imageError, setImageError] = useState(false) const [imageLoading, setImageLoading] = useState(false) const [imageHasUpdate, setImageHasUpdate] = useState(false) const [imageClicked, setImageClicked] = useState(false) const imageUpdateCheckedRef = useRef(null) const imageClickTimerRef = useRef(null) const [voiceError, setVoiceError] = useState(false) const [voiceLoading, setVoiceLoading] = useState(false) const [isVoicePlaying, setIsVoicePlaying] = useState(false) const voiceAudioRef = useRef(null) const [showImagePreview, setShowImagePreview] = useState(false) // 从缓存获取表情包 data URL const cacheKey = message.emojiMd5 || message.emojiCdnUrl || '' const [emojiLocalPath, setEmojiLocalPath] = useState( () => emojiDataUrlCache.get(cacheKey) ) const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}` const [imageLocalPath, setImageLocalPath] = useState( () => imageDataUrlCache.get(imageCacheKey) ) const voiceCacheKey = `voice:${message.localId}` const [voiceDataUrl, setVoiceDataUrl] = useState( () => voiceDataUrlCache.get(voiceCacheKey) ) const formatTime = (timestamp: number): string => { if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间' const date = new Date(timestamp * 1000) return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) + ' ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) } const detectImageMimeFromBase64 = useCallback((base64: string): string => { try { const head = window.atob(base64.slice(0, 48)) const bytes = new Uint8Array(head.length) for (let i = 0; i < head.length; i++) { bytes[i] = head.charCodeAt(i) } if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif' if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png' if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg' if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) { return 'image/webp' } } catch {} return 'image/jpeg' }, []) // 获取头像首字母 const getAvatarLetter = (name: string): string => { if (!name) return '?' const chars = [...name] return chars[0] || '?' } // 下载表情包 const downloadEmoji = () => { if (!message.emojiCdnUrl || emojiLoading) return // 先检查缓存 const cached = emojiDataUrlCache.get(cacheKey) if (cached) { setEmojiLocalPath(cached) setEmojiError(false) return } setEmojiLoading(true) setEmojiError(false) window.electronAPI.chat.downloadEmoji(message.emojiCdnUrl, message.emojiMd5).then((result: { success: boolean; localPath?: string; error?: string }) => { if (result.success && result.localPath) { emojiDataUrlCache.set(cacheKey, result.localPath) setEmojiLocalPath(result.localPath) } else { setEmojiError(true) } }).catch(() => { setEmojiError(true) }).finally(() => { setEmojiLoading(false) }) } // 群聊中获取发送者信息 useEffect(() => { if (isGroupChat && !isSent && message.senderUsername) { const sender = message.senderUsername const cached = senderAvatarCache.get(sender) if (cached) { setSenderAvatarUrl(cached.avatarUrl) setSenderName(cached.displayName) return } const pending = senderAvatarLoading.get(sender) if (pending) { pending.then((result) => { if (result) { setSenderAvatarUrl(result.avatarUrl) setSenderName(result.displayName) } }) return } const request = window.electronAPI.chat.getContactAvatar(sender) senderAvatarLoading.set(sender, request) request.then((result: { avatarUrl?: string; displayName?: string } | null) => { if (result) { senderAvatarCache.set(sender, result) setSenderAvatarUrl(result.avatarUrl) setSenderName(result.displayName) } }).catch(() => {}).finally(() => { senderAvatarLoading.delete(sender) }) } }, [isGroupChat, isSent, message.senderUsername]) // 自动下载表情包 useEffect(() => { if (emojiLocalPath) return if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) { downloadEmoji() } }, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError]) const requestImageDecrypt = useCallback(async (forceUpdate = false) => { if (!isImage || imageLoading) return setImageLoading(true) setImageError(false) try { if (message.imageMd5 || message.imageDatName) { const result = await window.electronAPI.image.decrypt({ sessionId: session.username, imageMd5: message.imageMd5 || undefined, imageDatName: message.imageDatName, force: forceUpdate }) if (result.success && result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) setImageLocalPath(result.localPath) setImageHasUpdate(false) return } } const fallback = await window.electronAPI.chat.getImageData(session.username, String(message.localId)) if (fallback.success && fallback.data) { const mime = detectImageMimeFromBase64(fallback.data) const dataUrl = `data:${mime};base64,${fallback.data}` imageDataUrlCache.set(imageCacheKey, dataUrl) setImageLocalPath(dataUrl) setImageHasUpdate(false) return } setImageError(true) } catch { setImageError(true) } finally { setImageLoading(false) } }, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64]) const handleImageClick = useCallback(() => { if (imageClickTimerRef.current) { window.clearTimeout(imageClickTimerRef.current) } setImageClicked(true) imageClickTimerRef.current = window.setTimeout(() => { setImageClicked(false) }, 800) console.info('[UI] image decrypt click', { sessionId: session.username, imageMd5: message.imageMd5, imageDatName: message.imageDatName, localId: message.localId }) void requestImageDecrypt() }, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username]) useEffect(() => { return () => { if (imageClickTimerRef.current) { window.clearTimeout(imageClickTimerRef.current) } } }, []) useEffect(() => { if (!isImage || imageLoading) return if (!message.imageMd5 && !message.imageDatName) return if (imageUpdateCheckedRef.current === imageCacheKey) return imageUpdateCheckedRef.current = imageCacheKey let cancelled = false window.electronAPI.image.resolveCache({ sessionId: session.username, imageMd5: message.imageMd5 || undefined, imageDatName: message.imageDatName }).then((result) => { if (cancelled) return if (result.success && result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) if (!imageLocalPath || imageLocalPath !== result.localPath) { setImageLocalPath(result.localPath) setImageError(false) } setImageHasUpdate(Boolean(result.hasUpdate)) } }).catch(() => {}) return () => { cancelled = true } }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username]) useEffect(() => { if (!isImage) return const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload) => { const matchesCacheKey = payload.cacheKey === message.imageMd5 || payload.cacheKey === message.imageDatName || (payload.imageMd5 && payload.imageMd5 === message.imageMd5) || (payload.imageDatName && payload.imageDatName === message.imageDatName) if (matchesCacheKey) { setImageHasUpdate(true) } }) return () => { unsubscribe?.() } }, [isImage, message.imageDatName, message.imageMd5]) useEffect(() => { if (!isImage) return const unsubscribe = window.electronAPI.image.onCacheResolved((payload) => { const matchesCacheKey = payload.cacheKey === message.imageMd5 || payload.cacheKey === message.imageDatName || (payload.imageMd5 && payload.imageMd5 === message.imageMd5) || (payload.imageDatName && payload.imageDatName === message.imageDatName) if (matchesCacheKey) { imageDataUrlCache.set(imageCacheKey, payload.localPath) setImageLocalPath(payload.localPath) setImageError(false) } }) return () => { unsubscribe?.() } }, [isImage, imageCacheKey, message.imageDatName, message.imageMd5]) useEffect(() => { if (!isVoice) return if (!voiceAudioRef.current) { voiceAudioRef.current = new Audio() } const audio = voiceAudioRef.current if (!audio) return const handlePlay = () => setIsVoicePlaying(true) const handlePause = () => setIsVoicePlaying(false) const handleEnded = () => setIsVoicePlaying(false) audio.addEventListener('play', handlePlay) audio.addEventListener('pause', handlePause) audio.addEventListener('ended', handleEnded) return () => { audio.pause() audio.removeEventListener('play', handlePlay) audio.removeEventListener('pause', handlePause) audio.removeEventListener('ended', handleEnded) } }, [isVoice]) if (isSystem) { return (
{message.parsedContent}
) } const bubbleClass = isSent ? 'sent' : 'received' // 头像逻辑: // - 自己发的:使用 myAvatarUrl // - 群聊中对方发的:使用发送者头像 // - 私聊中对方发的:使用会话头像 const avatarUrl = isSent ? myAvatarUrl : (isGroupChat ? senderAvatarUrl : session.avatarUrl) const avatarLetter = isSent ? '我' : getAvatarLetter(isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username)) // 是否有引用消息 const hasQuote = message.quotedContent && message.quotedContent.length > 0 // 解析混合文本和表情 const renderTextWithEmoji = (text: string) => { if (!text) return text const parts = text.split(/\[(.*?)\]/g) return parts.map((part, index) => { // 奇数索引是捕获组的内容(即括号内的文字) if (index % 2 === 1) { // @ts-ignore const path = getEmojiPath(part as any) if (path) { // path 例如 'assets/face/微笑.png',需要添加 base 前缀 return ( {`[${part}]`} ) } return `[${part}]` } return part }) } // 渲染消息内容 const renderContent = () => { if (isImage) { if (imageLoading) { return (
) } if (imageError || !imageLocalPath) { return ( ) } return ( <>
图片 setShowImagePreview(true)} onLoad={() => setImageError(false)} onError={() => setImageError(true)} /> {imageHasUpdate && ( )}
{showImagePreview && (
setShowImagePreview(false)}> 图片预览 e.stopPropagation()} />
)} ) } if (isVoice) { const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : '' const handleToggle = async () => { if (voiceLoading) return const audio = voiceAudioRef.current || new Audio() if (!voiceAudioRef.current) { voiceAudioRef.current = audio } if (isVoicePlaying) { audio.pause() audio.currentTime = 0 return } if (!voiceDataUrl) { setVoiceLoading(true) setVoiceError(false) try { const result = await window.electronAPI.chat.getVoiceData(session.username, String(message.localId)) if (result.success && result.data) { const url = `data:audio/wav;base64,${result.data}` voiceDataUrlCache.set(voiceCacheKey, url) setVoiceDataUrl(url) } else { setVoiceError(true) return } } catch { setVoiceError(true) return } finally { setVoiceLoading(false) } } const source = voiceDataUrlCache.get(voiceCacheKey) || voiceDataUrl if (!source) { setVoiceError(true) return } audio.src = source try { await audio.play() } catch { setVoiceError(true) } } const showDecryptHint = !voiceDataUrl && !voiceLoading && !isVoicePlaying return (
语音 {durationText && {durationText}} {voiceLoading && 解码中...} {showDecryptHint && 点击解密} {voiceError && 播放失败}
) } // 表情包消息 if (isEmoji) { // ... (keep existing emoji logic) // 没有 cdnUrl 或加载失败,显示占位符 if (!message.emojiCdnUrl || emojiError) { return (
表情包未缓存
) } // 显示加载中 if (emojiLoading || !emojiLocalPath) { return (
) } // 显示表情图片 return ( 表情 setEmojiError(true)} /> ) } // 带引用的消息 if (hasQuote) { return (
{message.quotedSender && {message.quotedSender}} {renderTextWithEmoji(message.quotedContent || '')}
{renderTextWithEmoji(message.parsedContent)}
) } // 普通消息 return
{renderTextWithEmoji(message.parsedContent)}
} return ( <> {showTime && (
{formatTime(message.createTime)}
)}
{avatarUrl ? ( ) : ( {avatarLetter} )}
{/* 群聊中显示发送者名称 */} {isGroupChat && !isSent && (
{senderName || message.senderUsername || '群成员'}
)} {renderContent()}
) } export default ChatPage