mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
fix(chat): repair search result sender info
This commit is contained in:
@@ -50,6 +50,21 @@
|
|||||||
border-radius: inherit;
|
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 */
|
/* Loading Skeleton */
|
||||||
.avatar-skeleton {
|
.avatar-skeleton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -76,4 +91,14 @@
|
|||||||
background-position: -200% 0;
|
background-position: -200% 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes avatar-spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
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 { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||||
import './Avatar.scss'
|
import './Avatar.scss'
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ interface AvatarProps {
|
|||||||
shape?: 'circle' | 'square' | 'rounded'
|
shape?: 'circle' | 'square' | 'rounded'
|
||||||
className?: string
|
className?: string
|
||||||
lazy?: boolean
|
lazy?: boolean
|
||||||
|
loading?: boolean
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,12 +24,14 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
shape = 'rounded',
|
shape = 'rounded',
|
||||||
className = '',
|
className = '',
|
||||||
lazy = true,
|
lazy = true,
|
||||||
|
loading = false,
|
||||||
onClick
|
onClick
|
||||||
}: AvatarProps) {
|
}: AvatarProps) {
|
||||||
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
||||||
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
||||||
|
const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src])
|
||||||
const [imageLoaded, setImageLoaded] = useState(isCached)
|
const [imageLoaded, setImageLoaded] = useState(isCached)
|
||||||
const [imageError, setImageError] = useState(false)
|
const [imageError, setImageError] = useState(isFailed)
|
||||||
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
||||||
const [isInQueue, setIsInQueue] = useState(false)
|
const [isInQueue, setIsInQueue] = useState(false)
|
||||||
const imgRef = useRef<HTMLImageElement>(null)
|
const imgRef = useRef<HTMLImageElement>(null)
|
||||||
@@ -42,7 +45,7 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
|
|
||||||
// Intersection Observer for lazy loading
|
// Intersection Observer for lazy loading
|
||||||
useEffect(() => {
|
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(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
@@ -50,10 +53,11 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
if (entry.isIntersecting && !isInQueue) {
|
if (entry.isIntersecting && !isInQueue) {
|
||||||
setIsInQueue(true)
|
setIsInQueue(true)
|
||||||
avatarLoadQueue.enqueue(src).then(() => {
|
avatarLoadQueue.enqueue(src).then(() => {
|
||||||
|
setImageError(false)
|
||||||
setShouldLoad(true)
|
setShouldLoad(true)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// 加载失败不要立刻显示错误,让浏览器渲染去报错
|
setImageError(true)
|
||||||
setShouldLoad(true)
|
setShouldLoad(false)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
setIsInQueue(false)
|
setIsInQueue(false)
|
||||||
})
|
})
|
||||||
@@ -67,14 +71,18 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
observer.observe(containerRef.current)
|
observer.observe(containerRef.current)
|
||||||
|
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [src, lazy, shouldLoad, isInQueue, isCached])
|
}, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed])
|
||||||
|
|
||||||
// Reset state when src changes
|
// Reset state when src changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cached = src ? loadedAvatarCache.has(src) : false
|
const cached = src ? loadedAvatarCache.has(src) : false
|
||||||
|
const failed = src ? avatarLoadQueue.hasFailed(src) : false
|
||||||
setImageLoaded(cached)
|
setImageLoaded(cached)
|
||||||
setImageError(false)
|
setImageError(failed)
|
||||||
if (lazy && !cached) {
|
if (failed) {
|
||||||
|
setShouldLoad(false)
|
||||||
|
setIsInQueue(false)
|
||||||
|
} else if (lazy && !cached) {
|
||||||
setShouldLoad(false)
|
setShouldLoad(false)
|
||||||
setIsInQueue(false)
|
setIsInQueue(false)
|
||||||
} else {
|
} else {
|
||||||
@@ -95,6 +103,7 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasValidUrl = !!src && !imageError && shouldLoad
|
const hasValidUrl = !!src && !imageError && shouldLoad
|
||||||
|
const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -112,13 +121,30 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
alt={name || 'avatar'}
|
alt={name || 'avatar'}
|
||||||
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
if (src) loadedAvatarCache.add(src)
|
if (src) {
|
||||||
|
avatarLoadQueue.clearFailed(src)
|
||||||
|
loadedAvatarCache.add(src)
|
||||||
|
}
|
||||||
setImageLoaded(true)
|
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"}
|
loading={lazy ? "lazy" : "eager"}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : shouldShowLoadingPlaceholder ? (
|
||||||
|
<div className="avatar-loading">
|
||||||
|
<Loader2 size="50%" className="avatar-loading-icon" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="avatar-placeholder">
|
<div className="avatar-placeholder">
|
||||||
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
||||||
|
|||||||
@@ -34,6 +34,88 @@ const SYSTEM_MESSAGE_TYPES = [
|
|||||||
266287972401, // 拍一拍
|
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 {
|
interface XmlField {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -668,15 +750,19 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
// 会话内搜索
|
// 会话内搜索
|
||||||
const [showInSessionSearch, setShowInSessionSearch] = useState(false)
|
const [showInSessionSearch, setShowInSessionSearch] = useState(false)
|
||||||
const [inSessionQuery, setInSessionQuery] = useState('')
|
const [inSessionQuery, setInSessionQuery] = useState('')
|
||||||
const [inSessionResults, setInSessionResults] = useState<any[]>([])
|
const [inSessionResults, setInSessionResults] = useState<Message[]>([])
|
||||||
const [inSessionSearching, setInSessionSearching] = useState(false)
|
const [inSessionSearching, setInSessionSearching] = useState(false)
|
||||||
|
const [inSessionEnriching, setInSessionEnriching] = useState(false)
|
||||||
|
const [inSessionSearchError, setInSessionSearchError] = useState<string | null>(null)
|
||||||
const inSessionSearchRef = useRef<HTMLInputElement>(null)
|
const inSessionSearchRef = useRef<HTMLInputElement>(null)
|
||||||
// 全局消息搜索
|
// 全局消息搜索
|
||||||
const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false)
|
const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false)
|
||||||
const [globalMsgQuery, setGlobalMsgQuery] = useState('')
|
const [globalMsgQuery, setGlobalMsgQuery] = useState('')
|
||||||
const [globalMsgResults, setGlobalMsgResults] = useState<Message[]>([])
|
const [globalMsgResults, setGlobalMsgResults] = useState<Array<Message & { sessionId: string }>>([])
|
||||||
const [globalMsgSearching, setGlobalMsgSearching] = useState(false)
|
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<{
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||||
@@ -2574,51 +2660,19 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setIsSessionSwitching(false)
|
setIsSessionSwitching(false)
|
||||||
|
|
||||||
// 处理从全局搜索跳转过来的情况
|
// 处理从全局搜索跳转过来的情况
|
||||||
if (pendingInSessionSearchRef.current) {
|
const pendingSearch = pendingInSessionSearchRef.current
|
||||||
const { keyword, firstMsgTime, results } = pendingInSessionSearchRef.current
|
if (pendingSearch?.sessionId === sessionId) {
|
||||||
pendingInSessionSearchRef.current = null
|
pendingInSessionSearchRef.current = null
|
||||||
|
void applyPendingInSessionSearch(sessionId, pendingSearch, options.switchRequestSeq)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleJumpDateSelect = useCallback((date: Date) => {
|
const handleJumpDateSelect = useCallback((date: Date, options: { sessionId?: string; switchRequestSeq?: number } = {}) => {
|
||||||
if (!currentSessionId) return
|
const targetSessionId = String(options.sessionId || currentSessionRef.current || currentSessionId || '').trim()
|
||||||
|
if (!targetSessionId) return
|
||||||
const targetDate = new Date(date)
|
const targetDate = new Date(date)
|
||||||
const end = Math.floor(targetDate.setHours(23, 59, 59, 999) / 1000)
|
const end = Math.floor(targetDate.setHours(23, 59, 59, 999) / 1000)
|
||||||
// 日期跳转采用“锚点定位”而非“当天过滤”:
|
// 日期跳转采用“锚点定位”而非“当天过滤”:
|
||||||
@@ -2628,9 +2682,377 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setJumpStartTime(0)
|
setJumpStartTime(0)
|
||||||
setJumpEndTime(end)
|
setJumpEndTime(end)
|
||||||
setShowJumpPopover(false)
|
setShowJumpPopover(false)
|
||||||
void loadMessages(currentSessionId, 0, 0, end, false)
|
void loadMessages(targetSessionId, 0, 0, end, false, {
|
||||||
|
switchRequestSeq: options.switchRequestSeq
|
||||||
|
})
|
||||||
}, [currentSessionId, loadMessages])
|
}, [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 () => {
|
const loadLaterMessages = useCallback(async () => {
|
||||||
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
|
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
|
||||||
@@ -2696,12 +3118,19 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return
|
if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return
|
||||||
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
|
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
|
||||||
sessionSwitchRequestSeqRef.current = switchRequestSeq
|
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)
|
setShowInSessionSearch(false)
|
||||||
setInSessionQuery('')
|
setInSessionQuery('')
|
||||||
setInSessionResults([])
|
setInSessionResults([])
|
||||||
|
setInSessionSearchError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentSession(normalizedSessionId, { preserveMessages: false })
|
setCurrentSession(normalizedSessionId, { preserveMessages: false })
|
||||||
@@ -2714,41 +3143,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setIsSessionSwitching(false)
|
setIsSessionSwitching(false)
|
||||||
|
|
||||||
// 处理从全局搜索跳转过来的情况
|
// 处理从全局搜索跳转过来的情况
|
||||||
if (pendingInSessionSearchRef.current) {
|
if (pendingSearch?.sessionId === normalizedSessionId) {
|
||||||
const { keyword, firstMsgTime, results } = pendingInSessionSearchRef.current
|
|
||||||
pendingInSessionSearchRef.current = null
|
pendingInSessionSearchRef.current = null
|
||||||
|
void applyPendingInSessionSearch(normalizedSessionId, pendingSearch, switchRequestSeq)
|
||||||
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 refreshSessionIncrementally(normalizedSessionId, switchRequestSeq)
|
void refreshSessionIncrementally(normalizedSessionId, switchRequestSeq)
|
||||||
@@ -2786,7 +3183,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
restoreSessionWindowCache,
|
restoreSessionWindowCache,
|
||||||
refreshSessionIncrementally,
|
refreshSessionIncrementally,
|
||||||
hydrateSessionPreview,
|
hydrateSessionPreview,
|
||||||
loadMessages
|
loadMessages,
|
||||||
|
cancelInSessionSearchTasks,
|
||||||
|
applyPendingInSessionSearch
|
||||||
])
|
])
|
||||||
|
|
||||||
// 选择会话
|
// 选择会话
|
||||||
@@ -2815,12 +3214,16 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const handleInSessionSearch = useCallback(async (keyword: string) => {
|
const handleInSessionSearch = useCallback(async (keyword: string) => {
|
||||||
setInSessionQuery(keyword)
|
setInSessionQuery(keyword)
|
||||||
if (inSessionSearchTimerRef.current) clearTimeout(inSessionSearchTimerRef.current)
|
if (inSessionSearchTimerRef.current) clearTimeout(inSessionSearchTimerRef.current)
|
||||||
|
inSessionSearchTimerRef.current = null
|
||||||
inSessionSearchGenRef.current += 1
|
inSessionSearchGenRef.current += 1
|
||||||
if (!keyword.trim() || !currentSessionId) {
|
if (!keyword.trim() || !currentSessionId) {
|
||||||
setInSessionResults([])
|
setInSessionResults([])
|
||||||
|
setInSessionSearchError(null)
|
||||||
setInSessionSearching(false)
|
setInSessionSearching(false)
|
||||||
|
setInSessionEnriching(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setInSessionSearchError(null)
|
||||||
const gen = inSessionSearchGenRef.current
|
const gen = inSessionSearchGenRef.current
|
||||||
const sid = currentSessionId
|
const sid = currentSessionId
|
||||||
inSessionSearchTimerRef.current = setTimeout(async () => {
|
inSessionSearchTimerRef.current = setTimeout(async () => {
|
||||||
@@ -2828,89 +3231,127 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setInSessionSearching(true)
|
setInSessionSearching(true)
|
||||||
try {
|
try {
|
||||||
const res = await window.electronAPI.chat.searchMessages(keyword.trim(), sid, 50, 0)
|
const res = await window.electronAPI.chat.searchMessages(keyword.trim(), sid, 50, 0)
|
||||||
if (gen !== inSessionSearchGenRef.current) return
|
if (!res?.success) {
|
||||||
const messages = res?.messages || []
|
throw new Error(res?.error || '搜索失败')
|
||||||
|
}
|
||||||
|
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
||||||
|
const messages = hydrateInSessionSearchResults(res?.messages || [], sid)
|
||||||
|
setInSessionResults(messages)
|
||||||
|
setInSessionSearchError(null)
|
||||||
|
|
||||||
// 补充联系人信息
|
setInSessionEnriching(true)
|
||||||
const enriched = await Promise.all(
|
void enrichMessagesWithSenderProfiles(messages, sid).then((enriched) => {
|
||||||
messages.map(async (msg: any) => {
|
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
||||||
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)
|
setInSessionResults(enriched)
|
||||||
} catch {
|
}).catch((error) => {
|
||||||
if (gen !== inSessionSearchGenRef.current) return
|
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([])
|
setInSessionResults([])
|
||||||
|
setInSessionSearchError(error instanceof Error ? error.message : String(error))
|
||||||
|
setInSessionEnriching(false)
|
||||||
} finally {
|
} finally {
|
||||||
if (gen === inSessionSearchGenRef.current) setInSessionSearching(false)
|
if (gen === inSessionSearchGenRef.current) setInSessionSearching(false)
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
}, [currentSessionId])
|
}, [currentSessionId, enrichMessagesWithSenderProfiles, hydrateInSessionSearchResults])
|
||||||
|
|
||||||
const handleToggleInSessionSearch = useCallback(() => {
|
const handleToggleInSessionSearch = useCallback(() => {
|
||||||
setShowInSessionSearch(v => {
|
setShowInSessionSearch(v => {
|
||||||
if (v) {
|
if (v) {
|
||||||
inSessionSearchGenRef.current += 1
|
cancelInSessionSearchTasks()
|
||||||
if (inSessionSearchTimerRef.current) clearTimeout(inSessionSearchTimerRef.current)
|
|
||||||
setInSessionQuery('')
|
setInSessionQuery('')
|
||||||
setInSessionResults([])
|
setInSessionResults([])
|
||||||
setInSessionSearching(false)
|
setInSessionSearchError(null)
|
||||||
} else {
|
} else {
|
||||||
setTimeout(() => inSessionSearchRef.current?.focus(), 50)
|
setTimeout(() => inSessionSearchRef.current?.focus(), 50)
|
||||||
}
|
}
|
||||||
return !v
|
return !v
|
||||||
})
|
})
|
||||||
}, [])
|
}, [cancelInSessionSearchTasks])
|
||||||
|
|
||||||
// 全局消息搜索
|
// 全局消息搜索
|
||||||
const globalMsgSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const globalMsgSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const globalMsgSearchGenRef = useRef(0)
|
const globalMsgSearchGenRef = useRef(0)
|
||||||
const handleGlobalMsgSearch = useCallback(async (keyword: string) => {
|
const handleGlobalMsgSearch = useCallback(async (keyword: string) => {
|
||||||
|
const normalizedKeyword = keyword.trim()
|
||||||
setGlobalMsgQuery(keyword)
|
setGlobalMsgQuery(keyword)
|
||||||
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
||||||
|
globalMsgSearchTimerRef.current = null
|
||||||
globalMsgSearchGenRef.current += 1
|
globalMsgSearchGenRef.current += 1
|
||||||
if (!keyword.trim()) {
|
if (!normalizedKeyword) {
|
||||||
|
pendingGlobalMsgSearchReplayRef.current = null
|
||||||
setGlobalMsgResults([])
|
setGlobalMsgResults([])
|
||||||
|
setGlobalMsgSearchError(null)
|
||||||
setShowGlobalMsgSearch(false)
|
setShowGlobalMsgSearch(false)
|
||||||
setGlobalMsgSearching(false)
|
setGlobalMsgSearching(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setShowGlobalMsgSearch(true)
|
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
|
const gen = globalMsgSearchGenRef.current
|
||||||
globalMsgSearchTimerRef.current = setTimeout(async () => {
|
globalMsgSearchTimerRef.current = setTimeout(async () => {
|
||||||
if (gen !== globalMsgSearchGenRef.current) return
|
if (gen !== globalMsgSearchGenRef.current) return
|
||||||
setGlobalMsgSearching(true)
|
setGlobalMsgSearching(true)
|
||||||
try {
|
try {
|
||||||
const results: Array<Message & { sessionId: string }> = []
|
const results: Array<Message & { sessionId: string }> = []
|
||||||
for (const session of sessions) {
|
const concurrency = 6
|
||||||
const res = await window.electronAPI.chat.searchMessages(keyword.trim(), session.username, 10, 0)
|
|
||||||
if (res?.messages) {
|
for (let index = 0; index < sessionList.length; index += concurrency) {
|
||||||
results.push(...res.messages.map(msg => ({ ...msg, sessionId: session.username })))
|
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
|
if (gen !== globalMsgSearchGenRef.current) return
|
||||||
setGlobalMsgResults(results as any)
|
|
||||||
} catch {
|
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)
|
||||||
|
setGlobalMsgSearchError(null)
|
||||||
|
} catch (error) {
|
||||||
if (gen !== globalMsgSearchGenRef.current) return
|
if (gen !== globalMsgSearchGenRef.current) return
|
||||||
setGlobalMsgResults([])
|
setGlobalMsgResults([])
|
||||||
|
setGlobalMsgSearchError(error instanceof Error ? error.message : String(error))
|
||||||
} finally {
|
} finally {
|
||||||
if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false)
|
if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false)
|
||||||
setGlobalMsgSearching(false)
|
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -2918,11 +3359,13 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const handleCloseGlobalMsgSearch = useCallback(() => {
|
const handleCloseGlobalMsgSearch = useCallback(() => {
|
||||||
globalMsgSearchGenRef.current += 1
|
globalMsgSearchGenRef.current += 1
|
||||||
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
||||||
|
globalMsgSearchTimerRef.current = null
|
||||||
|
pendingGlobalMsgSearchReplayRef.current = null
|
||||||
setShowGlobalMsgSearch(false)
|
setShowGlobalMsgSearch(false)
|
||||||
setGlobalMsgQuery('')
|
setGlobalMsgQuery('')
|
||||||
setGlobalMsgResults([])
|
setGlobalMsgResults([])
|
||||||
|
setGlobalMsgSearchError(null)
|
||||||
setGlobalMsgSearching(false)
|
setGlobalMsgSearching(false)
|
||||||
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
|
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
|
||||||
@@ -3205,6 +3648,28 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
isConnectedRef.current = isConnected
|
isConnectedRef.current = isConnected
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
searchKeywordRef.current = searchKeyword
|
searchKeywordRef.current = searchKeyword
|
||||||
}, [searchKeyword])
|
}, [searchKeyword])
|
||||||
@@ -4284,6 +4749,11 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<Loader2 className="spin" size={20} />
|
<Loader2 className="spin" size={20} />
|
||||||
<span>搜索中...</span>
|
<span>搜索中...</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : globalMsgSearchError ? (
|
||||||
|
<div className="no-results">
|
||||||
|
<AlertCircle size={32} />
|
||||||
|
<p>{globalMsgSearchError}</p>
|
||||||
|
</div>
|
||||||
) : globalMsgResults.length > 0 ? (
|
) : globalMsgResults.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="search-section-header">聊天记录:</div>
|
<div className="search-section-header">聊天记录:</div>
|
||||||
@@ -4306,11 +4776,12 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (session) {
|
if (session) {
|
||||||
pendingInSessionSearchRef.current = {
|
pendingInSessionSearchRef.current = {
|
||||||
|
sessionId,
|
||||||
keyword: globalMsgQuery,
|
keyword: globalMsgQuery,
|
||||||
firstMsgTime: firstMsg.createTime || 0,
|
firstMsgTime: firstMsg.createTime || 0,
|
||||||
results: messages
|
results: messages
|
||||||
};
|
}
|
||||||
handleSelectSession(session);
|
handleSelectSession(session)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -4626,40 +5097,74 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
{inSessionQuery && (
|
{inSessionQuery && (
|
||||||
<div className="search-result-header">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{inSessionResults.length > 0 && (
|
{inSessionResults.length > 0 && (
|
||||||
<div className="in-session-results">
|
<div className="in-session-results">
|
||||||
{inSessionResults.map((msg, i) => {
|
{inSessionResults.map((msg, i) => {
|
||||||
const msgData = msg as any;
|
const resolvedSenderDisplayName = resolveSearchSenderDisplayName(
|
||||||
const senderName = msgData.senderDisplayName || msgData.senderUsername || '未知';
|
msg.senderDisplayName,
|
||||||
const senderAvatar = msgData.senderAvatarUrl;
|
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 (
|
return (
|
||||||
<div key={i} className="result-item" onClick={() => {
|
<div key={i} className="result-item" onClick={() => {
|
||||||
const ts = msg.createTime || msgData.create_time;
|
const ts = msg.createTime
|
||||||
if (ts && currentSessionId) {
|
if (ts && currentSessionId) {
|
||||||
setCurrentOffset(0);
|
setCurrentOffset(0)
|
||||||
setJumpStartTime(0);
|
setJumpStartTime(0)
|
||||||
setJumpEndTime(0);
|
setJumpEndTime(0)
|
||||||
void loadMessages(currentSessionId, 0, ts - 1, ts + 1, false);
|
void loadMessages(currentSessionId, 0, ts - 1, ts + 1, false)
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<div className="result-header">
|
<div className="result-header">
|
||||||
<Avatar src={senderAvatar} name={senderName} size={32} />
|
<Avatar src={senderAvatar} name={senderName} size={32} loading={senderAvatarLoading} />
|
||||||
</div>
|
</div>
|
||||||
<div className="result-content">
|
<div className="result-content">
|
||||||
<span className="result-sender">{senderName}</span>
|
<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>
|
</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>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{inSessionQuery && !inSessionSearching && inSessionResults.length === 0 && (
|
{inSessionQuery && !inSessionSearching && !inSessionSearchError && inSessionResults.length === 0 && (
|
||||||
<div className="no-results">
|
<div className="no-results">
|
||||||
<MessageSquare size={32} />
|
<MessageSquare size={32} />
|
||||||
<p>未找到相关消息</p>
|
<p>未找到相关消息</p>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
export class AvatarLoadQueue {
|
export class AvatarLoadQueue {
|
||||||
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
|
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
|
||||||
private loading = new Map<string, Promise<void>>()
|
private loading = new Map<string, Promise<void>>()
|
||||||
|
private failed = new Map<string, number>()
|
||||||
private activeCount = 0
|
private activeCount = 0
|
||||||
private readonly maxConcurrent = 3
|
private readonly maxConcurrent = 3
|
||||||
private readonly delayBetweenBatches = 10
|
private readonly delayBetweenBatches = 10
|
||||||
|
private readonly failedTtlMs = 10 * 60 * 1000
|
||||||
|
|
||||||
private static instance: AvatarLoadQueue
|
private static instance: AvatarLoadQueue
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ export class AvatarLoadQueue {
|
|||||||
|
|
||||||
async enqueue(url: string): Promise<void> {
|
async enqueue(url: string): Promise<void> {
|
||||||
if (!url) return Promise.resolve()
|
if (!url) return Promise.resolve()
|
||||||
|
if (this.hasFailed(url)) {
|
||||||
|
return Promise.reject(new Error(`Failed: ${url}`))
|
||||||
|
}
|
||||||
|
|
||||||
// 核心修复:防止重复并发请求同一个 URL
|
// 核心修复:防止重复并发请求同一个 URL
|
||||||
const existingPromise = this.loading.get(url)
|
const existingPromise = this.loading.get(url)
|
||||||
@@ -31,13 +36,40 @@ export class AvatarLoadQueue {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.loading.set(url, loadPromise)
|
this.loading.set(url, loadPromise)
|
||||||
loadPromise.finally(() => {
|
void loadPromise.then(
|
||||||
|
() => {
|
||||||
this.loading.delete(url)
|
this.loading.delete(url)
|
||||||
})
|
this.clearFailed(url)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.loading.delete(url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return loadPromise
|
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() {
|
private async processQueue() {
|
||||||
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
|
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
|
||||||
return
|
return
|
||||||
@@ -49,13 +81,16 @@ export class AvatarLoadQueue {
|
|||||||
this.activeCount++
|
this.activeCount++
|
||||||
|
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
|
img.referrerPolicy = 'no-referrer'
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
this.activeCount--
|
this.activeCount--
|
||||||
|
this.clearFailed(task.url)
|
||||||
task.resolve()
|
task.resolve()
|
||||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||||
}
|
}
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
this.activeCount--
|
this.activeCount--
|
||||||
|
this.markFailed(task.url)
|
||||||
task.reject(new Error(`Failed: ${task.url}`))
|
task.reject(new Error(`Failed: ${task.url}`))
|
||||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||||
}
|
}
|
||||||
@@ -67,6 +102,7 @@ export class AvatarLoadQueue {
|
|||||||
clear() {
|
clear() {
|
||||||
this.queue = []
|
this.queue = []
|
||||||
this.loading.clear()
|
this.loading.clear()
|
||||||
|
this.failed.clear()
|
||||||
this.activeCount = 0
|
this.activeCount = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user