Merge pull request #459 from 2977094657/dev

fix(chat): 修复消息搜索结果的发送者信息和头像加载
This commit is contained in:
xuncha
2026-03-16 10:34:04 +08:00
committed by GitHub
4 changed files with 740 additions and 148 deletions

View File

@@ -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;
}
}
}
@keyframes avatar-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}

View File

@@ -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<HTMLImageElement>(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 (
<div
@@ -112,13 +121,30 @@ export const Avatar = React.memo(function Avatar({
alt={name || 'avatar'}
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
onLoad={() => {
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 ? (
<div className="avatar-loading">
<Loader2 size="50%" className="avatar-loading-icon" />
</div>
) : (
<div className="avatar-placeholder">
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}

View File

@@ -34,6 +34,88 @@ const SYSTEM_MESSAGE_TYPES = [
266287972401, // 拍一拍
]
interface PendingInSessionSearchPayload {
sessionId: string
keyword: string
firstMsgTime: number
results: Message[]
}
function sortMessagesByCreateTimeDesc<T extends Pick<Message, 'createTime' | 'localId'>>(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<any[]>([])
const [inSessionResults, setInSessionResults] = useState<Message[]>([])
const [inSessionSearching, setInSessionSearching] = useState(false)
const [inSessionEnriching, setInSessionEnriching] = useState(false)
const [inSessionSearchError, setInSessionSearchError] = useState<string | null>(null)
const inSessionSearchRef = useRef<HTMLInputElement>(null)
// 全局消息搜索
const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false)
const [globalMsgQuery, setGlobalMsgQuery] = useState('')
const [globalMsgResults, setGlobalMsgResults] = useState<Message[]>([])
const [globalMsgResults, setGlobalMsgResults] = useState<Array<Message & { sessionId: string }>>([])
const [globalMsgSearching, setGlobalMsgSearching] = useState(false)
const pendingInSessionSearchRef = useRef<{ keyword: string; firstMsgTime: number; results: any[] } | null>(null)
const [globalMsgSearchError, setGlobalMsgSearchError] = useState<string | null>(null)
const pendingInSessionSearchRef = useRef<PendingInSessionSearchPayload | null>(null)
const pendingGlobalMsgSearchReplayRef = useRef<string | null>(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<number, Message>()
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<string, { avatarUrl?: string; displayName?: string }>()
const pendingLoads: Array<Promise<void>> = []
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<ReturnType<typeof setTimeout> | 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<Message & { sessionId: string }> = []
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])
@@ -4304,6 +4769,11 @@ function ChatPage(props: ChatPageProps) {
<Loader2 className="spin" size={20} />
<span>...</span>
</div>
) : globalMsgSearchError ? (
<div className="no-results">
<AlertCircle size={32} />
<p>{globalMsgSearchError}</p>
</div>
) : globalMsgResults.length > 0 ? (
<>
<div className="search-section-header"></div>
@@ -4326,11 +4796,12 @@ function ChatPage(props: ChatPageProps) {
onClick={() => {
if (session) {
pendingInSessionSearchRef.current = {
sessionId,
keyword: globalMsgQuery,
firstMsgTime: firstMsg.createTime || 0,
results: messages
};
handleSelectSession(session);
}
handleSelectSession(session)
}
}}
>
@@ -4646,40 +5117,74 @@ function ChatPage(props: ChatPageProps) {
</div>
{inSessionQuery && (
<div className="search-result-header">
{inSessionSearching ? '搜索中...' : `找到 ${inSessionResults.length} 条结果`}
{inSessionSearching
? '搜索中...'
: inSessionSearchError
? '搜索失败'
: `找到 ${inSessionResults.length} 条结果`}
</div>
)}
{inSessionQuery && !inSessionSearching && inSessionSearchError && (
<div className="no-results">
<AlertCircle size={32} />
<p>{inSessionSearchError}</p>
</div>
)}
{inSessionResults.length > 0 && (
<div className="in-session-results">
{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 (
<div key={i} className="result-item" onClick={() => {
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)
}
}}>
<div className="result-header">
<Avatar src={senderAvatar} name={senderName} size={32} />
<Avatar src={senderAvatar} name={senderName} size={32} loading={senderAvatarLoading} />
</div>
<div className="result-content">
<span className="result-sender">{senderName}</span>
<span className="result-text">{(msg.content || msgData.strContent || msgData.message_content || msgData.parsedContent || '').slice(0, 80)}</span>
<span className="result-text">{previewText}</span>
</div>
<span className="result-time">{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' }) : ''}</span>
<span className="result-time">{displayTime}</span>
</div>
);
)
})}
</div>
)}
{inSessionQuery && !inSessionSearching && inSessionResults.length === 0 && (
{inSessionQuery && !inSessionSearching && !inSessionSearchError && inSessionResults.length === 0 && (
<div className="no-results">
<MessageSquare size={32} />
<p></p>

View File

@@ -3,9 +3,11 @@
export class AvatarLoadQueue {
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
private loading = new Map<string, Promise<void>>()
private failed = new Map<string, number>()
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<void> {
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
}
}