diff --git a/src/components/Avatar.scss b/src/components/Avatar.scss index 34b11b2..6a15310 100644 --- a/src/components/Avatar.scss +++ b/src/components/Avatar.scss @@ -50,6 +50,21 @@ border-radius: inherit; } + .avatar-loading { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary, #999); + background-color: var(--bg-tertiary, #e0e0e0); + border-radius: inherit; + + .avatar-loading-icon { + animation: avatar-spin 0.9s linear infinite; + } + } + /* Loading Skeleton */ .avatar-skeleton { position: absolute; @@ -76,4 +91,14 @@ background-position: -200% 0; } } -} \ No newline at end of file + + @keyframes avatar-spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } + } +} diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 7406bd5..69020f1 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useMemo } from 'react' -import { User } from 'lucide-react' +import { Loader2, User } from 'lucide-react' import { avatarLoadQueue } from '../utils/AvatarLoadQueue' import './Avatar.scss' @@ -13,6 +13,7 @@ interface AvatarProps { shape?: 'circle' | 'square' | 'rounded' className?: string lazy?: boolean + loading?: boolean onClick?: () => void } @@ -23,12 +24,14 @@ export const Avatar = React.memo(function Avatar({ shape = 'rounded', className = '', lazy = true, + loading = false, onClick }: AvatarProps) { // 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画 const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src]) + const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src]) const [imageLoaded, setImageLoaded] = useState(isCached) - const [imageError, setImageError] = useState(false) + const [imageError, setImageError] = useState(isFailed) const [shouldLoad, setShouldLoad] = useState(!lazy || isCached) const [isInQueue, setIsInQueue] = useState(false) const imgRef = useRef(null) @@ -42,7 +45,7 @@ export const Avatar = React.memo(function Avatar({ // Intersection Observer for lazy loading useEffect(() => { - if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return + if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached || imageError || isFailed) return const observer = new IntersectionObserver( (entries) => { @@ -50,10 +53,11 @@ export const Avatar = React.memo(function Avatar({ if (entry.isIntersecting && !isInQueue) { setIsInQueue(true) avatarLoadQueue.enqueue(src).then(() => { + setImageError(false) setShouldLoad(true) }).catch(() => { - // 加载失败不要立刻显示错误,让浏览器渲染去报错 - setShouldLoad(true) + setImageError(true) + setShouldLoad(false) }).finally(() => { setIsInQueue(false) }) @@ -67,14 +71,18 @@ export const Avatar = React.memo(function Avatar({ observer.observe(containerRef.current) return () => observer.disconnect() - }, [src, lazy, shouldLoad, isInQueue, isCached]) + }, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed]) // Reset state when src changes useEffect(() => { const cached = src ? loadedAvatarCache.has(src) : false + const failed = src ? avatarLoadQueue.hasFailed(src) : false setImageLoaded(cached) - setImageError(false) - if (lazy && !cached) { + setImageError(failed) + if (failed) { + setShouldLoad(false) + setIsInQueue(false) + } else if (lazy && !cached) { setShouldLoad(false) setIsInQueue(false) } else { @@ -95,6 +103,7 @@ export const Avatar = React.memo(function Avatar({ } const hasValidUrl = !!src && !imageError && shouldLoad + const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError return (
{ - if (src) loadedAvatarCache.add(src) + if (src) { + avatarLoadQueue.clearFailed(src) + loadedAvatarCache.add(src) + } setImageLoaded(true) + setImageError(false) + }} + onError={() => { + if (src) { + avatarLoadQueue.markFailed(src) + loadedAvatarCache.delete(src) + } + setImageLoaded(false) + setImageError(true) + setShouldLoad(false) }} - onError={() => setImageError(true)} loading={lazy ? "lazy" : "eager"} + referrerPolicy="no-referrer" /> + ) : shouldShowLoadingPlaceholder ? ( +
+ +
) : (
{name ? {getAvatarLetter()} : } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index eaad286..f4dd78a 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -34,6 +34,88 @@ const SYSTEM_MESSAGE_TYPES = [ 266287972401, // 拍一拍 ] +interface PendingInSessionSearchPayload { + sessionId: string + keyword: string + firstMsgTime: number + results: Message[] +} + +function sortMessagesByCreateTimeDesc>(items: T[]): T[] { + return [...items].sort((a, b) => { + const timeDiff = (b.createTime || 0) - (a.createTime || 0) + if (timeDiff !== 0) return timeDiff + return (b.localId || 0) - (a.localId || 0) + }) +} + +function normalizeSearchIdentityText(value?: string | null): string | undefined { + const normalized = String(value || '').trim() + if (!normalized) return undefined + const lower = normalized.toLowerCase() + if (normalized === '未知' || lower === 'unknown' || lower === 'null' || lower === 'undefined') { + return undefined + } + if (lower.startsWith('unknown_sender_')) { + return undefined + } + return normalized +} + +function normalizeSearchAvatarUrl(value?: string | null): string | undefined { + const normalized = String(value || '').trim() + if (!normalized) return undefined + const lower = normalized.toLowerCase() + if (lower === 'null' || lower === 'undefined') { + return undefined + } + return normalized +} + +function isWxidLikeSearchIdentity(value?: string | null): boolean { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized) return false + if (normalized.startsWith('wxid_')) return true + const suffixMatch = normalized.match(/^(.+)_([a-z0-9]{4})$/i) + return Boolean(suffixMatch && suffixMatch[1].startsWith('wxid_')) +} + +function resolveSearchSenderDisplayName( + displayName?: string | null, + senderUsername?: string | null, + sessionId?: string | null +): string | undefined { + const normalizedDisplayName = normalizeSearchIdentityText(displayName) + if (!normalizedDisplayName) return undefined + + const normalizedSenderUsername = normalizeSearchIdentityText(senderUsername) + const normalizedSessionId = normalizeSearchIdentityText(sessionId) + + if (normalizedSessionId && normalizedDisplayName === normalizedSessionId) { + return undefined + } + if (isWxidLikeSearchIdentity(normalizedDisplayName)) { + return undefined + } + if ( + normalizedSenderUsername && + normalizedDisplayName === normalizedSenderUsername && + isWxidLikeSearchIdentity(normalizedSenderUsername) + ) { + return undefined + } + + return normalizedDisplayName +} + +function resolveSearchSenderUsernameFallback(value?: string | null): string | undefined { + const normalized = normalizeSearchIdentityText(value) + if (!normalized || isWxidLikeSearchIdentity(normalized)) { + return undefined + } + return normalized +} + interface XmlField { key: string; value: string; @@ -668,15 +750,19 @@ function ChatPage(props: ChatPageProps) { // 会话内搜索 const [showInSessionSearch, setShowInSessionSearch] = useState(false) const [inSessionQuery, setInSessionQuery] = useState('') - const [inSessionResults, setInSessionResults] = useState([]) + const [inSessionResults, setInSessionResults] = useState([]) const [inSessionSearching, setInSessionSearching] = useState(false) + const [inSessionEnriching, setInSessionEnriching] = useState(false) + const [inSessionSearchError, setInSessionSearchError] = useState(null) const inSessionSearchRef = useRef(null) // 全局消息搜索 const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false) const [globalMsgQuery, setGlobalMsgQuery] = useState('') - const [globalMsgResults, setGlobalMsgResults] = useState([]) + const [globalMsgResults, setGlobalMsgResults] = useState>([]) const [globalMsgSearching, setGlobalMsgSearching] = useState(false) - const pendingInSessionSearchRef = useRef<{ keyword: string; firstMsgTime: number; results: any[] } | null>(null) + const [globalMsgSearchError, setGlobalMsgSearchError] = useState(null) + const pendingInSessionSearchRef = useRef(null) + const pendingGlobalMsgSearchReplayRef = useRef(null) // 自定义删除确认对话框 const [deleteConfirm, setDeleteConfirm] = useState<{ @@ -2574,51 +2660,19 @@ function ChatPage(props: ChatPageProps) { setIsSessionSwitching(false) // 处理从全局搜索跳转过来的情况 - if (pendingInSessionSearchRef.current) { - const { keyword, firstMsgTime, results } = pendingInSessionSearchRef.current + const pendingSearch = pendingInSessionSearchRef.current + if (pendingSearch?.sessionId === sessionId) { pendingInSessionSearchRef.current = null - - setShowInSessionSearch(true) - setInSessionQuery(keyword) - - if (firstMsgTime > 0) { - handleJumpDateSelect(new Date(firstMsgTime * 1000)) - } - - // 先获取完整消息,再补充发送者信息 - const sid = currentSessionId - if (sid) { - Promise.all( - results.map(async (msg: any) => { - try { - const full = await window.electronAPI.chat.getMessages(sid, 0, 3, msg.createTime, msg.createTime, false) - const found = full?.messages?.find((m: any) => m.localId === msg.localId) || msg - - if (found.senderUsername) { - const contact = await window.electronAPI.chat.getContact(found.senderUsername) - if (contact) { - found.senderDisplayName = contact.remark || contact.nickName || found.senderUsername - } - const avatarData = await window.electronAPI.chat.getContactAvatar(found.senderUsername) - if (avatarData?.avatarUrl) { - found.senderAvatarUrl = avatarData.avatarUrl - } - } - return found - } catch { - return msg - } - }) - ).then(enriched => setInSessionResults(enriched)) - } + void applyPendingInSessionSearch(sessionId, pendingSearch, options.switchRequestSeq) } } } } } - const handleJumpDateSelect = useCallback((date: Date) => { - if (!currentSessionId) return + const handleJumpDateSelect = useCallback((date: Date, options: { sessionId?: string; switchRequestSeq?: number } = {}) => { + const targetSessionId = String(options.sessionId || currentSessionRef.current || currentSessionId || '').trim() + if (!targetSessionId) return const targetDate = new Date(date) const end = Math.floor(targetDate.setHours(23, 59, 59, 999) / 1000) // 日期跳转采用“锚点定位”而非“当天过滤”: @@ -2628,9 +2682,377 @@ function ChatPage(props: ChatPageProps) { setJumpStartTime(0) setJumpEndTime(end) setShowJumpPopover(false) - void loadMessages(currentSessionId, 0, 0, end, false) + void loadMessages(targetSessionId, 0, 0, end, false, { + switchRequestSeq: options.switchRequestSeq + }) }, [currentSessionId, loadMessages]) + const cancelInSessionSearchTasks = useCallback(() => { + inSessionSearchGenRef.current += 1 + if (inSessionSearchTimerRef.current) { + clearTimeout(inSessionSearchTimerRef.current) + inSessionSearchTimerRef.current = null + } + setInSessionSearching(false) + setInSessionEnriching(false) + }, []) + + const resolveSearchSessionContext = useCallback((sessionId?: string) => { + const normalizedSessionId = String(sessionId || currentSessionRef.current || currentSessionId || '').trim() + const currentSearchSession = normalizedSessionId && Array.isArray(sessions) + ? sessions.find(session => session.username === normalizedSessionId) + : undefined + const resolvedSession = currentSearchSession + ? ( + standaloneSessionWindow && + normalizedInitialSessionId && + currentSearchSession.username === normalizedInitialSessionId + ? { + ...currentSearchSession, + displayName: currentSearchSession.displayName || fallbackDisplayName || currentSearchSession.username, + avatarUrl: currentSearchSession.avatarUrl || fallbackAvatarUrl || undefined + } + : currentSearchSession + ) + : ( + normalizedSessionId + ? { + username: normalizedSessionId, + displayName: fallbackDisplayName || normalizedSessionId, + avatarUrl: fallbackAvatarUrl || undefined + } as ChatSession + : undefined + ) + const isGroupSearchSession = Boolean( + resolvedSession && ( + isGroupChatSession(resolvedSession.username) || + ( + standaloneSessionWindow && + resolvedSession.username === normalizedInitialSessionId && + normalizedStandaloneInitialContactType === 'group' + ) + ) + ) + const isDirectSearchSession = Boolean( + resolvedSession && + isSingleContactSession(resolvedSession.username) && + !isGroupSearchSession + ) + return { + normalizedSessionId, + resolvedSession, + isDirectSearchSession, + isGroupSearchSession, + resolvedSessionDisplayName: normalizeSearchIdentityText(resolvedSession?.displayName) || normalizedSessionId || undefined, + resolvedSessionAvatarUrl: normalizeSearchAvatarUrl(resolvedSession?.avatarUrl) + } + }, [ + currentSessionId, + fallbackAvatarUrl, + fallbackDisplayName, + normalizedInitialSessionId, + normalizedStandaloneInitialContactType, + sessions, + standaloneSessionWindow, + isGroupChatSession + ]) + + const hydrateInSessionSearchResults = useCallback((rawMessages: Message[], sessionId?: string) => { + const sortedMessages = sortMessagesByCreateTimeDesc(rawMessages || []) + if (sortedMessages.length === 0) return [] + + const { + normalizedSessionId, + isDirectSearchSession, + resolvedSessionDisplayName, + resolvedSessionAvatarUrl + } = resolveSearchSessionContext(sessionId) + const resolvedSessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId) + + return sortedMessages.map((message) => { + const senderUsername = normalizeSearchIdentityText(message.senderUsername) || message.senderUsername + const senderDisplayName = resolveSearchSenderDisplayName( + message.senderDisplayName, + senderUsername, + normalizedSessionId + ) + const senderUsernameFallback = resolveSearchSenderUsernameFallback(senderUsername) + const senderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl) + const nextSenderDisplayName = message.isSend === 1 + ? (senderDisplayName || '我') + : ( + senderDisplayName || + (isDirectSearchSession ? resolvedSessionDisplayName : undefined) || + senderUsernameFallback || + (isDirectSearchSession ? resolvedSessionUsernameFallback : undefined) || + '未知' + ) + const nextSenderAvatarUrl = message.isSend === 1 + ? (senderAvatarUrl || myAvatarUrl) + : (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)) + + if ( + senderUsername === message.senderUsername && + nextSenderDisplayName === message.senderDisplayName && + nextSenderAvatarUrl === message.senderAvatarUrl + ) { + return message + } + + return { + ...message, + senderUsername, + senderDisplayName: nextSenderDisplayName, + senderAvatarUrl: nextSenderAvatarUrl + } + }) + }, [currentSessionId, myAvatarUrl, resolveSearchSessionContext]) + + const enrichMessagesWithSenderProfiles = useCallback(async (rawMessages: Message[], sessionId?: string) => { + let messages = hydrateInSessionSearchResults(rawMessages, sessionId) + if (messages.length === 0) return [] + + const sessionContext = resolveSearchSessionContext(sessionId) + const { normalizedSessionId, isDirectSearchSession, isGroupSearchSession } = sessionContext + let resolvedSessionDisplayName = sessionContext.resolvedSessionDisplayName + let resolvedSessionAvatarUrl = sessionContext.resolvedSessionAvatarUrl + + if ( + normalizedSessionId && + isDirectSearchSession && + ( + !resolvedSessionAvatarUrl || + !resolvedSessionDisplayName || + resolvedSessionDisplayName === normalizedSessionId + ) + ) { + try { + const result = await window.electronAPI.chat.enrichSessionsContactInfo([normalizedSessionId]) + const profile = result.success && result.contacts ? result.contacts[normalizedSessionId] : undefined + const profileDisplayName = resolveSearchSenderDisplayName( + profile?.displayName, + normalizedSessionId, + normalizedSessionId + ) + const profileAvatarUrl = normalizeSearchAvatarUrl(profile?.avatarUrl) + if (profileDisplayName) { + resolvedSessionDisplayName = profileDisplayName + } + if (profileAvatarUrl) { + resolvedSessionAvatarUrl = profileAvatarUrl + } + if (profileDisplayName || profileAvatarUrl) { + messages = messages.map((message) => { + if (message.isSend === 1) return message + const preservedDisplayName = resolveSearchSenderDisplayName( + message.senderDisplayName, + message.senderUsername, + normalizedSessionId + ) + return { + ...message, + senderDisplayName: preservedDisplayName || + profileDisplayName || + resolvedSessionDisplayName || + resolveSearchSenderUsernameFallback(message.senderUsername) || + message.senderDisplayName, + senderAvatarUrl: normalizeSearchAvatarUrl(message.senderAvatarUrl) || profileAvatarUrl || resolvedSessionAvatarUrl || message.senderAvatarUrl + } + }) + } + } catch { + // ignore session profile enrichment errors and keep raw search results usable + } + } + + if (normalizedSessionId && isGroupSearchSession) { + const missingSenderMessages = messages.filter((message) => { + if (message.localId <= 0) return false + if (message.isSend === 1) return false + return !normalizeSearchIdentityText(message.senderUsername) + }) + + if (missingSenderMessages.length > 0) { + const messageByLocalId = new Map() + for (let index = 0; index < missingSenderMessages.length; index += 8) { + const batch = missingSenderMessages.slice(index, index + 8) + const detailResults = await Promise.allSettled( + batch.map(async (message) => { + const result = await window.electronAPI.chat.getMessage(normalizedSessionId, message.localId) + if (!result.success || !result.message) return null + return { + localId: message.localId, + message: hydrateInSessionSearchResults([{ + ...message, + ...result.message, + parsedContent: message.parsedContent || result.message.parsedContent, + rawContent: message.rawContent || result.message.rawContent, + content: message.content || result.message.content + } as Message], normalizedSessionId)[0] + } + }) + ) + + for (const detail of detailResults) { + if (detail.status !== 'fulfilled' || !detail.value?.message) continue + messageByLocalId.set(detail.value.localId, detail.value.message) + } + } + + if (messageByLocalId.size > 0) { + messages = messages.map(message => messageByLocalId.get(message.localId) || message) + } + } + } + + const profileMap = new Map() + const pendingLoads: Array> = [] + const missingUsernames: string[] = [] + + const usernames = [...new Set( + messages + .map((message) => normalizeSearchIdentityText(message.senderUsername)) + .filter((username): username is string => Boolean(username)) + )] + + for (const username of usernames) { + const cached = senderAvatarCache.get(username) + if (cached) { + profileMap.set(username, cached) + continue + } + + const pending = senderAvatarLoading.get(username) + if (pending) { + pendingLoads.push( + pending.then((profile) => { + if (profile) { + profileMap.set(username, profile) + } + }).catch(() => {}) + ) + continue + } + + missingUsernames.push(username) + } + + if (pendingLoads.length > 0) { + await Promise.allSettled(pendingLoads) + } + + if (missingUsernames.length > 0) { + try { + const result = await window.electronAPI.chat.enrichSessionsContactInfo(missingUsernames) + if (result.success && result.contacts) { + for (const [username, profile] of Object.entries(result.contacts)) { + const normalizedProfile = { + avatarUrl: profile.avatarUrl, + displayName: profile.displayName + } + profileMap.set(username, normalizedProfile) + senderAvatarCache.set(username, normalizedProfile) + } + } + } catch { + // ignore sender enrichment errors and keep raw search results usable + } + } + + return messages.map((message) => { + const sender = normalizeSearchIdentityText(message.senderUsername) + const profile = sender ? profileMap.get(sender) : undefined + const profileDisplayName = resolveSearchSenderDisplayName( + profile?.displayName, + sender, + normalizedSessionId + ) + const currentSenderDisplayName = resolveSearchSenderDisplayName( + message.senderDisplayName, + sender, + normalizedSessionId + ) + const senderUsernameFallback = resolveSearchSenderUsernameFallback(sender) + const sessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId) + const currentSenderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl) + const nextSenderDisplayName = message.isSend === 1 + ? (currentSenderDisplayName || profileDisplayName || '我') + : ( + profileDisplayName || + currentSenderDisplayName || + (isDirectSearchSession ? resolvedSessionDisplayName : undefined) || + senderUsernameFallback || + (isDirectSearchSession ? sessionUsernameFallback : undefined) || + '未知' + ) + const nextSenderAvatarUrl = message.isSend === 1 + ? (currentSenderAvatarUrl || myAvatarUrl || normalizeSearchAvatarUrl(profile?.avatarUrl)) + : ( + currentSenderAvatarUrl || + normalizeSearchAvatarUrl(profile?.avatarUrl) || + (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined) + ) + + if ( + sender === message.senderUsername && + nextSenderDisplayName === message.senderDisplayName && + nextSenderAvatarUrl === message.senderAvatarUrl + ) { + return message + } + + return { + ...message, + senderUsername: sender || message.senderUsername, + senderDisplayName: nextSenderDisplayName, + senderAvatarUrl: nextSenderAvatarUrl + } + }) + }, [ + currentSessionId, + hydrateInSessionSearchResults, + myAvatarUrl, + resolveSearchSessionContext + ]) + + const applyPendingInSessionSearch = useCallback(async ( + sessionId: string, + payload: PendingInSessionSearchPayload, + switchRequestSeq?: number + ) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + if (payload.sessionId !== normalizedSessionId) return + if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return + if (currentSessionRef.current !== normalizedSessionId) return + + const immediateResults = hydrateInSessionSearchResults(payload.results || [], normalizedSessionId) + setShowInSessionSearch(true) + setInSessionQuery(payload.keyword) + setInSessionSearchError(null) + setInSessionResults(immediateResults) + + if (payload.firstMsgTime > 0) { + handleJumpDateSelect(new Date(payload.firstMsgTime * 1000), { + sessionId: normalizedSessionId, + switchRequestSeq + }) + } + + setInSessionEnriching(true) + void enrichMessagesWithSenderProfiles(immediateResults, normalizedSessionId).then((enrichedResults) => { + if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return + if (currentSessionRef.current !== normalizedSessionId) return + setInSessionResults(enrichedResults) + }).catch((error) => { + console.warn('[InSessionSearch] 恢复全局搜索结果发送者信息失败:', error) + }).finally(() => { + if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return + if (currentSessionRef.current !== normalizedSessionId) return + setInSessionEnriching(false) + }) + }, [enrichMessagesWithSenderProfiles, handleJumpDateSelect, hydrateInSessionSearchResults]) + // 加载更晚的消息 const loadLaterMessages = useCallback(async () => { if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return @@ -2696,12 +3118,19 @@ function ChatPage(props: ChatPageProps) { if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1 sessionSwitchRequestSeqRef.current = switchRequestSeq + currentSessionRef.current = normalizedSessionId + + const pendingSearch = pendingInSessionSearchRef.current + const shouldPreservePendingSearch = pendingSearch?.sessionId === normalizedSessionId + cancelInSessionSearchTasks() // 清空会话内搜索状态(除非是从全局搜索跳转过来) - if (!pendingInSessionSearchRef.current) { + if (!shouldPreservePendingSearch) { + pendingInSessionSearchRef.current = null setShowInSessionSearch(false) setInSessionQuery('') setInSessionResults([]) + setInSessionSearchError(null) } setCurrentSession(normalizedSessionId, { preserveMessages: false }) @@ -2714,41 +3143,9 @@ function ChatPage(props: ChatPageProps) { setIsSessionSwitching(false) // 处理从全局搜索跳转过来的情况 - if (pendingInSessionSearchRef.current) { - const { keyword, firstMsgTime, results } = pendingInSessionSearchRef.current + if (pendingSearch?.sessionId === normalizedSessionId) { pendingInSessionSearchRef.current = null - - setShowInSessionSearch(true) - setInSessionQuery(keyword) - - if (firstMsgTime > 0) { - handleJumpDateSelect(new Date(firstMsgTime * 1000)) - } - - // 先获取完整消息,再补充发送者信息 - const sid = normalizedSessionId - Promise.all( - results.map(async (msg: any) => { - try { - const full = await window.electronAPI.chat.getMessages(sid, 0, 3, msg.createTime, msg.createTime, false) - const found = full?.messages?.find((m: any) => m.localId === msg.localId) || msg - - if (found.senderUsername) { - const contact = await window.electronAPI.chat.getContact(found.senderUsername) - if (contact) { - found.senderDisplayName = contact.remark || contact.nickName || found.senderUsername - } - const avatarData = await window.electronAPI.chat.getContactAvatar(found.senderUsername) - if (avatarData?.avatarUrl) { - found.senderAvatarUrl = avatarData.avatarUrl - } - } - return found - } catch { - return msg - } - }) - ).then(enriched => setInSessionResults(enriched)) + void applyPendingInSessionSearch(normalizedSessionId, pendingSearch, switchRequestSeq) } void refreshSessionIncrementally(normalizedSessionId, switchRequestSeq) @@ -2786,7 +3183,9 @@ function ChatPage(props: ChatPageProps) { restoreSessionWindowCache, refreshSessionIncrementally, hydrateSessionPreview, - loadMessages + loadMessages, + cancelInSessionSearchTasks, + applyPendingInSessionSearch ]) // 选择会话 @@ -2815,12 +3214,16 @@ function ChatPage(props: ChatPageProps) { const handleInSessionSearch = useCallback(async (keyword: string) => { setInSessionQuery(keyword) if (inSessionSearchTimerRef.current) clearTimeout(inSessionSearchTimerRef.current) + inSessionSearchTimerRef.current = null inSessionSearchGenRef.current += 1 if (!keyword.trim() || !currentSessionId) { setInSessionResults([]) + setInSessionSearchError(null) setInSessionSearching(false) + setInSessionEnriching(false) return } + setInSessionSearchError(null) const gen = inSessionSearchGenRef.current const sid = currentSessionId inSessionSearchTimerRef.current = setTimeout(async () => { @@ -2828,89 +3231,127 @@ function ChatPage(props: ChatPageProps) { setInSessionSearching(true) try { const res = await window.electronAPI.chat.searchMessages(keyword.trim(), sid, 50, 0) - if (gen !== inSessionSearchGenRef.current) return - const messages = res?.messages || [] + if (!res?.success) { + throw new Error(res?.error || '搜索失败') + } + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return + const messages = hydrateInSessionSearchResults(res?.messages || [], sid) + setInSessionResults(messages) + setInSessionSearchError(null) - // 补充联系人信息 - const enriched = await Promise.all( - messages.map(async (msg: any) => { - if (msg.senderUsername) { - try { - const contact = await window.electronAPI.chat.getContact(msg.senderUsername) - if (contact) { - msg.senderDisplayName = contact.remark || contact.nickName || msg.senderUsername - } - const avatarData = await window.electronAPI.chat.getContactAvatar(msg.senderUsername) - if (avatarData?.avatarUrl) { - msg.senderAvatarUrl = avatarData.avatarUrl - } - } catch {} - } - return msg - }) - ) - - if (gen !== inSessionSearchGenRef.current) return - console.log('补充后:', enriched[0]) - setInSessionResults(enriched) - } catch { - if (gen !== inSessionSearchGenRef.current) return + setInSessionEnriching(true) + void enrichMessagesWithSenderProfiles(messages, sid).then((enriched) => { + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return + setInSessionResults(enriched) + }).catch((error) => { + console.warn('[InSessionSearch] 补充发送者信息失败:', error) + }).finally(() => { + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return + setInSessionEnriching(false) + }) + } catch (error) { + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return setInSessionResults([]) + setInSessionSearchError(error instanceof Error ? error.message : String(error)) + setInSessionEnriching(false) } finally { if (gen === inSessionSearchGenRef.current) setInSessionSearching(false) } }, 500) - }, [currentSessionId]) + }, [currentSessionId, enrichMessagesWithSenderProfiles, hydrateInSessionSearchResults]) const handleToggleInSessionSearch = useCallback(() => { setShowInSessionSearch(v => { if (v) { - inSessionSearchGenRef.current += 1 - if (inSessionSearchTimerRef.current) clearTimeout(inSessionSearchTimerRef.current) + cancelInSessionSearchTasks() setInSessionQuery('') setInSessionResults([]) - setInSessionSearching(false) + setInSessionSearchError(null) } else { setTimeout(() => inSessionSearchRef.current?.focus(), 50) } return !v }) - }, []) + }, [cancelInSessionSearchTasks]) // 全局消息搜索 const globalMsgSearchTimerRef = useRef | null>(null) const globalMsgSearchGenRef = useRef(0) const handleGlobalMsgSearch = useCallback(async (keyword: string) => { + const normalizedKeyword = keyword.trim() setGlobalMsgQuery(keyword) if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current) + globalMsgSearchTimerRef.current = null globalMsgSearchGenRef.current += 1 - if (!keyword.trim()) { + if (!normalizedKeyword) { + pendingGlobalMsgSearchReplayRef.current = null setGlobalMsgResults([]) + setGlobalMsgSearchError(null) setShowGlobalMsgSearch(false) setGlobalMsgSearching(false) return } setShowGlobalMsgSearch(true) + setGlobalMsgSearchError(null) + + const sessionList = Array.isArray(sessionsRef.current) ? sessionsRef.current.filter((session) => String(session.username || '').trim()) : [] + if (!isConnectedRef.current || sessionList.length === 0) { + pendingGlobalMsgSearchReplayRef.current = normalizedKeyword + setGlobalMsgResults([]) + setGlobalMsgSearchError(null) + setGlobalMsgSearching(false) + return + } + + pendingGlobalMsgSearchReplayRef.current = null const gen = globalMsgSearchGenRef.current globalMsgSearchTimerRef.current = setTimeout(async () => { if (gen !== globalMsgSearchGenRef.current) return setGlobalMsgSearching(true) try { const results: Array = [] - for (const session of sessions) { - const res = await window.electronAPI.chat.searchMessages(keyword.trim(), session.username, 10, 0) - if (res?.messages) { - results.push(...res.messages.map(msg => ({ ...msg, sessionId: session.username }))) + const concurrency = 6 + + for (let index = 0; index < sessionList.length; index += concurrency) { + const chunk = sessionList.slice(index, index + concurrency) + const chunkResults = await Promise.allSettled( + chunk.map(async (session) => { + const res = await window.electronAPI.chat.searchMessages(normalizedKeyword, session.username, 10, 0) + if (!res?.success) { + throw new Error(res?.error || `搜索失败: ${session.username}`) + } + if (!res?.messages?.length) return [] + return res.messages.map((msg) => ({ ...msg, sessionId: session.username })) + }) + ) + + if (gen !== globalMsgSearchGenRef.current) return + + for (const item of chunkResults) { + if (item.status === 'rejected') { + throw item.reason instanceof Error ? item.reason : new Error(String(item.reason)) + } + if (item.value.length > 0) { + results.push(...item.value) + } } } + + results.sort((a, b) => { + const timeDiff = (b.createTime || 0) - (a.createTime || 0) + if (timeDiff !== 0) return timeDiff + return (b.localId || 0) - (a.localId || 0) + }) + if (gen !== globalMsgSearchGenRef.current) return - setGlobalMsgResults(results as any) - } catch { + setGlobalMsgResults(results) + setGlobalMsgSearchError(null) + } catch (error) { if (gen !== globalMsgSearchGenRef.current) return setGlobalMsgResults([]) + setGlobalMsgSearchError(error instanceof Error ? error.message : String(error)) } finally { if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false) - setGlobalMsgSearching(false) } }, 500) }, []) @@ -2918,11 +3359,13 @@ function ChatPage(props: ChatPageProps) { const handleCloseGlobalMsgSearch = useCallback(() => { globalMsgSearchGenRef.current += 1 if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current) + globalMsgSearchTimerRef.current = null + pendingGlobalMsgSearchReplayRef.current = null setShowGlobalMsgSearch(false) setGlobalMsgQuery('') setGlobalMsgResults([]) + setGlobalMsgSearchError(null) setGlobalMsgSearching(false) - if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current) }, []) // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) @@ -3205,6 +3648,28 @@ function ChatPage(props: ChatPageProps) { isConnectedRef.current = isConnected }, [isConnected]) + useEffect(() => { + const replayKeyword = pendingGlobalMsgSearchReplayRef.current + if (!replayKeyword || !isConnected || sessions.length === 0) return + pendingGlobalMsgSearchReplayRef.current = null + void handleGlobalMsgSearch(replayKeyword) + }, [isConnected, sessions.length, handleGlobalMsgSearch]) + + useEffect(() => { + return () => { + inSessionSearchGenRef.current += 1 + if (inSessionSearchTimerRef.current) { + clearTimeout(inSessionSearchTimerRef.current) + inSessionSearchTimerRef.current = null + } + globalMsgSearchGenRef.current += 1 + if (globalMsgSearchTimerRef.current) { + clearTimeout(globalMsgSearchTimerRef.current) + globalMsgSearchTimerRef.current = null + } + } + }, []) + useEffect(() => { searchKeywordRef.current = searchKeyword }, [searchKeyword]) @@ -4284,6 +4749,11 @@ function ChatPage(props: ChatPageProps) { 搜索中...
+ ) : globalMsgSearchError ? ( +
+ +

{globalMsgSearchError}

+
) : globalMsgResults.length > 0 ? ( <>
聊天记录:
@@ -4306,11 +4776,12 @@ function ChatPage(props: ChatPageProps) { onClick={() => { if (session) { pendingInSessionSearchRef.current = { + sessionId, keyword: globalMsgQuery, firstMsgTime: firstMsg.createTime || 0, results: messages - }; - handleSelectSession(session); + } + handleSelectSession(session) } }} > @@ -4626,40 +5097,74 @@ function ChatPage(props: ChatPageProps) {
{inSessionQuery && (
- {inSessionSearching ? '搜索中...' : `找到 ${inSessionResults.length} 条结果`} + {inSessionSearching + ? '搜索中...' + : inSessionSearchError + ? '搜索失败' + : `找到 ${inSessionResults.length} 条结果`} +
+ )} + {inSessionQuery && !inSessionSearching && inSessionSearchError && ( +
+ +

{inSessionSearchError}

)} {inSessionResults.length > 0 && (
{inSessionResults.map((msg, i) => { - const msgData = msg as any; - const senderName = msgData.senderDisplayName || msgData.senderUsername || '未知'; - const senderAvatar = msgData.senderAvatarUrl; + const resolvedSenderDisplayName = resolveSearchSenderDisplayName( + msg.senderDisplayName, + msg.senderUsername, + currentSessionId + ) + const resolvedSenderUsername = resolveSearchSenderUsernameFallback(msg.senderUsername) + const resolvedSenderAvatarUrl = normalizeSearchAvatarUrl(msg.senderAvatarUrl) + const resolvedCurrentSessionName = normalizeSearchIdentityText(currentSession?.displayName) || + resolveSearchSenderUsernameFallback(currentSession?.username) || + resolveSearchSenderUsernameFallback(currentSessionId) + const senderName = resolvedSenderDisplayName || ( + msg.isSend === 1 + ? '我' + : (isCurrentSessionPrivateSnsSupported + ? resolvedCurrentSessionName || (inSessionEnriching ? '加载中...' : '未知') + : resolvedSenderUsername || (inSessionEnriching ? '加载中...' : '未知成员')) + ) + const senderAvatar = resolvedSenderAvatarUrl || ( + msg.isSend === 1 + ? myAvatarUrl + : (isCurrentSessionPrivateSnsSupported ? normalizeSearchAvatarUrl(currentSession?.avatarUrl) : undefined) + ) + const senderAvatarLoading = inSessionEnriching && !senderAvatar + const previewText = (msg.parsedContent || msg.content || '').slice(0, 80) + const displayTime = msg.createTime + ? new Date(msg.createTime * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + : '' return (
{ - const ts = msg.createTime || msgData.create_time; + const ts = msg.createTime if (ts && currentSessionId) { - setCurrentOffset(0); - setJumpStartTime(0); - setJumpEndTime(0); - void loadMessages(currentSessionId, 0, ts - 1, ts + 1, false); + setCurrentOffset(0) + setJumpStartTime(0) + setJumpEndTime(0) + void loadMessages(currentSessionId, 0, ts - 1, ts + 1, false) } }}>
- +
{senderName} - {(msg.content || msgData.strContent || msgData.message_content || msgData.parsedContent || '').slice(0, 80)} + {previewText}
- {msg.createTime || msgData.create_time ? new Date((msg.createTime || msgData.create_time) * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''} + {displayTime}
- ); + ) })}
)} - {inSessionQuery && !inSessionSearching && inSessionResults.length === 0 && ( + {inSessionQuery && !inSessionSearching && !inSessionSearchError && inSessionResults.length === 0 && (

未找到相关消息

diff --git a/src/utils/AvatarLoadQueue.ts b/src/utils/AvatarLoadQueue.ts index 85e297b..a497f52 100644 --- a/src/utils/AvatarLoadQueue.ts +++ b/src/utils/AvatarLoadQueue.ts @@ -3,9 +3,11 @@ export class AvatarLoadQueue { private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = [] private loading = new Map>() + private failed = new Map() private activeCount = 0 private readonly maxConcurrent = 3 private readonly delayBetweenBatches = 10 + private readonly failedTtlMs = 10 * 60 * 1000 private static instance: AvatarLoadQueue @@ -18,6 +20,9 @@ export class AvatarLoadQueue { async enqueue(url: string): Promise { if (!url) return Promise.resolve() + if (this.hasFailed(url)) { + return Promise.reject(new Error(`Failed: ${url}`)) + } // 核心修复:防止重复并发请求同一个 URL const existingPromise = this.loading.get(url) @@ -31,13 +36,40 @@ export class AvatarLoadQueue { }) this.loading.set(url, loadPromise) - loadPromise.finally(() => { - this.loading.delete(url) - }) + void loadPromise.then( + () => { + this.loading.delete(url) + this.clearFailed(url) + }, + () => { + this.loading.delete(url) + } + ) return loadPromise } + hasFailed(url: string): boolean { + if (!url) return false + const failedAt = this.failed.get(url) + if (!failedAt) return false + if (Date.now() - failedAt > this.failedTtlMs) { + this.failed.delete(url) + return false + } + return true + } + + markFailed(url: string) { + if (!url) return + this.failed.set(url, Date.now()) + } + + clearFailed(url: string) { + if (!url) return + this.failed.delete(url) + } + private async processQueue() { if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) { return @@ -49,13 +81,16 @@ export class AvatarLoadQueue { this.activeCount++ const img = new Image() + img.referrerPolicy = 'no-referrer' img.onload = () => { this.activeCount-- + this.clearFailed(task.url) task.resolve() setTimeout(() => this.processQueue(), this.delayBetweenBatches) } img.onerror = () => { this.activeCount-- + this.markFailed(task.url) task.reject(new Error(`Failed: ${task.url}`)) setTimeout(() => this.processQueue(), this.delayBetweenBatches) } @@ -67,6 +102,7 @@ export class AvatarLoadQueue { clear() { this.queue = [] this.loading.clear() + this.failed.clear() this.activeCount = 0 } }