import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { useShallow } from 'zustand/react/shallow' import { useChatStore } from '../stores/chatStore' import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore' import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' import type { ChatRecordItem, ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { LivePhotoIcon } from '../components/LivePhotoIcon' import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import JumpToDatePopover from '../components/JumpToDatePopover' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline' import * as configService from '../services/config' import { finishBackgroundTask, isBackgroundTaskCancelRequested, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' import { emitOpenSingleExport, onExportSessionStatus, onSingleExportDialogStatus, requestExportSessionStatus } from '../services/exportBridge' import './ChatPage.scss' // 系统消息类型常量 const SYSTEM_MESSAGE_TYPES = [ 10000, // 系统消息 266287972401, // 拍一拍 ] interface PendingInSessionSearchPayload { sessionId: string keyword: string firstMsgTime: number results: Message[] } type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done' type GlobalMsgSearchResult = Message & { sessionId: string } interface GlobalMsgPrefixCacheEntry { keyword: string matchedSessionIds: Set completed: boolean } const GLOBAL_MSG_PER_SESSION_LIMIT = 10 const GLOBAL_MSG_SEED_LIMIT = 120 const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3 const GLOBAL_MSG_LEGACY_CONCURRENCY = 6 const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__' const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2 const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare' function isGlobalMsgSearchCanceled(error: unknown): boolean { return String(error || '') === GLOBAL_MSG_SEARCH_CANCELED_ERROR } function normalizeGlobalMsgSearchSessionId(value: unknown): string | null { const sessionId = String(value || '').trim() if (!sessionId) return null return sessionId } function normalizeGlobalMsgSearchMessages( messages: Message[] | undefined, fallbackSessionId?: string ): GlobalMsgSearchResult[] { if (!Array.isArray(messages) || messages.length === 0) return [] const dedup = new Set() const normalized: GlobalMsgSearchResult[] = [] const normalizedFallback = normalizeGlobalMsgSearchSessionId(fallbackSessionId) for (const message of messages) { const raw = message as Message & { sessionId?: string; _session_id?: string } const sessionId = normalizeGlobalMsgSearchSessionId(raw.sessionId || raw._session_id || normalizedFallback) if (!sessionId) continue const uniqueKey = raw.localId > 0 ? `${sessionId}::local:${raw.localId}` : `${sessionId}::key:${raw.messageKey || ''}:${raw.createTime || 0}` if (dedup.has(uniqueKey)) continue dedup.add(uniqueKey) normalized.push({ ...message, sessionId }) } return normalized } function buildGlobalMsgSearchSessionMap(messages: GlobalMsgSearchResult[]): Map { const map = new Map() for (const message of messages) { if (!message.sessionId) continue const list = map.get(message.sessionId) || [] if (list.length >= GLOBAL_MSG_PER_SESSION_LIMIT) continue list.push(message) map.set(message.sessionId, list) } return map } function flattenGlobalMsgSearchSessionMap(map: Map): GlobalMsgSearchResult[] { const all: GlobalMsgSearchResult[] = [] for (const list of map.values()) { if (list.length > 0) all.push(...list) } return sortMessagesByCreateTimeDesc(all) } function normalizeChatRecordText(value?: string): string { return String(value || '') .replace(/\u00a0/g, ' ') .replace(/\s+/g, ' ') .trim() } function hasRenderableChatRecordName(value?: string): boolean { return value !== undefined && value !== null && String(value).length > 0 } function getChatRecordPreviewText(item: ChatRecordItem): string { const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle) if (item.datatype === 17) { return normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录' } if (item.datatype === 2 || item.datatype === 3) return '[媒体消息]' if (item.datatype === 43) return '[视频]' if (item.datatype === 34) return '[语音]' if (item.datatype === 47) return '[表情]' return text || '[媒体消息]' } function buildChatRecordPreviewItems(recordList: ChatRecordItem[], maxVisible = 3): ChatRecordItem[] { if (recordList.length <= maxVisible) return recordList.slice(0, maxVisible) const firstNestedIndex = recordList.findIndex(item => item.datatype === 17) if (firstNestedIndex < 0 || firstNestedIndex < maxVisible) { return recordList.slice(0, maxVisible) } if (maxVisible <= 1) { return [recordList[firstNestedIndex]] } return [ ...recordList.slice(0, maxVisible - 1), recordList[firstNestedIndex] ] } function composeGlobalMsgSearchResults( seedMap: Map, authoritativeMap: Map ): GlobalMsgSearchResult[] { const merged = new Map() for (const [sessionId, seedRows] of seedMap.entries()) { if (authoritativeMap.has(sessionId)) { merged.set(sessionId, authoritativeMap.get(sessionId) || []) } else { merged.set(sessionId, seedRows) } } for (const [sessionId, rows] of authoritativeMap.entries()) { if (!merged.has(sessionId)) merged.set(sessionId, rows) } return flattenGlobalMsgSearchSessionMap(merged) } function shouldRunGlobalMsgShadowCompareSample(): boolean { if (!import.meta.env.DEV) return false try { const forced = window.localStorage.getItem(GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY) if (forced === '1') return true if (forced === '0') return false } catch { // ignore storage read failures } return Math.random() < GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE } function buildGlobalMsgSearchSessionLocalIds(results: GlobalMsgSearchResult[]): Record { const grouped = new Map() for (const row of results) { if (!row.sessionId || row.localId <= 0) continue const list = grouped.get(row.sessionId) || [] list.push(row.localId) grouped.set(row.sessionId, list) } const output: Record = {} for (const [sessionId, localIds] of grouped.entries()) { output[sessionId] = localIds } return output } function sortMessagesByCreateTimeDesc>(items: T[]): T[] { return [...items].sort((a, b) => { const timeDiff = (b.createTime || 0) - (a.createTime || 0) if (timeDiff !== 0) return timeDiff return (b.localId || 0) - (a.localId || 0) }) } function normalizeSearchIdentityText(value?: string | null): string | undefined { const normalized = String(value || '').trim() if (!normalized) return undefined const lower = normalized.toLowerCase() if (normalized === '未知' || lower === 'unknown' || lower === 'null' || lower === 'undefined') { return undefined } if (lower.startsWith('unknown_sender_')) { return undefined } return normalized } function normalizeSearchAvatarUrl(value?: string | null): string | undefined { const normalized = String(value || '').trim() if (!normalized) return undefined const lower = normalized.toLowerCase() if (lower === 'null' || lower === 'undefined') { return undefined } return normalized } function resolveSessionDisplayName( displayName?: string | null, sessionId?: string | null ): string | undefined { const normalizedSessionId = String(sessionId || '').trim() const normalizedDisplayName = normalizeSearchIdentityText(displayName) if (!normalizedDisplayName) return undefined if (normalizedSessionId && normalizedDisplayName === normalizedSessionId) return undefined return normalizedDisplayName } function isFoldPlaceholderSession(sessionId?: string | null): boolean { return String(sessionId || '').toLowerCase().includes('placeholder_foldgroup') } 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 } function buildSearchIdentityCandidates(value?: string | null): string[] { const normalized = normalizeSearchIdentityText(value) if (!normalized) return [] const lower = normalized.toLowerCase() const candidates = new Set([lower]) if (lower.startsWith('wxid_')) { const match = lower.match(/^(wxid_[^_]+)/i) if (match?.[1]) { candidates.add(match[1]) } } return [...candidates] } function isCurrentUserSearchIdentity( senderUsername?: string | null, myWxid?: string | null ): boolean { const senderCandidates = buildSearchIdentityCandidates(senderUsername) const selfCandidates = buildSearchIdentityCandidates(myWxid) if (senderCandidates.length === 0 || selfCandidates.length === 0) { return false } for (const sender of senderCandidates) { for (const self of selfCandidates) { if (sender === self) return true if (sender.startsWith(self + '_')) return true if (self.startsWith(sender + '_')) return true } } return false } interface XmlField { key: string; value: string; type: 'attr' | 'node'; tagName?: string; path: string; } interface BatchImageDecryptCandidate { imageMd5?: string imageDatName?: string createTime?: number } // 尝试解析 XML 为可编辑字段 function parseXmlToFields(xml: string): XmlField[] { const fields: XmlField[] = [] if (!xml || !xml.includes('<')) return [] try { const parser = new DOMParser() // 包装一下确保是单一根节点 const wrappedXml = xml.trim().startsWith('${xml}` const doc = parser.parseFromString(wrappedXml, 'text/xml') const errorNode = doc.querySelector('parsererror') if (errorNode) return [] const walk = (node: Node, path: string = '') => { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element if (element.tagName === 'root') { node.childNodes.forEach((child, index) => walk(child, path)) return } const currentPath = path ? `${path} > ${element.tagName}` : element.tagName for (let i = 0; i < element.attributes.length; i++) { const attr = element.attributes[i] fields.push({ key: attr.name, value: attr.value, type: 'attr', tagName: element.tagName, path: `${currentPath}[@${attr.name}]` }) } if (element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE) { const text = element.textContent?.trim() || '' if (text) { fields.push({ key: element.tagName, value: text, type: 'node', path: currentPath }) } } else { node.childNodes.forEach((child, index) => walk(child, `${currentPath}[${index}]`)) } } } doc.childNodes.forEach((node, index) => walk(node, '')) } catch (e) { console.warn('[XML Parse] Failed:', e) } return fields } // 将编辑后的字段同步回 XML function updateXmlWithFields(xml: string, fields: XmlField[]): string { try { const parser = new DOMParser() const wrappedXml = xml.trim().startsWith('${xml}` const doc = parser.parseFromString(wrappedXml, 'text/xml') const errorNode = doc.querySelector('parsererror') if (errorNode) return xml fields.forEach(f => { if (f.type === 'attr') { const elements = doc.getElementsByTagName(f.tagName!) if (elements.length > 0) { elements[0].setAttribute(f.key, f.value) } } else { const elements = doc.getElementsByTagName(f.key) if (elements.length > 0 && (elements[0].childNodes.length <= 1)) { elements[0].textContent = f.value } } }) let result = new XMLSerializer().serializeToString(doc) if (!xml.trim().startsWith('', '').replace('', '').replace('', '') } return result } catch (e) { return xml } } // 判断是否为系统消息 function isSystemMessage(localType: number): boolean { return SYSTEM_MESSAGE_TYPES.includes(localType) } // 格式化文件大小 function formatFileSize(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] } // 清理消息内容的辅助函数 function cleanMessageContent(content: string): string { if (!content) return '' return content.trim() } const CHAT_SESSION_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000 const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000 const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30 const CHAT_SESSION_PREVIEW_MAX_SESSIONS = 18 const CHAT_SESSION_WINDOW_CACHE_TTL_MS = 12 * 60 * 60 * 1000 const CHAT_SESSION_WINDOW_CACHE_MAX_SESSIONS = 30 const CHAT_SESSION_WINDOW_CACHE_MAX_MESSAGES = 300 const GROUP_MEMBERS_PANEL_CACHE_TTL_MS = 10 * 60 * 1000 const SESSION_CONTACT_PROFILE_RETRY_INTERVAL_MS = 15 * 1000 const SESSION_CONTACT_PROFILE_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000 function buildChatSessionListCacheKey(scope: string): string { return `weflow.chat.sessions.v1::${scope || 'default'}` } function buildChatSessionPreviewCacheKey(scope: string): string { return `weflow.chat.preview.v1::${scope || 'default'}` } function normalizeChatCacheScope(dbPath: unknown, wxid: unknown): string { const db = String(dbPath || '').trim() const id = String(wxid || '').trim() if (!db && !id) return 'default' return `${db}::${id}` } function safeParseJson(raw: string | null): T | null { if (!raw) return null try { return JSON.parse(raw) as T } catch { return null } } function formatYmdDateFromSeconds(timestamp?: number): string { if (!timestamp || !Number.isFinite(timestamp)) return '—' const d = new Date(timestamp * 1000) const y = d.getFullYear() const m = `${d.getMonth() + 1}`.padStart(2, '0') const day = `${d.getDate()}`.padStart(2, '0') return `${y}-${m}-${day}` } function formatYmdHmDateTime(timestamp?: number): string { if (!timestamp || !Number.isFinite(timestamp)) return '—' const d = new Date(timestamp) const y = d.getFullYear() const m = `${d.getMonth() + 1}`.padStart(2, '0') const day = `${d.getDate()}`.padStart(2, '0') const h = `${d.getHours()}`.padStart(2, '0') const min = `${d.getMinutes()}`.padStart(2, '0') return `${y}-${m}-${day} ${h}:${min}` } interface ChatPageProps { standaloneSessionWindow?: boolean initialSessionId?: string | null standaloneSource?: string | null standaloneInitialDisplayName?: string | null standaloneInitialAvatarUrl?: string | null standaloneInitialContactType?: string | null } type StandaloneLoadStage = 'idle' | 'connecting' | 'loading' | 'ready' interface SessionDetail { wxid: string displayName: string remark?: string nickName?: string alias?: string avatarUrl?: string messageCount: number voiceMessages?: number imageMessages?: number videoMessages?: number emojiMessages?: number transferMessages?: number redPacketMessages?: number callMessages?: number privateMutualGroups?: number groupMemberCount?: number groupMyMessages?: number groupActiveSpeakers?: number groupMutualFriends?: number relationStatsLoaded?: boolean statsUpdatedAt?: number statsStale?: boolean firstMessageTime?: number latestMessageTime?: number messageTables: { dbName: string; tableName: string; count: number }[] } interface SessionExportMetric { totalMessages: number voiceMessages: number imageMessages: number videoMessages: number emojiMessages: number transferMessages: number redPacketMessages: number callMessages: number firstTimestamp?: number lastTimestamp?: number privateMutualGroups?: number groupMemberCount?: number groupMyMessages?: number groupActiveSpeakers?: number groupMutualFriends?: number } interface SessionExportCacheMeta { updatedAt: number stale: boolean includeRelations: boolean source: 'memory' | 'disk' | 'fresh' } interface SessionContactProfile { displayName?: string avatarUrl?: string alias?: string updatedAt: number } type GroupMessageCountStatus = 'loading' | 'ready' | 'failed' interface GroupPanelMember { username: string displayName: string avatarUrl?: string nickname?: string alias?: string remark?: string groupNickname?: string isOwner?: boolean isFriend: boolean messageCount: number messageCountStatus: GroupMessageCountStatus } interface SessionListCachePayload { updatedAt: number sessions: ChatSession[] } interface SessionPreviewCacheEntry { updatedAt: number messages: Message[] } interface SessionPreviewCachePayload { updatedAt: number entries: Record } interface GroupMembersPanelCacheEntry { updatedAt: number members: GroupPanelMember[] includeMessageCounts: boolean } interface SessionWindowCacheEntry { updatedAt: number messages: Message[] currentOffset: number hasMoreMessages: boolean hasMoreLater: boolean jumpStartTime: number jumpEndTime: number } interface LoadMessagesOptions { preferLatestPath?: boolean deferGroupSenderWarmup?: boolean forceInitialLimit?: number switchRequestSeq?: number inSessionJumpRequestSeq?: number } // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts import { avatarLoadQueue } from '../utils/AvatarLoadQueue' import { Avatar } from '../components/Avatar' // 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染) // 高亮搜索关键词组件 const HighlightText = React.memo(({ text, keyword }: { text: string; keyword: string }) => { if (!keyword) return <>{text} const lowerText = text.toLowerCase() const lowerKeyword = keyword.toLowerCase() const matchIndex = lowerText.indexOf(lowerKeyword) if (matchIndex === -1) return <>{text} // 如果匹配位置在后面且文本过长,截断前面部分 const maxLength = 50 let displayText = text if (text.length > maxLength && matchIndex > 20) { const start = Math.max(0, matchIndex - 15) displayText = '...' + text.slice(start) } const parts = displayText.split(new RegExp(`(${keyword})`, 'gi')) return ( <> {parts.map((part, i) => part.toLowerCase() === lowerKeyword ? {part} : part )} ) }) const HighlightTextNoTruncate = React.memo(({ text, keyword }: { text: string; keyword: string }) => { if (!keyword) return <>{text} const lowerText = text.toLowerCase() const lowerKeyword = keyword.toLowerCase() const matchIndex = lowerText.indexOf(lowerKeyword) if (matchIndex === -1) return <>{text} const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const matchEnd = matchIndex + keyword.length const maxDisplayLength = 25 // 如果匹配位置不在开头,或文本过长,则居中显示 if (matchIndex > 5 || text.length > maxDisplayLength) { const start = Math.max(0, matchIndex - 8) const end = Math.min(text.length, matchEnd + 15) const prefix = start > 0 ? '...' : '' const suffix = end < text.length ? '...' : '' const middleText = text.slice(start, end) const parts = middleText.split(new RegExp(`(${escapedKeyword})`, 'gi')) return ( <> {prefix} {parts.map((part, i) => part.toLowerCase() === lowerKeyword ? {part} : part )} {suffix} ) } const parts = text.split(new RegExp(`(${escapedKeyword})`, 'gi')) return ( <> {parts.map((part, i) => part.toLowerCase() === lowerKeyword ? {part} : part )} ) }) // 会话项组件(使用 memo 优化,避免不必要的重渲染) const SessionItem = React.memo(function SessionItem({ session, isActive, onSelect, formatTime, searchKeyword }: { session: ChatSession isActive: boolean onSelect: (session: ChatSession) => void formatTime: (timestamp: number) => string searchKeyword?: string }) { const timeText = useMemo(() => formatTime(session.lastTimestamp || session.sortTimestamp), [formatTime, session.lastTimestamp, session.sortTimestamp] ) const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup') // 折叠入口:专属名称和图标 if (isFoldEntry) { return (
onSelect(session)} >
折叠的聊天 {timeText}
{session.summary || '暂无消息'}
) } // 根据匹配字段显示不同的 summary const summaryContent = useMemo(() => { if (session.matchedField === 'wxid') { return wxid: } else if (session.matchedField === 'alias' && session.alias) { return 微信号: } return {session.summary || '暂无消息'} }, [session.matchedField, session.username, session.alias, session.summary, searchKeyword]) return (
onSelect(session)} >
{(() => { const shouldHighlight = (session.matchedField as any) === 'name' && searchKeyword return shouldHighlight ? ( ) : ( session.displayName || session.username ) })()} {timeText}
{summaryContent}
{session.isMuted && } {session.unreadCount > 0 && ( {session.unreadCount > 99 ? '99+' : session.unreadCount} )}
) }, (prevProps, nextProps) => { return ( prevProps.session.username === nextProps.session.username && prevProps.session.displayName === nextProps.session.displayName && prevProps.session.avatarUrl === nextProps.session.avatarUrl && prevProps.session.summary === nextProps.session.summary && prevProps.session.matchedField === nextProps.session.matchedField && prevProps.session.alias === nextProps.session.alias && prevProps.session.unreadCount === nextProps.session.unreadCount && prevProps.session.lastTimestamp === nextProps.session.lastTimestamp && prevProps.session.sortTimestamp === nextProps.session.sortTimestamp && prevProps.session.isMuted === nextProps.session.isMuted && prevProps.isActive === nextProps.isActive && prevProps.searchKeyword === nextProps.searchKeyword ) }) function ChatPage(props: ChatPageProps) { const { standaloneSessionWindow = false, initialSessionId = null, standaloneSource = null, standaloneInitialDisplayName = null, standaloneInitialAvatarUrl = null, standaloneInitialContactType = null } = props const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId]) const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource]) const normalizedStandaloneInitialDisplayName = useMemo(() => String(standaloneInitialDisplayName || '').trim(), [standaloneInitialDisplayName]) const normalizedStandaloneInitialAvatarUrl = useMemo(() => String(standaloneInitialAvatarUrl || '').trim(), [standaloneInitialAvatarUrl]) const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType]) const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export' const navigate = useNavigate() const { isConnected, isConnecting, connectionError, sessions, currentSessionId, isLoadingSessions, messages, isLoadingMessages, isLoadingMore, hasMoreMessages, searchKeyword, setConnected, setConnecting, setConnectionError, setSessions, setCurrentSession, setLoadingSessions, setMessages, appendMessages, setLoadingMessages, setLoadingMore, setHasMoreMessages, hasMoreLater, setHasMoreLater, setSearchKeyword } = useChatStore(useShallow((state) => ({ isConnected: state.isConnected, isConnecting: state.isConnecting, connectionError: state.connectionError, sessions: state.sessions, currentSessionId: state.currentSessionId, isLoadingSessions: state.isLoadingSessions, messages: state.messages, isLoadingMessages: state.isLoadingMessages, isLoadingMore: state.isLoadingMore, hasMoreMessages: state.hasMoreMessages, searchKeyword: state.searchKeyword, setConnected: state.setConnected, setConnecting: state.setConnecting, setConnectionError: state.setConnectionError, setSessions: state.setSessions, setCurrentSession: state.setCurrentSession, setLoadingSessions: state.setLoadingSessions, setMessages: state.setMessages, appendMessages: state.appendMessages, setLoadingMessages: state.setLoadingMessages, setLoadingMore: state.setLoadingMore, setHasMoreMessages: state.setHasMoreMessages, hasMoreLater: state.hasMoreLater, setHasMoreLater: state.setHasMoreLater, setSearchKeyword: state.setSearchKeyword }))) const messageListRef = useRef(null) const [messageListScrollParent, setMessageListScrollParent] = useState(null) const messageVirtuosoRef = useRef(null) const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 }) const topRangeLoadLockRef = useRef(false) const bottomRangeLoadLockRef = useRef(false) const suppressAutoLoadLaterRef = useRef(false) const searchInputRef = useRef(null) const sidebarRef = useRef(null) const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => { messageListRef.current = node setMessageListScrollParent(node) }, []) const getMessageKey = useCallback((msg: Message): string => { if (msg.messageKey) return msg.messageKey return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}` }, []) const initialRevealTimerRef = useRef(null) const sessionListRef = useRef(null) const jumpCalendarWrapRef = useRef(null) const jumpPopoverPortalRef = useRef(null) const [currentOffset, setCurrentOffset] = useState(0) const [jumpStartTime, setJumpStartTime] = useState(0) const [jumpEndTime, setJumpEndTime] = useState(0) const [showJumpPopover, setShowJumpPopover] = useState(false) const [jumpPopoverDate, setJumpPopoverDate] = useState(new Date()) const [jumpPopoverPosition, setJumpPopoverPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }) const isDateJumpRef = useRef(false) const [messageDates, setMessageDates] = useState>(new Set()) const [hasLoadedMessageDates, setHasLoadedMessageDates] = useState(false) const [loadingDates, setLoadingDates] = useState(false) const messageDatesCache = useRef>>(new Map()) const [messageDateCounts, setMessageDateCounts] = useState>({}) const [loadingDateCounts, setLoadingDateCounts] = useState(false) const messageDateCountsCache = useRef>>(new Map()) const [myAvatarUrl, setMyAvatarUrl] = useState(undefined) const [myWxid, setMyWxid] = useState(undefined) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [sidebarWidth, setSidebarWidth] = useState(260) const [isResizing, setIsResizing] = useState(false) const [showDetailPanel, setShowDetailPanel] = useState(false) const [showGroupMembersPanel, setShowGroupMembersPanel] = useState(false) const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingDetail, setIsLoadingDetail] = useState(false) const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false) const [isRefreshingDetailStats, setIsRefreshingDetailStats] = useState(false) const [isLoadingRelationStats, setIsLoadingRelationStats] = useState(false) const [groupPanelMembers, setGroupPanelMembers] = useState([]) const [isLoadingGroupMembers, setIsLoadingGroupMembers] = useState(false) const [groupMembersError, setGroupMembersError] = useState(null) const [groupMembersLoadingHint, setGroupMembersLoadingHint] = useState('') const [isRefreshingGroupMembers, setIsRefreshingGroupMembers] = useState(false) const [groupMemberSearchKeyword, setGroupMemberSearchKeyword] = useState('') const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图 const [hasInitialMessages, setHasInitialMessages] = useState(false) const [isSessionSwitching, setIsSessionSwitching] = useState(false) const [noMessageTable, setNoMessageTable] = useState(false) const [fallbackDisplayName, setFallbackDisplayName] = useState(normalizedStandaloneInitialDisplayName || null) const [fallbackAvatarUrl, setFallbackAvatarUrl] = useState(normalizedStandaloneInitialAvatarUrl || null) const [standaloneLoadStage, setStandaloneLoadStage] = useState( standaloneSessionWindow && normalizedInitialSessionId ? 'connecting' : 'idle' ) const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [autoTranscribeVoiceEnabled, setAutoTranscribeVoiceEnabled] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState>(new Set()) const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false) const [chatSnsTimelineTarget, setChatSnsTimelineTarget] = useState(null) const [exportPrepareHint, setExportPrepareHint] = useState('') // 消息右键菜单 const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null) const [showMessageInfo, setShowMessageInfo] = useState(null) const [editingMessage, setEditingMessage] = useState<{ message: Message, content: string } | null>(null) // 多选模式 const [isSelectionMode, setIsSelectionMode] = useState(false) const [selectedMessages, setSelectedMessages] = useState>(new Set()) // 编辑消息额外状态 const [editMode, setEditMode] = useState<'raw' | 'fields'>('raw') const [tempFields, setTempFields] = useState([]) // 批量语音转文字相关状态(进度/结果 由全局 store 管理) const { isBatchTranscribing, runningBatchVoiceTaskType, batchTranscribeProgress, startTranscribe, updateProgress, finishTranscribe, setShowBatchProgress } = useBatchTranscribeStore(useShallow((state) => ({ isBatchTranscribing: state.isBatchTranscribing, runningBatchVoiceTaskType: state.taskType, batchTranscribeProgress: state.progress, startTranscribe: state.startTranscribe, updateProgress: state.updateProgress, finishTranscribe: state.finishTranscribe, setShowBatchProgress: state.setShowToast }))) const { isBatchDecrypting, batchDecryptProgress, startDecrypt, updateDecryptProgress, finishDecrypt, setShowBatchDecryptToast } = useBatchImageDecryptStore(useShallow((state) => ({ isBatchDecrypting: state.isBatchDecrypting, batchDecryptProgress: state.progress, startDecrypt: state.startDecrypt, updateDecryptProgress: state.updateProgress, finishDecrypt: state.finishDecrypt, setShowBatchDecryptToast: state.setShowToast }))) const [showBatchConfirm, setShowBatchConfirm] = useState(false) const [batchVoiceCount, setBatchVoiceCount] = useState(0) const [batchVoiceMessages, setBatchVoiceMessages] = useState(null) const [batchVoiceDates, setBatchVoiceDates] = useState([]) const [batchSelectedDates, setBatchSelectedDates] = useState>(new Set()) const [batchVoiceTaskType, setBatchVoiceTaskType] = useState('transcribe') const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false) const [batchImageMessages, setBatchImageMessages] = useState(null) const [batchImageDates, setBatchImageDates] = useState([]) const [batchImageSelectedDates, setBatchImageSelectedDates] = useState>(new Set()) const [batchDecryptConcurrency, setBatchDecryptConcurrency] = useState(6) const [showConcurrencyDropdown, setShowConcurrencyDropdown] = useState(false) // 批量删除相关状态 const [isDeleting, setIsDeleting] = useState(false) const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0 }) const [cancelDeleteRequested, setCancelDeleteRequested] = useState(false) // 会话内搜索 const [showInSessionSearch, setShowInSessionSearch] = useState(false) const [inSessionQuery, setInSessionQuery] = useState('') const [inSessionResults, setInSessionResults] = useState([]) const [inSessionSearching, setInSessionSearching] = useState(false) const [inSessionEnriching, setInSessionEnriching] = useState(false) const [inSessionSearchError, setInSessionSearchError] = useState(null) const inSessionSearchRef = useRef(null) const inSessionResultJumpTimerRef = useRef(null) const inSessionResultJumpRequestSeqRef = useRef(0) // 全局消息搜索 const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false) const [globalMsgQuery, setGlobalMsgQuery] = useState('') const [globalMsgResults, setGlobalMsgResults] = useState([]) const [globalMsgSearching, setGlobalMsgSearching] = useState(false) const [globalMsgSearchPhase, setGlobalMsgSearchPhase] = useState('idle') const [globalMsgIsBackfilling, setGlobalMsgIsBackfilling] = useState(false) const [globalMsgAuthoritativeSessionCount, setGlobalMsgAuthoritativeSessionCount] = useState(0) const [globalMsgSearchError, setGlobalMsgSearchError] = useState(null) const pendingInSessionSearchRef = useRef(null) const pendingGlobalMsgSearchReplayRef = useRef(null) const globalMsgPrefixCacheRef = useRef(null) // 自定义删除确认对话框 const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; mode: 'single' | 'batch'; message?: Message; count?: number; }>({ show: false, mode: 'single' }) // 联系人信息加载控制 const isEnrichingRef = useRef(false) const enrichCancelledRef = useRef(false) const isScrollingRef = useRef(false) const sessionScrollTimeoutRef = useRef(null) const pendingSessionContactEnrichRef = useRef>(new Set()) const sessionContactEnrichAttemptAtRef = useRef>(new Map()) const sessionContactProfileCacheRef = useRef>(new Map()) const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) const messageKeySetRef = useRef>(new Set()) const lastMessageTimeRef = useRef(0) const isMessageListAtBottomRef = useRef(true) const lastObservedMessageCountRef = useRef(0) const lastVisibleSenderWarmupAtRef = useRef(0) const sessionMapRef = useRef>(new Map()) const sessionsRef = useRef([]) const currentSessionRef = useRef(null) const pendingSessionLoadRef = useRef(null) const sessionSwitchRequestSeqRef = useRef(0) const initialLoadRequestedSessionRef = useRef(null) const prevSessionRef = useRef(null) const isConnectedRef = useRef(false) const isRefreshingRef = useRef(false) const searchKeywordRef = useRef('') const preloadImageKeysRef = useRef>(new Set()) const lastPreloadSessionRef = useRef(null) const detailRequestSeqRef = useRef(0) const groupMembersRequestSeqRef = useRef(0) const groupMembersPanelCacheRef = useRef>(new Map()) const hasInitializedGroupMembersRef = useRef(false) const chatCacheScopeRef = useRef('default') const previewCacheRef = useRef>({}) const sessionWindowCacheRef = useRef>(new Map()) const previewPersistTimerRef = useRef(null) const sessionListPersistTimerRef = useRef(null) const scrollBottomButtonArmTimerRef = useRef(null) const suppressScrollToBottomButtonRef = useRef(false) const pendingExportRequestIdRef = useRef(null) const exportPrepareLongWaitTimerRef = useRef(null) const jumpDatesRequestSeqRef = useRef(0) const jumpDateCountsRequestSeqRef = useRef(0) const suppressScrollToBottomButton = useCallback((delayMs = 180) => { suppressScrollToBottomButtonRef.current = true if (scrollBottomButtonArmTimerRef.current !== null) { window.clearTimeout(scrollBottomButtonArmTimerRef.current) scrollBottomButtonArmTimerRef.current = null } scrollBottomButtonArmTimerRef.current = window.setTimeout(() => { suppressScrollToBottomButtonRef.current = false scrollBottomButtonArmTimerRef.current = null }, delayMs) }, []) const isGroupChatSession = useCallback((username: string) => { return username.includes('@chatroom') }, []) const mergeSessionContactPresentation = useCallback((session: ChatSession, previousSession?: ChatSession): ChatSession => { const username = String(session.username || '').trim() if (!username || isFoldPlaceholderSession(username)) { return session } const now = Date.now() const cacheMap = sessionContactProfileCacheRef.current const cachedProfile = cacheMap.get(username) if (cachedProfile && now - cachedProfile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { cacheMap.delete(username) } const profile = cacheMap.get(username) const sessionDisplayName = resolveSessionDisplayName(session.displayName, username) const previousDisplayName = resolveSessionDisplayName(previousSession?.displayName, username) const profileDisplayName = resolveSessionDisplayName(profile?.displayName, username) const resolvedDisplayName = sessionDisplayName || previousDisplayName || profileDisplayName || session.displayName || username const sessionAvatarUrl = normalizeSearchAvatarUrl(session.avatarUrl) const previousAvatarUrl = normalizeSearchAvatarUrl(previousSession?.avatarUrl) const profileAvatarUrl = normalizeSearchAvatarUrl(profile?.avatarUrl) const resolvedAvatarUrl = sessionAvatarUrl || previousAvatarUrl || profileAvatarUrl const sessionAlias = normalizeSearchIdentityText(session.alias) const previousAlias = normalizeSearchIdentityText(previousSession?.alias) const profileAlias = normalizeSearchIdentityText(profile?.alias) const resolvedAlias = sessionAlias || previousAlias || profileAlias if ( resolvedDisplayName === session.displayName && resolvedAvatarUrl === session.avatarUrl && resolvedAlias === session.alias ) { return session } return { ...session, displayName: resolvedDisplayName, avatarUrl: resolvedAvatarUrl, alias: resolvedAlias } }, []) const clearExportPrepareState = useCallback(() => { pendingExportRequestIdRef.current = null setIsPreparingExportDialog(false) setExportPrepareHint('') if (exportPrepareLongWaitTimerRef.current) { window.clearTimeout(exportPrepareLongWaitTimerRef.current) exportPrepareLongWaitTimerRef.current = null } }, []) const resolveCurrentViewDate = useCallback(() => { if (jumpStartTime > 0) { return new Date(jumpStartTime * 1000) } const fallbackMessage = messages[messages.length - 1] || messages[0] const rawTimestamp = Number(fallbackMessage?.createTime || 0) if (Number.isFinite(rawTimestamp) && rawTimestamp > 0) { return new Date(rawTimestamp > 10000000000 ? rawTimestamp : rawTimestamp * 1000) } return new Date() }, [jumpStartTime, messages]) const loadJumpCalendarData = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return const cachedDates = messageDatesCache.current.get(normalizedSessionId) if (cachedDates) { setMessageDates(new Set(cachedDates)) setHasLoadedMessageDates(true) setLoadingDates(false) } else { setLoadingDates(true) setHasLoadedMessageDates(false) setMessageDates(new Set()) const requestSeq = jumpDatesRequestSeqRef.current + 1 jumpDatesRequestSeqRef.current = requestSeq try { const result = await window.electronAPI.chat.getMessageDates(normalizedSessionId) if (requestSeq !== jumpDatesRequestSeqRef.current || currentSessionRef.current !== normalizedSessionId) return if (result?.success && Array.isArray(result.dates)) { const dateSet = new Set(result.dates) messageDatesCache.current.set(normalizedSessionId, dateSet) setMessageDates(new Set(dateSet)) setHasLoadedMessageDates(true) } } catch (error) { console.error('获取消息日期失败:', error) } finally { if (requestSeq === jumpDatesRequestSeqRef.current && currentSessionRef.current === normalizedSessionId) { setLoadingDates(false) } } } const cachedCounts = messageDateCountsCache.current.get(normalizedSessionId) if (cachedCounts) { setMessageDateCounts({ ...cachedCounts }) setLoadingDateCounts(false) return } setLoadingDateCounts(true) setMessageDateCounts({}) const requestSeq = jumpDateCountsRequestSeqRef.current + 1 jumpDateCountsRequestSeqRef.current = requestSeq try { const result = await window.electronAPI.chat.getMessageDateCounts(normalizedSessionId) if (requestSeq !== jumpDateCountsRequestSeqRef.current || currentSessionRef.current !== normalizedSessionId) return if (result?.success && result.counts) { const normalizedCounts: Record = {} Object.entries(result.counts).forEach(([date, value]) => { const count = Number(value) if (!date || !Number.isFinite(count) || count <= 0) return normalizedCounts[date] = count }) messageDateCountsCache.current.set(normalizedSessionId, normalizedCounts) setMessageDateCounts(normalizedCounts) } } catch (error) { console.error('获取每日消息数失败:', error) } finally { if (requestSeq === jumpDateCountsRequestSeqRef.current && currentSessionRef.current === normalizedSessionId) { setLoadingDateCounts(false) } } }, []) const updateJumpPopoverPosition = useCallback(() => { const anchor = jumpCalendarWrapRef.current if (!anchor) return const popoverWidth = 312 const viewportGap = 8 const anchorRect = anchor.getBoundingClientRect() let left = anchorRect.right - popoverWidth left = Math.max(viewportGap, Math.min(left, window.innerWidth - popoverWidth - viewportGap)) const portalHeight = jumpPopoverPortalRef.current?.offsetHeight || 0 const belowTop = anchorRect.bottom + 10 let top = belowTop if (portalHeight > 0 && belowTop + portalHeight > window.innerHeight - viewportGap) { top = Math.max(viewportGap, anchorRect.top - portalHeight - 10) } setJumpPopoverPosition(prev => { if (prev.top === top && prev.left === left) return prev return { top, left } }) }, []) const handleToggleJumpPopover = useCallback(() => { if (!currentSessionId) return if (showJumpPopover) { setShowJumpPopover(false) return } setJumpPopoverDate(resolveCurrentViewDate()) updateJumpPopoverPosition() setShowJumpPopover(true) requestAnimationFrame(() => updateJumpPopoverPosition()) void loadJumpCalendarData(currentSessionId) }, [currentSessionId, loadJumpCalendarData, resolveCurrentViewDate, showJumpPopover, updateJumpPopoverPosition]) useEffect(() => { const unsubscribe = onExportSessionStatus((payload) => { const ids = Array.isArray(payload?.inProgressSessionIds) ? payload.inProgressSessionIds .filter((id): id is string => typeof id === 'string') .map(id => id.trim()) .filter(Boolean) : [] setInProgressExportSessionIds(new Set(ids)) }) requestExportSessionStatus() const timer = window.setTimeout(() => { requestExportSessionStatus() }, 0) return () => { window.clearTimeout(timer) unsubscribe() } }, []) useEffect(() => { const unsubscribe = onSingleExportDialogStatus((payload) => { const requestId = typeof payload?.requestId === 'string' ? payload.requestId.trim() : '' if (!requestId || requestId !== pendingExportRequestIdRef.current) return if (payload.status === 'initializing') { setExportPrepareHint('正在准备导出模块(首次会稍慢,通常 1-3 秒)') if (exportPrepareLongWaitTimerRef.current) { window.clearTimeout(exportPrepareLongWaitTimerRef.current) } exportPrepareLongWaitTimerRef.current = window.setTimeout(() => { if (pendingExportRequestIdRef.current !== requestId) return setExportPrepareHint('仍在准备导出模块,请稍候...') }, 8000) return } if (payload.status === 'opened') { clearExportPrepareState() return } if (payload.status === 'failed') { const message = (typeof payload.message === 'string' && payload.message.trim()) ? payload.message.trim() : '导出模块初始化失败,请重试' clearExportPrepareState() window.alert(message) } }) return () => { unsubscribe() if (exportPrepareLongWaitTimerRef.current) { window.clearTimeout(exportPrepareLongWaitTimerRef.current) exportPrepareLongWaitTimerRef.current = null } } }, [clearExportPrepareState]) useEffect(() => { if (!isPreparingExportDialog || !currentSessionId) return if (!inProgressExportSessionIds.has(currentSessionId)) return clearExportPrepareState() }, [clearExportPrepareState, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog]) // 加载当前用户头像 const loadMyAvatar = useCallback(async () => { try { const result = await window.electronAPI.chat.getMyAvatarUrl() if (result.success && result.avatarUrl) { setMyAvatarUrl(result.avatarUrl) } } catch (e) { console.error('加载用户头像失败:', e) } }, []) const resolveChatCacheScope = useCallback(async (): Promise => { try { const [dbPath, myWxid] = await Promise.all([ window.electronAPI.config.get('dbPath'), window.electronAPI.config.get('myWxid') ]) const scope = normalizeChatCacheScope(dbPath, myWxid) chatCacheScopeRef.current = scope return scope } catch { chatCacheScopeRef.current = 'default' return 'default' } }, []) const loadPreviewCacheFromStorage = useCallback((scope: string): Record => { try { const cacheKey = buildChatSessionPreviewCacheKey(scope) const payload = safeParseJson(window.localStorage.getItem(cacheKey)) if (!payload || typeof payload.updatedAt !== 'number' || !payload.entries) { return {} } if (Date.now() - payload.updatedAt > CHAT_SESSION_PREVIEW_CACHE_TTL_MS) { return {} } return payload.entries } catch { return {} } }, []) const persistPreviewCacheToStorage = useCallback((scope: string, entries: Record) => { try { const cacheKey = buildChatSessionPreviewCacheKey(scope) const payload: SessionPreviewCachePayload = { updatedAt: Date.now(), entries } window.localStorage.setItem(cacheKey, JSON.stringify(payload)) } catch { // ignore cache write failures } }, []) const persistSessionPreviewCache = useCallback((sessionId: string, previewMessages: Message[]) => { const id = String(sessionId || '').trim() if (!id || !Array.isArray(previewMessages) || previewMessages.length === 0) return const trimmed = previewMessages.slice(-CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION) const currentEntries = { ...previewCacheRef.current } currentEntries[id] = { updatedAt: Date.now(), messages: trimmed } const sortedIds = Object.entries(currentEntries) .sort((a, b) => (b[1]?.updatedAt || 0) - (a[1]?.updatedAt || 0)) .map(([entryId]) => entryId) const keptIds = new Set(sortedIds.slice(0, CHAT_SESSION_PREVIEW_MAX_SESSIONS)) const compactEntries: Record = {} for (const [entryId, entry] of Object.entries(currentEntries)) { if (keptIds.has(entryId)) { compactEntries[entryId] = entry } } previewCacheRef.current = compactEntries if (previewPersistTimerRef.current !== null) { window.clearTimeout(previewPersistTimerRef.current) } previewPersistTimerRef.current = window.setTimeout(() => { persistPreviewCacheToStorage(chatCacheScopeRef.current, previewCacheRef.current) previewPersistTimerRef.current = null }, 220) }, [persistPreviewCacheToStorage]) const hydrateSessionPreview = useCallback(async (sessionId: string) => { const id = String(sessionId || '').trim() if (!id) return const localEntry = previewCacheRef.current[id] if ( localEntry && Array.isArray(localEntry.messages) && localEntry.messages.length > 0 && Date.now() - localEntry.updatedAt <= CHAT_SESSION_PREVIEW_CACHE_TTL_MS ) { setMessages(localEntry.messages.slice()) setHasInitialMessages(true) return } try { const result = await window.electronAPI.chat.getCachedMessages(id) if (!result.success || !Array.isArray(result.messages) || result.messages.length === 0) { return } if (currentSessionRef.current !== id && pendingSessionLoadRef.current !== id) return setMessages(result.messages) setHasInitialMessages(true) persistSessionPreviewCache(id, result.messages) } catch { // ignore preview cache errors } }, [persistSessionPreviewCache, setMessages]) const saveSessionWindowCache = useCallback((sessionId: string, entry: Omit) => { const id = String(sessionId || '').trim() if (!id || !Array.isArray(entry.messages) || entry.messages.length === 0) return const trimmedMessages = entry.messages.length > CHAT_SESSION_WINDOW_CACHE_MAX_MESSAGES ? entry.messages.slice(-CHAT_SESSION_WINDOW_CACHE_MAX_MESSAGES) : entry.messages.slice() const cache = sessionWindowCacheRef.current cache.set(id, { updatedAt: Date.now(), ...entry, messages: trimmedMessages, currentOffset: trimmedMessages.length }) if (cache.size <= CHAT_SESSION_WINDOW_CACHE_MAX_SESSIONS) return const sortedByTime = [...cache.entries()] .sort((a, b) => (a[1].updatedAt || 0) - (b[1].updatedAt || 0)) for (const [key] of sortedByTime) { if (cache.size <= CHAT_SESSION_WINDOW_CACHE_MAX_SESSIONS) break cache.delete(key) } }, []) const restoreSessionWindowCache = useCallback((sessionId: string): boolean => { const id = String(sessionId || '').trim() if (!id) return false const cache = sessionWindowCacheRef.current const entry = cache.get(id) if (!entry) return false if (Date.now() - entry.updatedAt > CHAT_SESSION_WINDOW_CACHE_TTL_MS) { cache.delete(id) return false } if (!Array.isArray(entry.messages) || entry.messages.length === 0) { cache.delete(id) return false } // LRU: 命中后更新时间 cache.set(id, { ...entry, updatedAt: Date.now(), messages: entry.messages.slice() }) setMessages(entry.messages.slice()) setCurrentOffset(entry.messages.length) setHasMoreMessages(entry.hasMoreMessages !== false) setHasMoreLater(entry.hasMoreLater === true) setJumpStartTime(entry.jumpStartTime || 0) setJumpEndTime(entry.jumpEndTime || 0) setNoMessageTable(false) setHasInitialMessages(true) return true }, [ setMessages, setHasMoreMessages, setHasMoreLater, setCurrentOffset, setJumpStartTime, setJumpEndTime, setNoMessageTable, setHasInitialMessages ]) const hydrateSessionListCache = useCallback((scope: string): boolean => { try { const cacheKey = buildChatSessionListCacheKey(scope) const payload = safeParseJson(window.localStorage.getItem(cacheKey)) if (!payload || typeof payload.updatedAt !== 'number' || !Array.isArray(payload.sessions)) { previewCacheRef.current = loadPreviewCacheFromStorage(scope) return false } previewCacheRef.current = loadPreviewCacheFromStorage(scope) if (Date.now() - payload.updatedAt > CHAT_SESSION_LIST_CACHE_TTL_MS) { return false } if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) { setSessions(payload.sessions) sessionsRef.current = payload.sessions return payload.sessions.length > 0 } return false } catch { previewCacheRef.current = loadPreviewCacheFromStorage(scope) return false } }, [loadPreviewCacheFromStorage, setSessions]) const persistSessionListCache = useCallback((scope: string, nextSessions: ChatSession[]) => { try { const cacheKey = buildChatSessionListCacheKey(scope) const payload: SessionListCachePayload = { updatedAt: Date.now(), sessions: nextSessions } window.localStorage.setItem(cacheKey, JSON.stringify(payload)) } catch { // ignore cache write failures } }, []) const applySessionDetailStats = useCallback(( sessionId: string, metric: SessionExportMetric, cacheMeta?: SessionExportCacheMeta, relationLoadedOverride?: boolean ) => { setSessionDetail((prev) => { if (!prev || prev.wxid !== sessionId) return prev const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded) return { ...prev, messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount, voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages, imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages, videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages, emojiMessages: Number.isFinite(metric.emojiMessages) ? metric.emojiMessages : prev.emojiMessages, transferMessages: Number.isFinite(metric.transferMessages) ? metric.transferMessages : prev.transferMessages, redPacketMessages: Number.isFinite(metric.redPacketMessages) ? metric.redPacketMessages : prev.redPacketMessages, callMessages: Number.isFinite(metric.callMessages) ? metric.callMessages : prev.callMessages, groupMemberCount: Number.isFinite(metric.groupMemberCount) ? metric.groupMemberCount : prev.groupMemberCount, groupMyMessages: Number.isFinite(metric.groupMyMessages) ? metric.groupMyMessages : prev.groupMyMessages, groupActiveSpeakers: Number.isFinite(metric.groupActiveSpeakers) ? metric.groupActiveSpeakers : prev.groupActiveSpeakers, privateMutualGroups: relationLoaded && Number.isFinite(metric.privateMutualGroups) ? metric.privateMutualGroups : prev.privateMutualGroups, groupMutualFriends: relationLoaded && Number.isFinite(metric.groupMutualFriends) ? metric.groupMutualFriends : prev.groupMutualFriends, relationStatsLoaded: relationLoaded, statsUpdatedAt: cacheMeta?.updatedAt ?? prev.statsUpdatedAt, statsStale: typeof cacheMeta?.stale === 'boolean' ? cacheMeta.stale : prev.statsStale, firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : prev.firstMessageTime, latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime } }) }, []) // 加载会话详情 const loadSessionDetail = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return const taskId = registerBackgroundTask({ sourcePage: 'chat', title: '聊天页会话详情统计', detail: `准备读取 ${sessionMapRef.current.get(normalizedSessionId)?.displayName || normalizedSessionId} 的详情`, progressText: '基础信息', cancelable: true }) const requestSeq = ++detailRequestSeqRef.current const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId) const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0 ? Math.floor(mappedSession.messageCountHint) : undefined setIsRefreshingDetailStats(false) setIsLoadingRelationStats(false) setSessionDetail((prev) => { const sameSession = prev?.wxid === normalizedSessionId return { wxid: normalizedSessionId, displayName: mappedSession?.displayName || prev?.displayName || normalizedSessionId, remark: sameSession ? prev?.remark : undefined, nickName: sameSession ? prev?.nickName : undefined, alias: sameSession ? prev?.alias : undefined, avatarUrl: mappedSession?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), messageCount: hintedCount ?? (sameSession ? prev.messageCount : Number.NaN), voiceMessages: sameSession ? prev?.voiceMessages : undefined, imageMessages: sameSession ? prev?.imageMessages : undefined, videoMessages: sameSession ? prev?.videoMessages : undefined, emojiMessages: sameSession ? prev?.emojiMessages : undefined, transferMessages: sameSession ? prev?.transferMessages : undefined, redPacketMessages: sameSession ? prev?.redPacketMessages : undefined, callMessages: sameSession ? prev?.callMessages : undefined, privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined, groupMemberCount: sameSession ? prev?.groupMemberCount : undefined, groupMyMessages: sameSession ? prev?.groupMyMessages : undefined, groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined, groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined, relationStatsLoaded: sameSession ? prev?.relationStatsLoaded : false, statsUpdatedAt: sameSession ? prev?.statsUpdatedAt : undefined, statsStale: sameSession ? prev?.statsStale : undefined, firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] } }) setIsLoadingDetail(true) setIsLoadingDetailExtra(true) if (normalizedSessionId.includes('@chatroom')) { void (async () => { try { const hintResult = await window.electronAPI.chat.getGroupMyMessageCountHint(normalizedSessionId) if (requestSeq !== detailRequestSeqRef.current) return if (!hintResult.success || !Number.isFinite(hintResult.count)) return const hintedMyCount = Math.max(0, Math.floor(hintResult.count as number)) setSessionDetail((prev) => { if (!prev || prev.wxid !== normalizedSessionId) return prev return { ...prev, groupMyMessages: hintedMyCount } }) } catch { // ignore hint errors } })() } try { updateBackgroundTask(taskId, { detail: '正在读取会话基础详情', progressText: '基础信息' }) const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,当前基础查询结束后未继续补充统计' }) return } if (requestSeq !== detailRequestSeqRef.current) { finishBackgroundTask(taskId, 'canceled', { detail: '会话已切换,旧详情任务已停止' }) return } if (result.success && result.detail) { setSessionDetail((prev) => ({ wxid: normalizedSessionId, displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId, remark: result.detail!.remark, nickName: result.detail!.nickName, alias: result.detail!.alias, avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, voiceMessages: prev?.voiceMessages, imageMessages: prev?.imageMessages, videoMessages: prev?.videoMessages, emojiMessages: prev?.emojiMessages, transferMessages: prev?.transferMessages, redPacketMessages: prev?.redPacketMessages, callMessages: prev?.callMessages, privateMutualGroups: prev?.privateMutualGroups, groupMemberCount: prev?.groupMemberCount, groupMyMessages: prev?.groupMyMessages, groupActiveSpeakers: prev?.groupActiveSpeakers, groupMutualFriends: prev?.groupMutualFriends, relationStatsLoaded: prev?.relationStatsLoaded, statsUpdatedAt: prev?.statsUpdatedAt, statsStale: prev?.statsStale, firstMessageTime: prev?.firstMessageTime, latestMessageTime: prev?.latestMessageTime, messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] })) } } catch (e) { console.error('加载会话详情失败:', e) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsLoadingDetail(false) } } try { updateBackgroundTask(taskId, { detail: '正在读取补充信息与导出统计', progressText: '补充统计' }) const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], { includeRelations: false, allowStaleCache: true, cacheOnly: true } ) ]) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,补充统计结果未继续写入' }) return } if (requestSeq !== detailRequestSeqRef.current) { finishBackgroundTask(taskId, 'canceled', { detail: '会话已切换,旧补充统计任务已停止' }) return } if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) { const detail = extraResultSettled.value.detail if (detail) { setSessionDetail((prev) => { if (!prev || prev.wxid !== normalizedSessionId) return prev return { ...prev, firstMessageTime: detail.firstMessageTime, latestMessageTime: detail.latestMessageTime, messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : [] } }) } } let refreshIncludeRelations = false let shouldRefreshStatsInBackground = false if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined refreshIncludeRelations = Boolean(cacheMeta?.includeRelations) if (metric) { applySessionDetailStats(normalizedSessionId, metric, cacheMeta, refreshIncludeRelations) } else if (cacheMeta) { setSessionDetail((prev) => { if (!prev || prev.wxid !== normalizedSessionId) return prev return { ...prev, relationStatsLoaded: refreshIncludeRelations || prev.relationStatsLoaded, statsUpdatedAt: cacheMeta.updatedAt, statsStale: cacheMeta.stale } }) } shouldRefreshStatsInBackground = !metric || Boolean(cacheMeta?.stale) } else { shouldRefreshStatsInBackground = true } finishBackgroundTask(taskId, 'completed', { detail: '聊天页会话详情统计完成', progressText: '已完成' }) if (shouldRefreshStatsInBackground) { setIsRefreshingDetailStats(true) void (async () => { try { const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], { includeRelations: false, forceRefresh: true } ) if (requestSeq !== detailRequestSeqRef.current) return if (freshResult.success && freshResult.data) { const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined if (freshMetric) { applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, false) } else if (freshMeta) { setSessionDetail((prev) => { if (!prev || prev.wxid !== normalizedSessionId) return prev return { ...prev, statsUpdatedAt: freshMeta.updatedAt, statsStale: freshMeta.stale } }) } } } catch (error) { console.error('聊天页后台刷新会话统计失败:', error) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsRefreshingDetailStats(false) } } })() } } catch (e) { console.error('加载会话详情补充统计失败:', e) finishBackgroundTask(taskId, 'failed', { detail: String(e) }) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsLoadingDetailExtra(false) } } }, [applySessionDetailStats]) const loadRelationStats = useCallback(async () => { const normalizedSessionId = String(currentSessionId || '').trim() if (!normalizedSessionId || isLoadingRelationStats) return const requestSeq = detailRequestSeqRef.current const taskId = registerBackgroundTask({ sourcePage: 'chat', title: '聊天页关系统计补算', detail: `正在补算 ${normalizedSessionId} 的共同好友与关联数据`, progressText: '关系统计', cancelable: true }) setIsLoadingRelationStats(true) try { const relationResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } ) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,当前关系统计查询结束后未继续刷新' }) return } if (requestSeq !== detailRequestSeqRef.current) { finishBackgroundTask(taskId, 'canceled', { detail: '会话已切换,旧关系统计任务已停止' }) return } const metric = relationResult.success && relationResult.data ? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined : undefined const cacheMeta = relationResult.success ? relationResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined : undefined if (metric) { applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true) } const needRefresh = relationResult.success && Array.isArray(relationResult.needsRefresh) && relationResult.needsRefresh.includes(normalizedSessionId) if (needRefresh) { setIsRefreshingDetailStats(true) void (async () => { try { updateBackgroundTask(taskId, { detail: '正在刷新关系统计结果', progressText: '关系统计刷新' }) const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } ) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,刷新结果未继续写入' }) return } if (requestSeq !== detailRequestSeqRef.current) { finishBackgroundTask(taskId, 'canceled', { detail: '会话已切换,旧关系统计刷新任务已停止' }) return } if (freshResult.success && freshResult.data) { const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined if (freshMetric) { applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true) } } finishBackgroundTask(taskId, 'completed', { detail: '聊天页关系统计补算完成', progressText: '已完成' }) } catch (error) { console.error('刷新会话关系统计失败:', error) finishBackgroundTask(taskId, 'failed', { detail: String(error) }) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsRefreshingDetailStats(false) } } })() } else { finishBackgroundTask(taskId, 'completed', { detail: '聊天页关系统计补算完成', progressText: '已完成' }) } } catch (error) { console.error('加载会话关系统计失败:', error) finishBackgroundTask(taskId, 'failed', { detail: String(error) }) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsLoadingRelationStats(false) } } }, [applySessionDetailStats, currentSessionId, isLoadingRelationStats]) const normalizeGroupPanelMembers = useCallback(( payload: GroupPanelMember[], options?: { messageCountStatus?: GroupMessageCountStatus } ): GroupPanelMember[] => { const membersPayload = Array.isArray(payload) ? payload : [] return membersPayload .map((member: GroupPanelMember): GroupPanelMember | null => { const username = String(member.username || '').trim() if (!username) return null const preferredName = String( member.groupNickname || member.remark || member.displayName || member.nickname || username ) const rawStatus = member.messageCountStatus const normalizedStatus: GroupMessageCountStatus = options?.messageCountStatus ?? (rawStatus === 'loading' || rawStatus === 'failed' ? rawStatus : 'ready') return { username, displayName: preferredName, avatarUrl: member.avatarUrl, nickname: member.nickname, alias: member.alias, remark: member.remark, groupNickname: member.groupNickname, isOwner: Boolean(member.isOwner), isFriend: Boolean(member.isFriend), messageCount: Number.isFinite(member.messageCount) ? Math.max(0, Math.floor(member.messageCount)) : 0, messageCountStatus: normalizedStatus } }) .filter((member: GroupPanelMember | null): member is GroupPanelMember => Boolean(member)) .sort((a: GroupPanelMember, b: GroupPanelMember) => { const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner)) if (ownerDiff !== 0) return ownerDiff const friendDiff = Number(b.isFriend) - Number(a.isFriend) if (friendDiff !== 0) return friendDiff const canSortByCount = a.messageCountStatus === 'ready' && b.messageCountStatus === 'ready' if (canSortByCount && a.messageCount !== b.messageCount) return b.messageCount - a.messageCount return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN') }) }, []) const normalizeWxidLikeIdentity = useCallback((value?: string): string => { const trimmed = String(value || '').trim() if (!trimmed) return '' const lowered = trimmed.toLowerCase() if (lowered.startsWith('wxid_')) { const matched = lowered.match(/^(wxid_[^_]+)/i) return matched ? matched[1].toLowerCase() : lowered } const suffixMatch = lowered.match(/^(.+)_([a-z0-9]{4})$/i) return suffixMatch ? suffixMatch[1].toLowerCase() : lowered }, []) const isSelfGroupMember = useCallback((memberUsername?: string): boolean => { const selfRaw = String(myWxid || '').trim().toLowerCase() const selfNormalized = normalizeWxidLikeIdentity(myWxid) if (!selfRaw && !selfNormalized) return false const memberRaw = String(memberUsername || '').trim().toLowerCase() const memberNormalized = normalizeWxidLikeIdentity(memberUsername) return Boolean( (selfRaw && memberRaw && selfRaw === memberRaw) || (selfNormalized && memberNormalized && selfNormalized === memberNormalized) ) }, [myWxid, normalizeWxidLikeIdentity]) const resolveMyGroupMessageCountFromMembers = useCallback((members: GroupPanelMember[]): number | undefined => { if (!myWxid) return undefined for (const member of members) { if (!isSelfGroupMember(member.username)) continue if (Number.isFinite(member.messageCount)) { return Math.max(0, Math.floor(member.messageCount)) } return 0 } return undefined }, [isSelfGroupMember, myWxid]) const syncGroupMyMessagesFromMembers = useCallback((chatroomId: string, members: GroupPanelMember[]) => { const myMessageCount = resolveMyGroupMessageCountFromMembers(members) if (!Number.isFinite(myMessageCount)) return setSessionDetail((prev) => { if (!prev || prev.wxid !== chatroomId || !prev.wxid.includes('@chatroom')) return prev return { ...prev, groupMyMessages: myMessageCount as number } }) }, [resolveMyGroupMessageCountFromMembers]) const updateGroupMembersPanelCache = useCallback(( chatroomId: string, members: GroupPanelMember[], includeMessageCounts: boolean ) => { groupMembersPanelCacheRef.current.set(chatroomId, { updatedAt: Date.now(), members, includeMessageCounts }) if (groupMembersPanelCacheRef.current.size > 80) { const oldestEntry = Array.from(groupMembersPanelCacheRef.current.entries()) .sort((a, b) => a[1].updatedAt - b[1].updatedAt)[0] if (oldestEntry) { groupMembersPanelCacheRef.current.delete(oldestEntry[0]) } } }, []) const setGroupMembersCountStatus = useCallback(( status: GroupMessageCountStatus, options?: { onlyWhenNotReady?: boolean } ) => { setGroupPanelMembers((prev) => { if (!Array.isArray(prev) || prev.length === 0) return prev if (options?.onlyWhenNotReady && prev.some((member) => member.messageCountStatus === 'ready')) { return prev } const next = normalizeGroupPanelMembers(prev, { messageCountStatus: status }) const changed = next.some((member, index) => member.messageCountStatus !== prev[index]?.messageCountStatus) return changed ? next : prev }) }, [normalizeGroupPanelMembers]) const syncGroupMembersMyCountFromDetail = useCallback((chatroomId: string, myMessageCount: number) => { if (!chatroomId || !chatroomId.includes('@chatroom')) return const normalizedCount = Number.isFinite(myMessageCount) ? Math.max(0, Math.floor(myMessageCount)) : 0 const patchMembers = (members: GroupPanelMember[]): { changed: boolean; members: GroupPanelMember[] } => { if (!Array.isArray(members) || members.length === 0) { return { changed: false, members } } let changed = false const patched = members.map((member) => { if (!isSelfGroupMember(member.username)) return member if (member.messageCount === normalizedCount) return member changed = true return { ...member, messageCount: normalizedCount } }) if (!changed) return { changed: false, members } return { changed: true, members: normalizeGroupPanelMembers(patched) } } const cached = groupMembersPanelCacheRef.current.get(chatroomId) if (cached && cached.members.length > 0) { const patchedCache = patchMembers(cached.members) if (patchedCache.changed) { updateGroupMembersPanelCache(chatroomId, patchedCache.members, true) } } setGroupPanelMembers((prev) => { const patched = patchMembers(prev) if (!patched.changed) return prev return patched.members }) }, [ isSelfGroupMember, normalizeGroupPanelMembers, updateGroupMembersPanelCache ]) const getGroupMembersPanelDataWithTimeout = useCallback(async ( chatroomId: string, options: { forceRefresh?: boolean; includeMessageCounts?: boolean }, timeoutMs: number ) => { let timeoutTimer: number | null = null try { const timeoutPromise = new Promise<{ success: false; error: string }>((resolve) => { timeoutTimer = window.setTimeout(() => { resolve({ success: false, error: '加载群成员超时,请稍后重试' }) }, timeoutMs) }) return await Promise.race([ window.electronAPI.groupAnalytics.getGroupMembersPanelData(chatroomId, options), timeoutPromise ]) } finally { if (timeoutTimer) { window.clearTimeout(timeoutTimer) } } }, []) const loadGroupMembersPanel = useCallback(async (chatroomId: string) => { if (!chatroomId || !isGroupChatSession(chatroomId)) return const requestSeq = ++groupMembersRequestSeqRef.current const now = Date.now() const cached = groupMembersPanelCacheRef.current.get(chatroomId) const cacheFresh = Boolean(cached && now - cached.updatedAt < GROUP_MEMBERS_PANEL_CACHE_TTL_MS) const hasCachedMembers = Boolean(cached && cached.members.length > 0) const hasFreshMessageCounts = Boolean(cacheFresh && cached?.includeMessageCounts) let startedBackgroundRefresh = false const refreshMessageCountsInBackground = (forceRefresh: boolean) => { startedBackgroundRefresh = true setIsRefreshingGroupMembers(true) setGroupMembersCountStatus('loading', { onlyWhenNotReady: true }) void (async () => { try { const countsResult = await getGroupMembersPanelDataWithTimeout( chatroomId, { forceRefresh, includeMessageCounts: true }, 25000 ) if (requestSeq !== groupMembersRequestSeqRef.current) return if (!countsResult.success || !Array.isArray(countsResult.data)) { setGroupMembersError('成员列表已加载,发言统计稍后再试') setGroupMembersCountStatus('failed', { onlyWhenNotReady: true }) return } const membersWithCounts = normalizeGroupPanelMembers( countsResult.data as GroupPanelMember[], { messageCountStatus: 'ready' } ) setGroupPanelMembers(membersWithCounts) syncGroupMyMessagesFromMembers(chatroomId, membersWithCounts) setGroupMembersError(null) updateGroupMembersPanelCache(chatroomId, membersWithCounts, true) hasInitializedGroupMembersRef.current = true } catch { if (requestSeq !== groupMembersRequestSeqRef.current) return setGroupMembersError('成员列表已加载,发言统计稍后再试') setGroupMembersCountStatus('failed', { onlyWhenNotReady: true }) } finally { if (requestSeq === groupMembersRequestSeqRef.current) { setIsRefreshingGroupMembers(false) } } })() } if (cacheFresh && cached) { const cachedMembers = normalizeGroupPanelMembers( cached.members, { messageCountStatus: cached.includeMessageCounts ? 'ready' : 'loading' } ) setGroupPanelMembers(cachedMembers) if (cached.includeMessageCounts) { syncGroupMyMessagesFromMembers(chatroomId, cachedMembers) } setGroupMembersError(null) setGroupMembersLoadingHint('') setIsLoadingGroupMembers(false) hasInitializedGroupMembersRef.current = true if (!hasFreshMessageCounts) { refreshMessageCountsInBackground(false) } else { setIsRefreshingGroupMembers(false) } return } setGroupMembersError(null) if (hasCachedMembers && cached) { const cachedMembers = normalizeGroupPanelMembers( cached.members, { messageCountStatus: cached.includeMessageCounts ? 'ready' : 'loading' } ) setGroupPanelMembers(cachedMembers) if (cached.includeMessageCounts) { syncGroupMyMessagesFromMembers(chatroomId, cachedMembers) } setIsRefreshingGroupMembers(true) setGroupMembersLoadingHint('') setIsLoadingGroupMembers(false) } else { setGroupPanelMembers([]) setIsRefreshingGroupMembers(false) setIsLoadingGroupMembers(true) setGroupMembersLoadingHint( hasInitializedGroupMembersRef.current ? '加载群成员中...' : '首次加载群成员,正在初始化索引(可能需要几秒)' ) } try { const membersResult = await getGroupMembersPanelDataWithTimeout( chatroomId, { includeMessageCounts: false, forceRefresh: false }, 12000 ) if (requestSeq !== groupMembersRequestSeqRef.current) return if (!membersResult.success || !Array.isArray(membersResult.data)) { if (!hasCachedMembers) { setGroupPanelMembers([]) } setGroupMembersError(membersResult.error || (hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : '加载群成员失败')) return } const members = normalizeGroupPanelMembers( membersResult.data as GroupPanelMember[], { messageCountStatus: 'loading' } ) setGroupPanelMembers(members) setGroupMembersError(null) updateGroupMembersPanelCache(chatroomId, members, false) hasInitializedGroupMembersRef.current = true refreshMessageCountsInBackground(false) } catch (e) { if (requestSeq !== groupMembersRequestSeqRef.current) return if (!hasCachedMembers) { setGroupPanelMembers([]) } setGroupMembersError(hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : String(e)) } finally { if (requestSeq === groupMembersRequestSeqRef.current) { setIsLoadingGroupMembers(false) setGroupMembersLoadingHint('') if (!startedBackgroundRefresh) { setIsRefreshingGroupMembers(false) } } } }, [ getGroupMembersPanelDataWithTimeout, isGroupChatSession, syncGroupMyMessagesFromMembers, normalizeGroupPanelMembers, updateGroupMembersPanelCache ]) const toggleGroupMembersPanel = useCallback(() => { if (!currentSessionId || !isGroupChatSession(currentSessionId)) return if (showGroupMembersPanel) { setShowGroupMembersPanel(false) return } setShowDetailPanel(false) setShowGroupMembersPanel(true) }, [currentSessionId, showGroupMembersPanel, isGroupChatSession]) // 切换详情面板 const toggleDetailPanel = useCallback(() => { if (showDetailPanel) { setShowDetailPanel(false) return } setShowGroupMembersPanel(false) setShowDetailPanel(true) if (currentSessionId) { void loadSessionDetail(currentSessionId) } }, [showDetailPanel, currentSessionId, loadSessionDetail]) useEffect(() => { if (!showGroupMembersPanel) return if (!currentSessionId || !isGroupChatSession(currentSessionId)) { setShowGroupMembersPanel(false) return } setGroupMemberSearchKeyword('') void loadGroupMembersPanel(currentSessionId) }, [showGroupMembersPanel, currentSessionId, loadGroupMembersPanel, isGroupChatSession]) useEffect(() => { const chatroomId = String(sessionDetail?.wxid || '').trim() if (!chatroomId || !chatroomId.includes('@chatroom')) return if (!Number.isFinite(sessionDetail?.groupMyMessages)) return syncGroupMembersMyCountFromDetail(chatroomId, sessionDetail!.groupMyMessages as number) }, [sessionDetail?.groupMyMessages, sessionDetail?.wxid, syncGroupMembersMyCountFromDetail]) // 复制字段值到剪贴板 const handleCopyField = useCallback(async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) setCopiedField(field) setTimeout(() => setCopiedField(null), 1500) } catch { // fallback const textarea = document.createElement('textarea') textarea.value = text document.body.appendChild(textarea) textarea.select() document.execCommand('copy') document.body.removeChild(textarea) setCopiedField(field) setTimeout(() => setCopiedField(null), 1500) } }, []) // 连接数据库 const connect = useCallback(async () => { setConnecting(true) setConnectionError(null) try { const scopePromise = resolveChatCacheScope() const result = await window.electronAPI.chat.connect() if (result.success) { setConnected(true) const wxidPromise = window.electronAPI.config.get('myWxid') await Promise.all([scopePromise, loadSessions(), loadMyAvatar()]) // 获取 myWxid 用于匹配个人头像 const wxid = await wxidPromise if (wxid) setMyWxid(wxid as string) } else { setConnectionError(result.error || '连接失败') } } catch (e) { setConnectionError(String(e)) } finally { setConnecting(false) } }, [loadMyAvatar, resolveChatCacheScope]) const handleAccountChanged = useCallback(async () => { senderAvatarCache.clear() senderAvatarLoading.clear() sessionContactProfileCacheRef.current.clear() pendingSessionContactEnrichRef.current.clear() sessionContactEnrichAttemptAtRef.current.clear() preloadImageKeysRef.current.clear() lastPreloadSessionRef.current = null pendingSessionLoadRef.current = null initialLoadRequestedSessionRef.current = null sessionSwitchRequestSeqRef.current += 1 sessionWindowCacheRef.current.clear() setIsSessionSwitching(false) setSessionDetail(null) setIsRefreshingDetailStats(false) setIsLoadingRelationStats(false) setShowDetailPanel(false) setShowGroupMembersPanel(false) setGroupPanelMembers([]) setGroupMembersError(null) setGroupMembersLoadingHint('') setIsRefreshingGroupMembers(false) setGroupMemberSearchKeyword('') groupMembersRequestSeqRef.current += 1 groupMembersPanelCacheRef.current.clear() hasInitializedGroupMembersRef.current = false setIsLoadingGroupMembers(false) setCurrentSession(null) setSessions([]) setMessages([]) setShowScrollToBottom(false) suppressScrollToBottomButton(260) setSearchKeyword('') setConnectionError(null) setConnected(false) setConnecting(false) setHasMoreMessages(true) setHasMoreLater(false) const scope = await resolveChatCacheScope() hydrateSessionListCache(scope) await connect() }, [ connect, resolveChatCacheScope, hydrateSessionListCache, setConnected, setConnecting, setConnectionError, setCurrentSession, setHasMoreLater, setHasMoreMessages, setMessages, setSearchKeyword, setSessionDetail, setShowDetailPanel, setShowGroupMembersPanel, suppressScrollToBottomButton, setSessions ]) useEffect(() => { let canceled = false void configService.getAutoTranscribeVoice() .then((enabled) => { if (!canceled) { setAutoTranscribeVoiceEnabled(Boolean(enabled)) } }) .catch(() => { if (!canceled) { setAutoTranscribeVoiceEnabled(false) } }) return () => { canceled = true } }, []) useEffect(() => { let cancelled = false void (async () => { const scope = await resolveChatCacheScope() if (cancelled) return hydrateSessionListCache(scope) })() return () => { cancelled = true } }, [resolveChatCacheScope, hydrateSessionListCache]) // 同步 currentSessionId 到 ref useEffect(() => { currentSessionRef.current = currentSessionId isMessageListAtBottomRef.current = true topRangeLoadLockRef.current = false bottomRangeLoadLockRef.current = false setShowScrollToBottom(false) suppressScrollToBottomButton(260) }, [currentSessionId, suppressScrollToBottomButton]) const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => { const usernames = sessionList.map((s) => s.username).filter(Boolean) if (usernames.length === 0) return try { const result = await window.electronAPI.chat.getSessionStatuses(usernames) if (!result.success || !result.map) return const statusMap = result.map const { sessions: latestSessions } = useChatStore.getState() if (!Array.isArray(latestSessions) || latestSessions.length === 0) return let hasChanges = false const updatedSessions = latestSessions.map((session) => { const status = statusMap[session.username] if (!status) return session const nextIsFolded = status.isFolded ?? session.isFolded const nextIsMuted = status.isMuted ?? session.isMuted if (nextIsFolded === session.isFolded && nextIsMuted === session.isMuted) { return session } hasChanges = true return { ...session, isFolded: nextIsFolded, isMuted: nextIsMuted } }) if (hasChanges) { setSessions(updatedSessions) } } catch (e) { console.warn('会话状态补齐失败:', e) } }, [setSessions]) // 加载会话列表(优化:先返回基础数据,异步加载联系人信息) const loadSessions = async (options?: { silent?: boolean }) => { if (options?.silent) { setIsRefreshingSessions(true) } else { setLoadingSessions(true) } try { const scope = await resolveChatCacheScope() const result = await window.electronAPI.chat.getSessions() if (result.success && result.sessions) { // 确保 sessions 是数组 const sessionsArray = Array.isArray(result.sessions) ? result.sessions : [] const nextSessions = mergeSessions(sessionsArray) // 确保 nextSessions 也是数组 if (Array.isArray(nextSessions)) { setSessions(nextSessions) sessionsRef.current = nextSessions persistSessionListCache(scope, nextSessions) void hydrateSessionStatuses(nextSessions) // 立即启动联系人信息加载,不再延迟 500ms void enrichSessionsContactInfo(nextSessions) } else { console.error('mergeSessions returned non-array:', nextSessions) const fallbackSessions = sessionsArray.map((session) => mergeSessionContactPresentation(session)) setSessions(fallbackSessions) sessionsRef.current = fallbackSessions persistSessionListCache(scope, fallbackSessions) void hydrateSessionStatuses(fallbackSessions) void enrichSessionsContactInfo(fallbackSessions) } } else if (!result.success) { setConnectionError(result.error || '获取会话失败') } } catch (e) { console.error('加载会话失败:', e) setConnectionError('加载会话失败') } finally { if (options?.silent) { setIsRefreshingSessions(false) } else { setLoadingSessions(false) } } } // 分批异步加载联系人信息(优化:缓存优先 + 可持续队列 + 首屏优先批次) const enrichSessionsContactInfo = async (sessions: ChatSession[]) => { if (Array.isArray(sessions) && sessions.length > 0) { const now = Date.now() for (const session of sessions) { const username = String(session.username || '').trim() if (!username || isFoldPlaceholderSession(username)) continue const profileCache = sessionContactProfileCacheRef.current const cachedProfile = profileCache.get(username) if (cachedProfile && now - cachedProfile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { profileCache.delete(username) } const hasAvatar = Boolean(normalizeSearchAvatarUrl(session.avatarUrl)) const hasDisplayName = Boolean(resolveSessionDisplayName(session.displayName, username)) if (hasAvatar && hasDisplayName) continue const profile = profileCache.get(username) const profileHasAvatar = Boolean(normalizeSearchAvatarUrl(profile?.avatarUrl)) const profileHasDisplayName = Boolean(resolveSessionDisplayName(profile?.displayName, username)) if (profileHasAvatar && profileHasDisplayName) continue const lastAttemptAt = sessionContactEnrichAttemptAtRef.current.get(username) || 0 if (now - lastAttemptAt < SESSION_CONTACT_PROFILE_RETRY_INTERVAL_MS) continue pendingSessionContactEnrichRef.current.add(username) } } if (pendingSessionContactEnrichRef.current.size === 0) return if (isEnrichingRef.current) return isEnrichingRef.current = true enrichCancelledRef.current = false const totalStart = performance.now() const batchSize = 8 let processedBatchCount = 0 try { while (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { if (isScrollingRef.current) { while (isScrollingRef.current && !enrichCancelledRef.current) { await new Promise(resolve => setTimeout(resolve, 120)) } } if (enrichCancelledRef.current) break const usernames = Array.from(pendingSessionContactEnrichRef.current).slice(0, batchSize) if (usernames.length === 0) break usernames.forEach((username) => pendingSessionContactEnrichRef.current.delete(username)) const attemptAt = Date.now() usernames.forEach((username) => sessionContactEnrichAttemptAtRef.current.set(username, attemptAt)) const batchStart = performance.now() const shouldRunImmediately = processedBatchCount < 2 if (shouldRunImmediately) { await loadContactInfoBatch(usernames) } else { await new Promise((resolve) => { if ('requestIdleCallback' in window) { window.requestIdleCallback(() => { void loadContactInfoBatch(usernames).finally(resolve) }, { timeout: 700 }) } else { setTimeout(() => { void loadContactInfoBatch(usernames).finally(resolve) }, 80) } }) } processedBatchCount += 1 const batchTime = performance.now() - batchStart if (batchTime > 200) { console.warn(`[性能监控] 联系人批次 ${processedBatchCount} 耗时: ${batchTime.toFixed(2)}ms, batch=${usernames.length}`) } if (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { const delay = isScrollingRef.current ? 220 : 90 await new Promise(resolve => setTimeout(resolve, delay)) } } const totalTime = performance.now() - totalStart if (totalTime > 500) { console.info(`[性能监控] 联系人补齐总耗时: ${totalTime.toFixed(2)}ms`) } } catch (e) { console.error('加载联系人信息失败:', e) } finally { isEnrichingRef.current = false if (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { void enrichSessionsContactInfo([]) } } } // 联系人信息更新队列(防抖批量更新,避免频繁重渲染) const contactUpdateQueueRef = useRef>(new Map()) const contactUpdateTimerRef = useRef(null) const lastUpdateTimeRef = useRef(0) // 批量更新联系人信息(防抖,减少重渲染次数,增加延迟避免阻塞滚动) const flushContactUpdates = useCallback(() => { if (contactUpdateTimerRef.current) { clearTimeout(contactUpdateTimerRef.current) contactUpdateTimerRef.current = null } // 使用短防抖,让头像和昵称更快补齐但依然避免频繁重渲染 contactUpdateTimerRef.current = window.setTimeout(() => { const updates = contactUpdateQueueRef.current if (updates.size === 0) return const now = Date.now() // 如果距离上次更新太近(小于250ms),继续延迟 if (now - lastUpdateTimeRef.current < 250) { contactUpdateTimerRef.current = window.setTimeout(() => { flushContactUpdates() }, 250 - (now - lastUpdateTimeRef.current)) return } const { sessions: currentSessions } = useChatStore.getState() if (!Array.isArray(currentSessions)) return let hasChanges = false const updatedSessions = currentSessions.map(session => { const update = updates.get(session.username) if (update) { const newDisplayName = update.displayName || session.displayName || session.username const newAvatarUrl = update.avatarUrl || session.avatarUrl const newAlias = update.alias || session.alias if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl || newAlias !== session.alias) { hasChanges = true return { ...session, displayName: newDisplayName, avatarUrl: newAvatarUrl, alias: newAlias } } } return session }) if (hasChanges) { const updateStart = performance.now() setSessions(updatedSessions) sessionsRef.current = updatedSessions lastUpdateTimeRef.current = Date.now() const updateTime = performance.now() - updateStart if (updateTime > 50) { console.warn(`[性能监控] setSessions更新耗时: ${updateTime.toFixed(2)}ms, 更新了 ${updates.size} 个联系人`) } } updates.clear() contactUpdateTimerRef.current = null }, 120) }, [setSessions]) // 加载一批联系人信息并更新会话列表(优化:使用队列批量更新) const loadContactInfoBatch = async (usernames: string[]) => { const startTime = performance.now() try { // 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate) await new Promise(resolve => setTimeout(resolve, 0)) const dllStart = performance.now() const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) as { success: boolean contacts?: Record error?: string } const dllTime = performance.now() - dllStart // DLL 调用后再次让出控制权 await new Promise(resolve => setTimeout(resolve, 0)) const totalTime = performance.now() - startTime if (dllTime > 50 || totalTime > 100) { console.warn(`[性能监控] DLL调用耗时: ${dllTime.toFixed(2)}ms, 总耗时: ${totalTime.toFixed(2)}ms, usernames: ${usernames.length}`) } if (result.success && result.contacts) { // 将更新加入队列,用于侧边栏更新 const contacts = result.contacts || {} for (const [username, contact] of Object.entries(contacts)) { const normalizedDisplayName = resolveSessionDisplayName(contact.displayName, username) || contact.displayName const normalizedAvatarUrl = normalizeSearchAvatarUrl(contact.avatarUrl) const normalizedAlias = normalizeSearchIdentityText(contact.alias) contactUpdateQueueRef.current.set(username, { displayName: normalizedDisplayName, avatarUrl: normalizedAvatarUrl, alias: normalizedAlias }) if (normalizedDisplayName || normalizedAvatarUrl || normalizedAlias) { sessionContactProfileCacheRef.current.set(username, { displayName: normalizedDisplayName, avatarUrl: normalizedAvatarUrl, alias: normalizedAlias, updatedAt: Date.now() }) } // 如果是自己的信息且当前个人头像为空,同步更新 if (myWxid && username === myWxid && normalizedAvatarUrl && !myAvatarUrl) { setMyAvatarUrl(normalizedAvatarUrl) } // 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用 senderAvatarCache.set(username, { avatarUrl: normalizedAvatarUrl, displayName: normalizedDisplayName }) } // 触发批量更新 flushContactUpdates() } } catch (e) { console.error('加载联系人信息批次失败:', e) } } // 刷新会话列表 const handleRefresh = async () => { setJumpStartTime(0) setJumpEndTime(0) setHasMoreLater(false) await loadSessions({ silent: true }) } // 刷新当前会话消息(增量更新新消息) const [isRefreshingMessages, setIsRefreshingMessages] = useState(false) /** * 极速增量刷新:基于最后一条消息时间戳,获取后续新消息 * (由用户建议:记住上一条消息时间,自动取之后的并渲染,然后后台兜底全量同步) */ const handleIncrementalRefresh = async () => { if (!currentSessionId || isRefreshingRef.current) return isRefreshingRef.current = true setIsRefreshingMessages(true) // 找出当前已渲染消息中的最大时间戳(使用 getState 获取最新状态,避免闭包过时导致重复) const currentMessages = useChatStore.getState().messages || [] const lastMsg = currentMessages[currentMessages.length - 1] const minTime = lastMsg?.createTime || 0 // 1. 优先执行增量查询并渲染(第一步) try { const result = await (window.electronAPI.chat as any).getNewMessages(currentSessionId, minTime) as { success: boolean; messages?: Message[]; error?: string } if (result.success && result.messages && result.messages.length > 0) { // 过滤去重:必须对比实时的状态,防止在 handleRefreshMessages 运行期间导致的冲突 const latestMessages = useChatStore.getState().messages || [] const existingKeys = new Set(latestMessages.map(getMessageKey)) const newOnes = result.messages.filter(m => !existingKeys.has(getMessageKey(m))) if (newOnes.length > 0) { appendMessages(newOnes, false) flashNewMessages(newOnes.map(getMessageKey)) // 滚动到底部 requestAnimationFrame(() => { const latestMessages = useChatStore.getState().messages || [] const lastIndex = latestMessages.length - 1 if (lastIndex >= 0 && messageVirtuosoRef.current) { messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) } else if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) } } } catch (e) { console.warn('[IncrementalRefresh] 失败,将依赖全量同步兜底:', e) } finally { isRefreshingRef.current = false setIsRefreshingMessages(false) } } const handleRefreshMessages = async () => { if (!currentSessionId || isRefreshingRef.current) return setJumpStartTime(0) setJumpEndTime(0) setHasMoreLater(false) setIsRefreshingMessages(true) isRefreshingRef.current = true try { // 获取最新消息并增量添加 const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) as { success: boolean; messages?: Message[]; error?: string } if (!result.success || !result.messages) { return } // 使用实时状态进行去重对比 const latestMessages = useChatStore.getState().messages || [] const existing = new Set(latestMessages.map(getMessageKey)) const lastMsg = latestMessages[latestMessages.length - 1] const lastTime = lastMsg?.createTime ?? 0 const newMessages = result.messages.filter((msg) => { const key = getMessageKey(msg) if (existing.has(key)) return false // 这里的 lastTime 仅作参考过滤,主要的去重靠 key if (lastTime > 0 && msg.createTime < lastTime - 3600) return false // 仅过滤 1 小时之前的冗余请求 return true }) if (newMessages.length > 0) { appendMessages(newMessages, false) flashNewMessages(newMessages.map(getMessageKey)) // 滚动到底部 requestAnimationFrame(() => { const currentMessages = useChatStore.getState().messages || [] const lastIndex = currentMessages.length - 1 if (lastIndex >= 0 && messageVirtuosoRef.current) { messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) } else if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) } } catch (e) { console.error('刷新消息失败:', e) } finally { isRefreshingRef.current = false setIsRefreshingMessages(false) } } // 消息批量大小控制(保持稳定,避免游标反复重建) const currentBatchSizeRef = useRef(50) const warmupGroupSenderProfiles = useCallback((usernames: string[], defer = false) => { if (!Array.isArray(usernames) || usernames.length === 0) return const runWarmup = () => { const batchPromise = loadContactInfoBatch(usernames) usernames.forEach(username => { if (!senderAvatarLoading.has(username)) { senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null)) } }) batchPromise.finally(() => { usernames.forEach(username => senderAvatarLoading.delete(username)) }) } if (defer) { if ('requestIdleCallback' in window) { window.requestIdleCallback(() => { runWarmup() }, { timeout: 1200 }) } else { globalThis.setTimeout(runWarmup, 120) } return } runWarmup() }, [loadContactInfoBatch]) // 加载消息 const loadMessages = async ( sessionId: string, offset = 0, startTime = 0, endTime = 0, ascending = false, options: LoadMessagesOptions = {} ) => { const listEl = messageListRef.current const session = sessionMapRef.current.get(sessionId) const unreadCount = session?.unreadCount ?? 0 let messageLimit = currentBatchSizeRef.current if (offset === 0) { const preferredLimit = Number.isFinite(options.forceInitialLimit) ? Math.max(10, Math.floor(options.forceInitialLimit as number)) : (unreadCount > 99 ? 30 : 40) currentBatchSizeRef.current = preferredLimit messageLimit = preferredLimit } else { // 同一会话内保持固定批量,避免后端游标因 batch 改变而重建 messageLimit = currentBatchSizeRef.current } if (offset === 0) { suppressScrollToBottomButton(260) setShowScrollToBottom(false) setLoadingMessages(true) // 切会话时保留旧内容作为过渡,避免大面积闪烁 setHasInitialMessages(true) } else { setLoadingMore(true) } const visibleRange = visibleMessageRangeRef.current const visibleStartIndex = Math.min( Math.max(visibleRange.startIndex, 0), Math.max(messages.length - 1, 0) ) const anchorMessageKeyBeforePrepend = offset > 0 && messages.length > 0 ? getMessageKey(messages[visibleStartIndex]) : null // 记录加载前的第一条消息元素(非虚拟列表回退路径) const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null try { const useLatestPath = offset === 0 && startTime === 0 && endTime === 0 && !ascending && options.preferLatestPath const result = (useLatestPath ? await window.electronAPI.chat.getLatestMessages(sessionId, messageLimit) : await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime, ascending) ) as { success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string } const isStaleSwitchRequest = Boolean( options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current ) const isStaleInSessionJumpRequest = Boolean( options.inSessionJumpRequestSeq && options.inSessionJumpRequestSeq !== inSessionResultJumpRequestSeqRef.current ) if (isStaleSwitchRequest || isStaleInSessionJumpRequest) { return } if (options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current) { return } if (currentSessionRef.current !== sessionId) { return } if (result.success && result.messages) { const resultMessages = result.messages if (offset === 0) { setMessages(resultMessages) persistSessionPreviewCache(sessionId, resultMessages) if (resultMessages.length === 0) { setNoMessageTable(true) setHasMoreMessages(false) } // 群聊发送者信息补齐改为非阻塞执行,避免影响首屏切换 const isGroup = sessionId.includes('@chatroom') if (isGroup && resultMessages.length > 0) { const unknownSenders = [...new Set(resultMessages .filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername)) .map(m => m.senderUsername as string) )] if (unknownSenders.length > 0) { warmupGroupSenderProfiles(unknownSenders, options.deferGroupSenderWarmup === true) } } // 日期跳转时滚动到顶部,否则滚动到底部 const loadedMessages = result.messages requestAnimationFrame(() => { if (isDateJumpRef.current) { if (messageVirtuosoRef.current && resultMessages.length > 0) { messageVirtuosoRef.current.scrollToIndex({ index: 0, align: 'start', behavior: 'auto' }) } else if (messageListRef.current) { messageListRef.current.scrollTop = 0 } isDateJumpRef.current = false return } const lastIndex = resultMessages.length - 1 if (lastIndex >= 0 && messageVirtuosoRef.current) { messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) } else if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) } else { appendMessages(resultMessages, true) // 加载更多也同样处理发送者信息预取 const isGroup = sessionId.includes('@chatroom') if (isGroup) { const unknownSenders = [...new Set(resultMessages .filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername)) .map(m => m.senderUsername as string) )] if (unknownSenders.length > 0) { warmupGroupSenderProfiles(unknownSenders, false) } } // 加载更早消息后保持视口锚点,避免跳屏 const appendedMessages = result.messages requestAnimationFrame(() => { if (messageVirtuosoRef.current) { if (anchorMessageKeyBeforePrepend) { const latestMessages = useChatStore.getState().messages || [] const anchorIndex = latestMessages.findIndex((msg) => getMessageKey(msg) === anchorMessageKeyBeforePrepend) if (anchorIndex >= 0) { messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' }) return } } if (resultMessages.length > 0) { messageVirtuosoRef.current.scrollToIndex({ index: resultMessages.length, align: 'start', behavior: 'auto' }) } return } if (firstMsgEl && listEl) { listEl.scrollTop = firstMsgEl.offsetTop - 80 } }) } // 日期跳转(ascending=true):不往上加载更早的,往下加载更晚的 if (ascending) { setHasMoreMessages(false) setHasMoreLater(result.hasMore ?? false) } else { setHasMoreMessages(result.hasMore ?? false) if (offset === 0) { if (endTime > 0) { setHasMoreLater(true) } else { setHasMoreLater(false) } } } const nextOffset = typeof result.nextOffset === 'number' ? result.nextOffset : offset + resultMessages.length setCurrentOffset(nextOffset) } else if (!result.success) { setNoMessageTable(true) setHasMoreMessages(false) } } catch (e) { console.error('加载消息失败:', e) setConnectionError('加载消息失败') setHasMoreMessages(false) if (offset === 0 && currentSessionRef.current === sessionId) { setMessages([]) } } finally { setLoadingMessages(false) setLoadingMore(false) if (offset === 0 && pendingSessionLoadRef.current === sessionId) { if (!options.switchRequestSeq || options.switchRequestSeq === sessionSwitchRequestSeqRef.current) { pendingSessionLoadRef.current = null initialLoadRequestedSessionRef.current = null setIsSessionSwitching(false) // 处理从全局搜索跳转过来的情况 const pendingSearch = pendingInSessionSearchRef.current if (pendingSearch?.sessionId === sessionId) { pendingInSessionSearchRef.current = null void applyPendingInSessionSearch(sessionId, pendingSearch, options.switchRequestSeq) } } } } } 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) // 日期跳转采用“锚点定位”而非“当天过滤”: // 先定位到当日附近,再允许上下滚动跨天浏览。 isDateJumpRef.current = false setCurrentOffset(0) setJumpStartTime(0) setJumpEndTime(end) setShowJumpPopover(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 cancelInSessionSearchJump = useCallback(() => { inSessionResultJumpRequestSeqRef.current += 1 if (inSessionResultJumpTimerRef.current) { window.clearTimeout(inSessionResultJumpTimerRef.current) inSessionResultJumpTimerRef.current = null } }, []) 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, isGroupSearchSession, resolvedSessionDisplayName, resolvedSessionAvatarUrl } = resolveSearchSessionContext(sessionId) const resolvedSessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId) return sortedMessages.map((message) => { const senderUsername = normalizeSearchIdentityText(message.senderUsername) || message.senderUsername const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(senderUsername, myWxid) const senderDisplayName = resolveSearchSenderDisplayName( message.senderDisplayName, senderUsername, normalizedSessionId ) const senderUsernameFallback = resolveSearchSenderUsernameFallback(senderUsername) const senderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl) const nextIsSend = inferredSelfFromSender ? 1 : message.isSend const nextSenderDisplayName = nextIsSend === 1 ? (senderDisplayName || '我') : ( senderDisplayName || (isDirectSearchSession ? resolvedSessionDisplayName : undefined) || senderUsernameFallback || (isDirectSearchSession ? resolvedSessionUsernameFallback : undefined) || '未知' ) const nextSenderAvatarUrl = nextIsSend === 1 ? (senderAvatarUrl || myAvatarUrl) : (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)) if ( senderUsername === message.senderUsername && nextIsSend === message.isSend && nextSenderDisplayName === message.senderDisplayName && nextSenderAvatarUrl === message.senderAvatarUrl ) { return message } return { ...message, isSend: nextIsSend, senderUsername, senderDisplayName: nextSenderDisplayName, senderAvatarUrl: nextSenderAvatarUrl } }) }, [currentSessionId, myAvatarUrl, myWxid, resolveSearchSessionContext]) const enrichMessagesWithSenderProfiles = useCallback(async (rawMessages: Message[], sessionId?: string) => { let messages = hydrateInSessionSearchResults(rawMessages, sessionId) if (messages.length === 0) return [] const sessionContext = resolveSearchSessionContext(sessionId) const { normalizedSessionId, isDirectSearchSession, isGroupSearchSession } = sessionContext let resolvedSessionDisplayName = sessionContext.resolvedSessionDisplayName let resolvedSessionAvatarUrl = sessionContext.resolvedSessionAvatarUrl if ( normalizedSessionId && isDirectSearchSession && ( !resolvedSessionAvatarUrl || !resolvedSessionDisplayName || resolvedSessionDisplayName === normalizedSessionId ) ) { try { const result = await window.electronAPI.chat.enrichSessionsContactInfo([normalizedSessionId]) const profile = result.success && result.contacts ? result.contacts[normalizedSessionId] : undefined const profileDisplayName = resolveSearchSenderDisplayName( profile?.displayName, normalizedSessionId, normalizedSessionId ) const profileAvatarUrl = normalizeSearchAvatarUrl(profile?.avatarUrl) if (profileDisplayName) { resolvedSessionDisplayName = profileDisplayName } if (profileAvatarUrl) { resolvedSessionAvatarUrl = profileAvatarUrl } if (profileDisplayName || profileAvatarUrl) { messages = messages.map((message) => { if (message.isSend === 1) return message const preservedDisplayName = resolveSearchSenderDisplayName( message.senderDisplayName, message.senderUsername, normalizedSessionId ) return { ...message, senderDisplayName: preservedDisplayName || profileDisplayName || resolvedSessionDisplayName || resolveSearchSenderUsernameFallback(message.senderUsername) || message.senderDisplayName, senderAvatarUrl: normalizeSearchAvatarUrl(message.senderAvatarUrl) || profileAvatarUrl || resolvedSessionAvatarUrl || message.senderAvatarUrl } }) } } catch { // ignore session profile enrichment errors and keep raw search results usable } } if (normalizedSessionId && isGroupSearchSession) { const missingSenderMessages = messages.filter((message) => { if (message.localId <= 0) return false if (message.isSend === 1) return false return !normalizeSearchIdentityText(message.senderUsername) }) if (missingSenderMessages.length > 0) { const messageByLocalId = new Map() for (let index = 0; index < missingSenderMessages.length; index += 8) { const batch = missingSenderMessages.slice(index, index + 8) const detailResults = await Promise.allSettled( batch.map(async (message) => { const result = await window.electronAPI.chat.getMessage(normalizedSessionId, message.localId) if (!result.success || !result.message) return null return { localId: message.localId, message: hydrateInSessionSearchResults([{ ...message, ...result.message, parsedContent: message.parsedContent || result.message.parsedContent, rawContent: message.rawContent || result.message.rawContent, content: message.content || result.message.content } as Message], normalizedSessionId)[0] } }) ) for (const detail of detailResults) { if (detail.status !== 'fulfilled' || !detail.value?.message) continue messageByLocalId.set(detail.value.localId, detail.value.message) } } if (messageByLocalId.size > 0) { messages = messages.map(message => messageByLocalId.get(message.localId) || message) } } } const profileMap = new Map() const pendingLoads: Array> = [] const missingUsernames: string[] = [] const usernames = [...new Set( messages .map((message) => normalizeSearchIdentityText(message.senderUsername)) .filter((username): username is string => Boolean(username)) )] for (const username of usernames) { const cached = senderAvatarCache.get(username) if (cached) { profileMap.set(username, cached) continue } const pending = senderAvatarLoading.get(username) if (pending) { pendingLoads.push( pending.then((profile) => { if (profile) { profileMap.set(username, profile) } }).catch(() => {}) ) continue } missingUsernames.push(username) } if (pendingLoads.length > 0) { await Promise.allSettled(pendingLoads) } if (missingUsernames.length > 0) { try { const result = await window.electronAPI.chat.enrichSessionsContactInfo(missingUsernames) if (result.success && result.contacts) { for (const [username, profile] of Object.entries(result.contacts)) { const normalizedProfile = { avatarUrl: profile.avatarUrl, displayName: profile.displayName } profileMap.set(username, normalizedProfile) senderAvatarCache.set(username, normalizedProfile) } } } catch { // ignore sender enrichment errors and keep raw search results usable } } return messages.map((message) => { const sender = normalizeSearchIdentityText(message.senderUsername) const profile = sender ? profileMap.get(sender) : undefined const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(sender, myWxid) 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 nextIsSend = inferredSelfFromSender ? 1 : message.isSend const nextSenderDisplayName = nextIsSend === 1 ? (currentSenderDisplayName || profileDisplayName || '我') : ( profileDisplayName || currentSenderDisplayName || (isDirectSearchSession ? resolvedSessionDisplayName : undefined) || senderUsernameFallback || (isDirectSearchSession ? sessionUsernameFallback : undefined) || '未知' ) const nextSenderAvatarUrl = nextIsSend === 1 ? (currentSenderAvatarUrl || myAvatarUrl || normalizeSearchAvatarUrl(profile?.avatarUrl)) : ( currentSenderAvatarUrl || normalizeSearchAvatarUrl(profile?.avatarUrl) || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined) ) if ( sender === message.senderUsername && nextIsSend === message.isSend && nextSenderDisplayName === message.senderDisplayName && nextSenderAvatarUrl === message.senderAvatarUrl ) { return message } return { ...message, isSend: nextIsSend, senderUsername: sender || message.senderUsername, senderDisplayName: nextSenderDisplayName, senderAvatarUrl: nextSenderAvatarUrl } }) }, [ currentSessionId, hydrateInSessionSearchResults, myAvatarUrl, myWxid, 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(() => { // ignore sender enrichment errors and keep current search results usable }).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 setLoadingMore(true) try { const lastMsg = messages[messages.length - 1] // 从最后一条消息的时间开始往后找 const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) as { success: boolean; messages?: Message[]; hasMore?: boolean; error?: string } if (result.success && result.messages) { // 过滤掉已经在列表中的重复消息 const existingKeys = messageKeySetRef.current const newMsgs = result.messages.filter(m => !existingKeys.has(getMessageKey(m))) if (newMsgs.length > 0) { appendMessages(newMsgs, false) } setHasMoreLater(result.hasMore ?? false) } } catch (e) { console.error('加载后续消息失败:', e) } finally { setLoadingMore(false) } }, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore]) const refreshSessionIncrementally = useCallback(async (sessionId: string, switchRequestSeq?: number) => { const currentMessages = useChatStore.getState().messages || [] const lastMsg = currentMessages[currentMessages.length - 1] const minTime = lastMsg?.createTime || 0 if (!sessionId || minTime <= 0) return try { const result = await window.electronAPI.chat.getNewMessages(sessionId, minTime, 120) as { success: boolean messages?: Message[] error?: string } if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return if (currentSessionRef.current !== sessionId) return if (!result.success || !Array.isArray(result.messages) || result.messages.length === 0) return const latestMessages = useChatStore.getState().messages || [] const existing = new Set(latestMessages.map(getMessageKey)) const newMessages = result.messages.filter((msg) => !existing.has(getMessageKey(msg))) if (newMessages.length > 0) { appendMessages(newMessages, false) } } catch (error) { console.warn('[SessionCache] 增量刷新失败:', error) } }, [appendMessages, getMessageKey]) // 选择会话 const selectSessionById = useCallback((sessionId: string, options: { force?: boolean } = {}) => { const normalizedSessionId = String(sessionId || '').trim() 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() cancelInSessionSearchJump() // 清空会话内搜索状态(除非是从全局搜索跳转过来) if (!shouldPreservePendingSearch) { pendingInSessionSearchRef.current = null setShowInSessionSearch(false) setInSessionQuery('') setInSessionResults([]) setInSessionSearchError(null) } setCurrentSession(normalizedSessionId, { preserveMessages: false }) setNoMessageTable(false) const restoredFromWindowCache = restoreSessionWindowCache(normalizedSessionId) if (restoredFromWindowCache) { pendingSessionLoadRef.current = null initialLoadRequestedSessionRef.current = null setIsSessionSwitching(false) // 处理从全局搜索跳转过来的情况 if (pendingSearch?.sessionId === normalizedSessionId) { pendingInSessionSearchRef.current = null void applyPendingInSessionSearch(normalizedSessionId, pendingSearch, switchRequestSeq) } void refreshSessionIncrementally(normalizedSessionId, switchRequestSeq) } else { pendingSessionLoadRef.current = normalizedSessionId initialLoadRequestedSessionRef.current = normalizedSessionId setIsSessionSwitching(true) void hydrateSessionPreview(normalizedSessionId) setCurrentOffset(0) setJumpStartTime(0) setJumpEndTime(0) void loadMessages(normalizedSessionId, 0, 0, 0, false, { preferLatestPath: true, deferGroupSenderWarmup: true, forceInitialLimit: 30, switchRequestSeq }) } // 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开 setShowJumpPopover(false) setShowDetailPanel(false) setShowGroupMembersPanel(false) setGroupMemberSearchKeyword('') setGroupMembersError(null) setGroupMembersLoadingHint('') setIsRefreshingGroupMembers(false) groupMembersRequestSeqRef.current += 1 setIsLoadingGroupMembers(false) setSessionDetail(null) setIsRefreshingDetailStats(false) setIsLoadingRelationStats(false) }, [ currentSessionId, setCurrentSession, restoreSessionWindowCache, refreshSessionIncrementally, hydrateSessionPreview, loadMessages, cancelInSessionSearchJump, cancelInSessionSearchTasks, applyPendingInSessionSearch ]) // 选择会话 const handleSelectSession = (session: ChatSession) => { // 点击折叠群入口,切换到折叠群视图 if (session.username.toLowerCase().includes('placeholder_foldgroup')) { setFoldedView(true) return } selectSessionById(session.username) } // 搜索过滤 const handleSearch = (keyword: string) => { setSearchKeyword(keyword) } // 关闭搜索框 const handleCloseSearch = () => { setSearchKeyword('') } // 会话内搜索 const inSessionSearchTimerRef = useRef | null>(null) const inSessionSearchGenRef = useRef(0) 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 () => { if (gen !== inSessionSearchGenRef.current) return setInSessionSearching(true) try { const res = await window.electronAPI.chat.searchMessages(keyword.trim(), sid, 50, 0) 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) setInSessionEnriching(true) void enrichMessagesWithSenderProfiles(messages, sid).then((enriched) => { if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return setInSessionResults(enriched) }).catch(() => { // ignore sender enrichment errors and keep current search results usable }).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, enrichMessagesWithSenderProfiles, hydrateInSessionSearchResults]) const handleToggleInSessionSearch = useCallback(() => { setShowInSessionSearch(v => { if (v) { cancelInSessionSearchTasks() cancelInSessionSearchJump() setInSessionQuery('') setInSessionResults([]) setInSessionSearchError(null) } else { setTimeout(() => inSessionSearchRef.current?.focus(), 50) } return !v }) }, [cancelInSessionSearchJump, cancelInSessionSearchTasks]) // 全局消息搜索 const globalMsgSearchTimerRef = useRef | null>(null) const globalMsgSearchGenRef = useRef(0) const ensureGlobalMsgSearchNotStale = useCallback((gen: number) => { if (gen !== globalMsgSearchGenRef.current) { throw new Error(GLOBAL_MSG_SEARCH_CANCELED_ERROR) } }, []) const runLegacyGlobalMsgSearch = useCallback(async ( keyword: string, sessionList: ChatSession[], gen: number ): Promise => { const results: GlobalMsgSearchResult[] = [] for (let index = 0; index < sessionList.length; index += GLOBAL_MSG_LEGACY_CONCURRENCY) { ensureGlobalMsgSearchNotStale(gen) const chunk = sessionList.slice(index, index + GLOBAL_MSG_LEGACY_CONCURRENCY) const chunkResults = await Promise.allSettled( chunk.map(async (session) => { const res = await window.electronAPI.chat.searchMessages(keyword, session.username, GLOBAL_MSG_PER_SESSION_LIMIT, 0) if (!res?.success) { throw new Error(res?.error || `搜索失败: ${session.username}`) } return normalizeGlobalMsgSearchMessages(res?.messages || [], session.username) }) ) ensureGlobalMsgSearchNotStale(gen) 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) } } } return sortMessagesByCreateTimeDesc(results) }, [ensureGlobalMsgSearchNotStale]) const compareGlobalMsgSearchShadow = useCallback(( keyword: string, stagedResults: GlobalMsgSearchResult[], legacyResults: GlobalMsgSearchResult[] ) => { const stagedMap = buildGlobalMsgSearchSessionLocalIds(stagedResults) const legacyMap = buildGlobalMsgSearchSessionLocalIds(legacyResults) const stagedSessions = Object.keys(stagedMap).sort() const legacySessions = Object.keys(legacyMap).sort() let mismatch = stagedSessions.length !== legacySessions.length if (!mismatch) { for (let i = 0; i < stagedSessions.length; i += 1) { if (stagedSessions[i] !== legacySessions[i]) { mismatch = true break } } } if (!mismatch) { for (const sessionId of stagedSessions) { const stagedIds = stagedMap[sessionId] || [] const legacyIds = legacyMap[sessionId] || [] if (stagedIds.length !== legacyIds.length) { mismatch = true break } for (let i = 0; i < stagedIds.length; i += 1) { if (stagedIds[i] !== legacyIds[i]) { mismatch = true break } } if (mismatch) break } } if (!mismatch) { const stagedOrder = stagedResults.map((row) => `${row.sessionId}:${row.localId || 0}:${row.messageKey || ''}`) const legacyOrder = legacyResults.map((row) => `${row.sessionId}:${row.localId || 0}:${row.messageKey || ''}`) if (stagedOrder.length !== legacyOrder.length) { mismatch = true } else { for (let i = 0; i < stagedOrder.length; i += 1) { if (stagedOrder[i] !== legacyOrder[i]) { mismatch = true break } } } } if (!mismatch) return console.warn('[GlobalMsgSearch] shadow compare mismatch', { keyword, stagedSessionCount: stagedSessions.length, legacySessionCount: legacySessions.length, stagedResultCount: stagedResults.length, legacyResultCount: legacyResults.length, stagedMap, legacyMap }) }, []) 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 (!normalizedKeyword) { pendingGlobalMsgSearchReplayRef.current = null globalMsgPrefixCacheRef.current = null setGlobalMsgResults([]) setGlobalMsgSearchError(null) setShowGlobalMsgSearch(false) setGlobalMsgSearching(false) setGlobalMsgSearchPhase('idle') setGlobalMsgIsBackfilling(false) setGlobalMsgAuthoritativeSessionCount(0) return } setShowGlobalMsgSearch(true) setGlobalMsgSearchError(null) setGlobalMsgSearchPhase('seed') setGlobalMsgIsBackfilling(false) setGlobalMsgAuthoritativeSessionCount(0) 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) setGlobalMsgSearchPhase('idle') setGlobalMsgIsBackfilling(false) setGlobalMsgAuthoritativeSessionCount(0) return } pendingGlobalMsgSearchReplayRef.current = null const gen = globalMsgSearchGenRef.current globalMsgSearchTimerRef.current = setTimeout(async () => { if (gen !== globalMsgSearchGenRef.current) return setGlobalMsgSearching(true) setGlobalMsgSearchPhase('seed') setGlobalMsgIsBackfilling(false) setGlobalMsgAuthoritativeSessionCount(0) try { ensureGlobalMsgSearchNotStale(gen) const seedResponse = await window.electronAPI.chat.searchMessages(normalizedKeyword, undefined, GLOBAL_MSG_SEED_LIMIT, 0) if (!seedResponse?.success) { throw new Error(seedResponse?.error || '搜索失败') } ensureGlobalMsgSearchNotStale(gen) const seedRows = normalizeGlobalMsgSearchMessages(seedResponse?.messages || []) const seedMap = buildGlobalMsgSearchSessionMap(seedRows) const authoritativeMap = new Map() setGlobalMsgResults(composeGlobalMsgSearchResults(seedMap, authoritativeMap)) setGlobalMsgSearchError(null) setGlobalMsgSearchPhase('backfill') setGlobalMsgIsBackfilling(true) const previousPrefixCache = globalMsgPrefixCacheRef.current const previousKeyword = String(previousPrefixCache?.keyword || '').trim() const canUsePrefixCache = Boolean( previousPrefixCache && previousPrefixCache.completed && previousKeyword && normalizedKeyword.startsWith(previousKeyword) ) let targetSessionList = canUsePrefixCache ? sessionList.filter((session) => previousPrefixCache?.matchedSessionIds.has(session.username)) : sessionList if (canUsePrefixCache && previousPrefixCache) { let foundOutsidePrefix = false for (const sessionId of seedMap.keys()) { if (!previousPrefixCache.matchedSessionIds.has(sessionId)) { foundOutsidePrefix = true break } } if (foundOutsidePrefix) { targetSessionList = sessionList } } for (let index = 0; index < targetSessionList.length; index += GLOBAL_MSG_BACKFILL_CONCURRENCY) { ensureGlobalMsgSearchNotStale(gen) const chunk = targetSessionList.slice(index, index + GLOBAL_MSG_BACKFILL_CONCURRENCY) const chunkResults = await Promise.allSettled( chunk.map(async (session) => { const res = await window.electronAPI.chat.searchMessages(normalizedKeyword, session.username, GLOBAL_MSG_PER_SESSION_LIMIT, 0) if (!res?.success) { throw new Error(res?.error || `搜索失败: ${session.username}`) } return { sessionId: session.username, messages: normalizeGlobalMsgSearchMessages(res?.messages || [], session.username) } }) ) ensureGlobalMsgSearchNotStale(gen) for (const item of chunkResults) { if (item.status === 'rejected') { throw item.reason instanceof Error ? item.reason : new Error(String(item.reason)) } authoritativeMap.set(item.value.sessionId, item.value.messages) } setGlobalMsgAuthoritativeSessionCount(authoritativeMap.size) setGlobalMsgResults(composeGlobalMsgSearchResults(seedMap, authoritativeMap)) } ensureGlobalMsgSearchNotStale(gen) const finalResults = composeGlobalMsgSearchResults(seedMap, authoritativeMap) setGlobalMsgResults(finalResults) setGlobalMsgSearchError(null) setGlobalMsgSearchPhase('done') setGlobalMsgIsBackfilling(false) const matchedSessionIds = new Set() for (const row of finalResults) { matchedSessionIds.add(row.sessionId) } globalMsgPrefixCacheRef.current = { keyword: normalizedKeyword, matchedSessionIds, completed: true } if (shouldRunGlobalMsgShadowCompareSample()) { void (async () => { try { const legacyResults = await runLegacyGlobalMsgSearch(normalizedKeyword, sessionList, gen) if (gen !== globalMsgSearchGenRef.current) return compareGlobalMsgSearchShadow(normalizedKeyword, finalResults, legacyResults) } catch (error) { if (isGlobalMsgSearchCanceled(error)) return console.warn('[GlobalMsgSearch] shadow compare failed:', error) } })() } } catch (error) { if (isGlobalMsgSearchCanceled(error)) return if (gen !== globalMsgSearchGenRef.current) return setGlobalMsgResults([]) setGlobalMsgSearchError(error instanceof Error ? error.message : String(error)) setGlobalMsgSearchPhase('done') setGlobalMsgIsBackfilling(false) setGlobalMsgAuthoritativeSessionCount(0) globalMsgPrefixCacheRef.current = null } finally { if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false) } }, 500) }, [compareGlobalMsgSearchShadow, ensureGlobalMsgSearchNotStale, runLegacyGlobalMsgSearch]) const handleCloseGlobalMsgSearch = useCallback(() => { globalMsgSearchGenRef.current += 1 if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current) globalMsgSearchTimerRef.current = null pendingGlobalMsgSearchReplayRef.current = null globalMsgPrefixCacheRef.current = null setShowGlobalMsgSearch(false) setGlobalMsgQuery('') setGlobalMsgResults([]) setGlobalMsgSearchError(null) setGlobalMsgSearching(false) setGlobalMsgSearchPhase('idle') setGlobalMsgIsBackfilling(false) setGlobalMsgAuthoritativeSessionCount(0) }, []) const handleMessageRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { visibleMessageRangeRef.current = range const total = messages.length const shouldWarmupVisibleGroupSenders = Boolean( currentSessionId && ( isGroupChatSession(currentSessionId) || ( standaloneSessionWindow && normalizedInitialSessionId && currentSessionId === normalizedInitialSessionId && normalizedStandaloneInitialContactType === 'group' ) ) ) if (total <= 0) { isMessageListAtBottomRef.current = true setShowScrollToBottom(prev => (prev ? false : prev)) return } if (range.endIndex >= Math.max(total - 2, 0)) { isMessageListAtBottomRef.current = true setShowScrollToBottom(prev => (prev ? false : prev)) } if ( range.startIndex <= 2 && !topRangeLoadLockRef.current && !isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId ) { topRangeLoadLockRef.current = true void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) } if ( range.endIndex >= total - 3 && !bottomRangeLoadLockRef.current && !suppressAutoLoadLaterRef.current && !isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId ) { bottomRangeLoadLockRef.current = true void loadLaterMessages() } if (shouldWarmupVisibleGroupSenders) { const now = Date.now() if (now - lastVisibleSenderWarmupAtRef.current >= 180) { lastVisibleSenderWarmupAtRef.current = now const latestMessages = useChatStore.getState().messages || [] const visibleStart = Math.max(range.startIndex - 12, 0) const visibleEnd = Math.min(range.endIndex + 20, total - 1) const pendingUsernames = new Set() for (let index = visibleStart; index <= visibleEnd; index += 1) { const msg = latestMessages[index] if (!msg || msg.isSend === 1) continue const sender = String(msg.senderUsername || '').trim() if (!sender) continue if (senderAvatarCache.has(sender) || senderAvatarLoading.has(sender)) continue pendingUsernames.add(sender) if (pendingUsernames.size >= 24) break } if (pendingUsernames.size > 0) { warmupGroupSenderProfiles([...pendingUsernames], false) } } } }, [ messages.length, isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, isGroupChatSession, standaloneSessionWindow, normalizedInitialSessionId, normalizedStandaloneInitialContactType, warmupGroupSenderProfiles, loadMessages, loadLaterMessages ]) const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => { if (messages.length <= 0) { isMessageListAtBottomRef.current = true setShowScrollToBottom(prev => (prev ? false : prev)) return } const listEl = messageListRef.current const distanceFromBottom = listEl ? (listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)) : Number.POSITIVE_INFINITY const nearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(messages.length - 2, 0) const nearBottomByDistance = distanceFromBottom <= 140 const effectiveAtBottom = atBottom || nearBottomByRange || nearBottomByDistance isMessageListAtBottomRef.current = effectiveAtBottom if (!effectiveAtBottom) { bottomRangeLoadLockRef.current = false // 用户主动离开底部后,解除“搜索跳转后的自动向后加载抑制” suppressAutoLoadLaterRef.current = false } if ( isLoadingMessages || isSessionSwitching || isLoadingMore || suppressScrollToBottomButtonRef.current ) { setShowScrollToBottom(prev => (prev ? false : prev)) return } if (effectiveAtBottom) { setShowScrollToBottom(prev => (prev ? false : prev)) return } const shouldShow = distanceFromBottom > 180 setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow)) }, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching]) const handleMessageListWheel = useCallback((event: React.WheelEvent) => { if (event.deltaY <= 18) return if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreLater) return const listEl = messageListRef.current if (!listEl) return const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight) if (distanceFromBottom > 96) return if (bottomRangeLoadLockRef.current) return // 用户明确向下滚动时允许加载后续消息 suppressAutoLoadLaterRef.current = false bottomRangeLoadLockRef.current = true void loadLaterMessages() }, [currentSessionId, hasMoreLater, isLoadingMessages, isLoadingMore, loadLaterMessages]) const handleMessageAtTopStateChange = useCallback((atTop: boolean) => { if (!atTop) { topRangeLoadLockRef.current = false } }, []) const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => { return ( prev.username === next.username && prev.type === next.type && prev.unreadCount === next.unreadCount && prev.summary === next.summary && prev.sortTimestamp === next.sortTimestamp && prev.lastTimestamp === next.lastTimestamp && prev.lastMsgType === next.lastMsgType && prev.displayName === next.displayName && prev.avatarUrl === next.avatarUrl && prev.alias === next.alias ) }, []) const mergeSessions = useCallback((nextSessions: ChatSession[]) => { // 确保输入是数组 if (!Array.isArray(nextSessions)) { console.warn('mergeSessions: nextSessions is not an array:', nextSessions) return Array.isArray(sessionsRef.current) ? sessionsRef.current : [] } if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) { return nextSessions.map((next) => mergeSessionContactPresentation(next)) } const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s])) return nextSessions.map((next) => { const prev = prevMap.get(next.username) const merged = mergeSessionContactPresentation(next, prev) if (!prev) return merged return isSameSession(prev, merged) ? prev : merged }) }, [isSameSession, mergeSessionContactPresentation]) const flashNewMessages = useCallback((keys: string[]) => { if (keys.length === 0) return setHighlightedMessageKeys((prev) => [...prev, ...keys]) window.setTimeout(() => { setHighlightedMessageKeys((prev) => prev.filter((k) => !keys.includes(k))) }, 2500) }, []) const handleInSessionResultJump = useCallback((msg: Message) => { const targetTime = Number(msg.createTime || 0) const targetSessionId = String(currentSessionRef.current || currentSessionId || '').trim() if (!targetTime || !targetSessionId) return if (inSessionResultJumpTimerRef.current) { window.clearTimeout(inSessionResultJumpTimerRef.current) inSessionResultJumpTimerRef.current = null } const requestSeq = inSessionResultJumpRequestSeqRef.current + 1 inSessionResultJumpRequestSeqRef.current = requestSeq const anchorEndTime = targetTime + 1 const targetMessageKey = getMessageKey(msg) inSessionResultJumpTimerRef.current = window.setTimeout(() => { inSessionResultJumpTimerRef.current = null if (requestSeq !== inSessionResultJumpRequestSeqRef.current) return if (currentSessionRef.current !== targetSessionId) return setCurrentOffset(0) setJumpStartTime(0) setJumpEndTime(anchorEndTime) // 搜索跳转后默认不自动回流到最新消息,仅在用户主动向下滚动时加载后续 suppressAutoLoadLaterRef.current = true flashNewMessages([targetMessageKey]) void loadMessages(targetSessionId, 0, 0, anchorEndTime, false, { inSessionJumpRequestSeq: requestSeq }) }, 220) }, [currentSessionId, flashNewMessages, getMessageKey, loadMessages]) // 滚动到底部 const scrollToBottom = useCallback(() => { suppressScrollToBottomButton(220) isMessageListAtBottomRef.current = true setShowScrollToBottom(false) const lastIndex = messages.length - 1 if (lastIndex >= 0 && messageVirtuosoRef.current) { messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) return } if (messageListRef.current) { messageListRef.current.scrollTo({ top: messageListRef.current.scrollHeight, behavior: 'auto' }) } }, [messages.length, suppressScrollToBottomButton]) // 拖动调节侧边栏宽度 const handleResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault() setIsResizing(true) const startX = e.clientX const startWidth = sidebarWidth const handleMouseMove = (e: MouseEvent) => { const delta = e.clientX - startX const newWidth = Math.min(Math.max(startWidth + delta, 200), 400) setSidebarWidth(newWidth) } const handleMouseUp = () => { setIsResizing(false) document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) }, [sidebarWidth]) // 初始化连接 useEffect(() => { if (!isConnected && !isConnecting) { connect() } // 组件卸载时清理 return () => { avatarLoadQueue.clear() if (previewPersistTimerRef.current !== null) { window.clearTimeout(previewPersistTimerRef.current) previewPersistTimerRef.current = null } if (sessionListPersistTimerRef.current !== null) { window.clearTimeout(sessionListPersistTimerRef.current) sessionListPersistTimerRef.current = null } if (scrollBottomButtonArmTimerRef.current !== null) { window.clearTimeout(scrollBottomButtonArmTimerRef.current) scrollBottomButtonArmTimerRef.current = null } if (contactUpdateTimerRef.current) { clearTimeout(contactUpdateTimerRef.current) } if (sessionScrollTimeoutRef.current) { clearTimeout(sessionScrollTimeoutRef.current) } contactUpdateQueueRef.current.clear() pendingSessionContactEnrichRef.current.clear() sessionContactEnrichAttemptAtRef.current.clear() sessionContactProfileCacheRef.current.clear() enrichCancelledRef.current = true isEnrichingRef.current = false } }, []) useEffect(() => { const handleChange = () => { void handleAccountChanged() } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) }, [handleAccountChanged]) useEffect(() => { const nextSet = new Set() for (const msg of messages) { nextSet.add(getMessageKey(msg)) } messageKeySetRef.current = nextSet const lastMsg = messages[messages.length - 1] lastMessageTimeRef.current = lastMsg?.createTime ?? 0 }, [messages, getMessageKey]) useEffect(() => { lastObservedMessageCountRef.current = messages.length if (messages.length <= 0) { isMessageListAtBottomRef.current = true } }, [currentSessionId]) useEffect(() => { const previousCount = lastObservedMessageCountRef.current const currentCount = messages.length lastObservedMessageCountRef.current = currentCount if (currentCount <= previousCount) return if (!currentSessionId || isLoadingMessages || isSessionSwitching) return const wasNearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(previousCount - 2, 0) if (!isMessageListAtBottomRef.current && !wasNearBottomByRange) return suppressScrollToBottomButton(220) isMessageListAtBottomRef.current = true requestAnimationFrame(() => { const latestMessages = useChatStore.getState().messages || [] const lastIndex = latestMessages.length - 1 if (lastIndex >= 0 && messageVirtuosoRef.current) { messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) } }) }, [messages.length, currentSessionId, isLoadingMessages, isSessionSwitching, suppressScrollToBottomButton]) useEffect(() => { currentSessionRef.current = currentSessionId }, [currentSessionId]) useEffect(() => { if (currentSessionId !== lastPreloadSessionRef.current) { preloadImageKeysRef.current.clear() lastPreloadSessionRef.current = currentSessionId } }, [currentSessionId]) useEffect(() => { if (!currentSessionId || messages.length === 0) return const preloadEdgeCount = 40 const maxPreload = 30 const head = messages.slice(0, preloadEdgeCount) const tail = messages.slice(-preloadEdgeCount) const candidates = [...head, ...tail] const queued = preloadImageKeysRef.current const seen = new Set() const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = [] for (const msg of candidates) { if (payloads.length >= maxPreload) break if (msg.localType !== 3) continue const cacheKey = msg.imageMd5 || msg.imageDatName || `local:${msg.localId}` if (!msg.imageMd5 && !msg.imageDatName) continue if (imageDataUrlCache.has(cacheKey)) continue const taskKey = `${currentSessionId}|${cacheKey}` if (queued.has(taskKey) || seen.has(taskKey)) continue queued.add(taskKey) seen.add(taskKey) payloads.push({ sessionId: currentSessionId, imageMd5: msg.imageMd5 || undefined, imageDatName: msg.imageDatName }) } if (payloads.length > 0) { window.electronAPI.image.preload(payloads).catch(() => { }) } }, [currentSessionId, messages]) useEffect(() => { const nextMap = new Map() if (Array.isArray(sessions)) { for (const session of sessions) { nextMap.set(session.username, session) } } sessionMapRef.current = nextMap }, [sessions]) useEffect(() => { if (!Array.isArray(sessions) || sessions.length === 0) return const now = Date.now() const cache = sessionContactProfileCacheRef.current for (const session of sessions) { const username = String(session.username || '').trim() if (!username || isFoldPlaceholderSession(username)) continue const displayName = resolveSessionDisplayName(session.displayName, username) const avatarUrl = normalizeSearchAvatarUrl(session.avatarUrl) const alias = normalizeSearchIdentityText(session.alias) if (!displayName && !avatarUrl && !alias) continue const prev = cache.get(username) cache.set(username, { displayName: displayName || prev?.displayName, avatarUrl: avatarUrl || prev?.avatarUrl, alias: alias || prev?.alias, updatedAt: now }) } for (const [username, profile] of cache.entries()) { if (now - profile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { cache.delete(username) } } }, [sessions]) useEffect(() => { sessionsRef.current = Array.isArray(sessions) ? sessions : [] }, [sessions]) useEffect(() => { if (!isLoadingMore) { topRangeLoadLockRef.current = false bottomRangeLoadLockRef.current = false } }, [isLoadingMore]) useEffect(() => { if (initialRevealTimerRef.current !== null) { window.clearTimeout(initialRevealTimerRef.current) initialRevealTimerRef.current = null } if (!isLoadingMessages) { if (messages.length === 0) { setHasInitialMessages(true) } else { initialRevealTimerRef.current = window.setTimeout(() => { setHasInitialMessages(true) initialRevealTimerRef.current = null }, 120) } } }, [isLoadingMessages, messages.length]) useEffect(() => { if (currentSessionId !== prevSessionRef.current) { prevSessionRef.current = currentSessionId setNoMessageTable(false) if (initialRevealTimerRef.current !== null) { window.clearTimeout(initialRevealTimerRef.current) initialRevealTimerRef.current = null } if (messages.length === 0) { setHasInitialMessages(false) } else if (!isLoadingMessages) { setHasInitialMessages(true) } } }, [currentSessionId, messages.length, isLoadingMessages]) useEffect(() => { if (currentSessionId && isConnected && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) { if (pendingSessionLoadRef.current === currentSessionId) return if (initialLoadRequestedSessionRef.current === currentSessionId) return initialLoadRequestedSessionRef.current = currentSessionId setHasInitialMessages(false) void loadMessages(currentSessionId, 0, 0, 0, false, { preferLatestPath: true, deferGroupSenderWarmup: true, forceInitialLimit: 30 }) } }, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable]) useEffect(() => { return () => { if (initialRevealTimerRef.current !== null) { window.clearTimeout(initialRevealTimerRef.current) initialRevealTimerRef.current = null } } }, []) useEffect(() => { 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 } globalMsgPrefixCacheRef.current = null } }, []) useEffect(() => { searchKeywordRef.current = searchKeyword }, [searchKeyword]) useEffect(() => { if (!showJumpPopover) return const handleGlobalPointerDown = (event: MouseEvent) => { const target = event.target as Node | null if (!target) return if (jumpCalendarWrapRef.current?.contains(target)) return if (jumpPopoverPortalRef.current?.contains(target)) return setShowJumpPopover(false) } document.addEventListener('mousedown', handleGlobalPointerDown) return () => { document.removeEventListener('mousedown', handleGlobalPointerDown) } }, [showJumpPopover]) useEffect(() => { if (!showJumpPopover) return const syncPosition = () => { requestAnimationFrame(() => updateJumpPopoverPosition()) } syncPosition() window.addEventListener('resize', syncPosition) window.addEventListener('scroll', syncPosition, true) return () => { window.removeEventListener('resize', syncPosition) window.removeEventListener('scroll', syncPosition, true) } }, [showJumpPopover, updateJumpPopoverPosition]) useEffect(() => { setShowJumpPopover(false) setLoadingDates(false) setLoadingDateCounts(false) setHasLoadedMessageDates(false) setMessageDates(new Set()) setMessageDateCounts({}) }, [currentSessionId]) useEffect(() => { if (!currentSessionId || !Array.isArray(messages) || messages.length === 0) return persistSessionPreviewCache(currentSessionId, messages) saveSessionWindowCache(currentSessionId, { messages, currentOffset, hasMoreMessages, hasMoreLater, jumpStartTime, jumpEndTime }) }, [ currentSessionId, messages, currentOffset, hasMoreMessages, hasMoreLater, jumpStartTime, jumpEndTime, persistSessionPreviewCache, saveSessionWindowCache ]) useEffect(() => { return () => { inSessionResultJumpRequestSeqRef.current += 1 if (inSessionResultJumpTimerRef.current) { window.clearTimeout(inSessionResultJumpTimerRef.current) } } }, []) useEffect(() => { if (!Array.isArray(sessions) || sessions.length === 0) return if (sessionListPersistTimerRef.current !== null) { window.clearTimeout(sessionListPersistTimerRef.current) } sessionListPersistTimerRef.current = window.setTimeout(() => { persistSessionListCache(chatCacheScopeRef.current, sessions) sessionListPersistTimerRef.current = null }, 260) }, [sessions, persistSessionListCache]) // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 const filteredSessions = useMemo(() => { if (!Array.isArray(sessions)) { return [] } // 检查是否有折叠的群聊 const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) const hasFoldedGroups = foldedGroups.length > 0 const visible = sessions.filter(s => { if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false return true }) // 如果有折叠的群聊,但列表中没有入口,则插入入口 if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) { // 找到最新的折叠消息 const latestFolded = foldedGroups.reduce((latest, current) => { const latestTime = latest.sortTimestamp || latest.lastTimestamp const currentTime = current.sortTimestamp || current.lastTimestamp return currentTime > latestTime ? current : latest }) const foldEntry: ChatSession = { username: 'placeholder_foldgroup', displayName: '折叠的聊天', summary: `${latestFolded.displayName || latestFolded.username}: ${latestFolded.summary}`, type: 0, sortTimestamp: latestFolded.sortTimestamp || latestFolded.lastTimestamp, lastTimestamp: latestFolded.lastTimestamp || latestFolded.sortTimestamp, lastMsgType: 0, unreadCount: foldedGroups.reduce((sum, s) => sum + (s.unreadCount || 0), 0), isMuted: false, isFolded: false } // 按时间戳插入到正确位置 const foldTime = foldEntry.sortTimestamp || foldEntry.lastTimestamp const insertIndex = visible.findIndex(s => { const sTime = s.sortTimestamp || s.lastTimestamp return sTime < foldTime }) if (insertIndex === -1) { visible.push(foldEntry) } else { visible.splice(insertIndex, 0, foldEntry) } } if (!searchKeyword.trim()) { return visible } const lower = searchKeyword.toLowerCase() return visible .filter(s => { const matchedByName = s.displayName?.toLowerCase().includes(lower) const matchedByUsername = s.username.toLowerCase().includes(lower) const matchedByAlias = s.alias?.toLowerCase().includes(lower) return matchedByName || matchedByUsername || matchedByAlias }) .map(s => { const matchedByName = s.displayName?.toLowerCase().includes(lower) const matchedByUsername = s.username.toLowerCase().includes(lower) const matchedByAlias = s.alias?.toLowerCase().includes(lower) let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined if (matchedByUsername && !matchedByName && !matchedByAlias) { matchedField = 'wxid' } else if (matchedByAlias && !matchedByName && !matchedByUsername) { matchedField = 'alias' } else if (matchedByName && !matchedByUsername && !matchedByAlias) { matchedField = 'name' } return { ...s, matchedField } }) }, [sessions, searchKeyword]) // 折叠群列表(独立计算,供折叠 panel 使用) const foldedSessions = useMemo(() => { if (!Array.isArray(sessions)) return [] const folded = sessions.filter(s => s.isFolded) if (!searchKeyword.trim() || !foldedView) return folded const lower = searchKeyword.toLowerCase() return folded // 1. 先过滤 .filter(s => { const matchedByName = s.displayName?.toLowerCase().includes(lower) const matchedByUsername = s.username.toLowerCase().includes(lower) const matchedByAlias = s.alias?.toLowerCase().includes(lower) const matchedBySummary = s.summary?.toLowerCase().includes(lower) // 注意:这里有个 summary return matchedByName || matchedByUsername || matchedByAlias || matchedBySummary }) // 2. 后映射 .map(s => { const matchedByName = s.displayName?.toLowerCase().includes(lower) const matchedByUsername = s.username.toLowerCase().includes(lower) const matchedByAlias = s.alias?.toLowerCase().includes(lower) const matchedBySummary = s.summary?.toLowerCase().includes(lower) let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined if (matchedByUsername && !matchedByName && !matchedBySummary && !matchedByAlias) { matchedField = 'wxid' } else if (matchedByAlias && !matchedByName && !matchedBySummary && !matchedByUsername) { matchedField = 'alias' } // ✅ 同样返回新对象 return { ...s, matchedField } }) }, [sessions, searchKeyword, foldedView]) const sessionLookupMap = useMemo(() => { const map = new Map() for (const session of sessions) { const username = String(session.username || '').trim() if (!username) continue map.set(username, session) } return map }, [sessions]) const groupedGlobalMsgResults = useMemo(() => { const grouped = globalMsgResults.reduce((acc, msg) => { const sessionId = (msg as any).sessionId || '未知' if (!acc[sessionId]) acc[sessionId] = [] acc[sessionId].push(msg) return acc }, {} as Record) return Object.entries(grouped) }, [globalMsgResults]) const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0 const shouldShowSessionsSkeleton = isLoadingSessions && !hasSessionRecords const isSessionListSyncing = (isLoadingSessions || isRefreshingSessions) && hasSessionRecords // 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算 const formatSessionTime = useCallback((timestamp: number): string => { if (!Number.isFinite(timestamp) || timestamp <= 0) return '' const now = Date.now() const msgTime = timestamp * 1000 const diff = now - msgTime const minutes = Math.floor(diff / 60000) const hours = Math.floor(diff / 3600000) if (minutes < 1) return '刚刚' if (minutes < 60) return `${minutes}分钟前` if (hours < 24) return `${hours}小时前` // 超过24小时显示日期 const date = new Date(msgTime) const nowDate = new Date() if (date.getFullYear() === nowDate.getFullYear()) { return `${date.getMonth() + 1}/${date.getDate()}` } return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` }, []) // 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback) const currentSession = (() => { const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined if (found) { if ( standaloneSessionWindow && normalizedInitialSessionId && found.username === normalizedInitialSessionId ) { return { ...found, displayName: found.displayName || fallbackDisplayName || found.username, avatarUrl: found.avatarUrl || fallbackAvatarUrl || undefined } } return found } if (!currentSessionId) return found return { username: currentSessionId, type: 0, unreadCount: 0, summary: '', sortTimestamp: 0, lastTimestamp: 0, lastMsgType: 0, displayName: fallbackDisplayName || currentSessionId, avatarUrl: fallbackAvatarUrl || undefined, } as ChatSession })() const filteredGroupPanelMembers = useMemo(() => { const keyword = groupMemberSearchKeyword.trim().toLowerCase() if (!keyword) return groupPanelMembers return groupPanelMembers.filter((member) => { const fields = [ member.username, member.displayName, member.groupNickname, member.remark, member.nickname, member.alias ] return fields.some(field => String(field || '').toLowerCase().includes(keyword)) }) }, [groupMemberSearchKeyword, groupPanelMembers]) const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId)) const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog const isCurrentSessionGroup = Boolean( currentSession && ( isGroupChatSession(currentSession.username) || ( standaloneSessionWindow && currentSession.username === normalizedInitialSessionId && normalizedStandaloneInitialContactType === 'group' ) ) ) const isCurrentSessionPrivateSnsSupported = Boolean( currentSession && isSingleContactSession(currentSession.username) && !isCurrentSessionGroup ) const openCurrentSessionSnsTimeline = useCallback(() => { if (!currentSession || !isCurrentSessionPrivateSnsSupported) return setChatSnsTimelineTarget({ username: currentSession.username, displayName: currentSession.displayName || currentSession.username, avatarUrl: currentSession.avatarUrl }) }, [currentSession, isCurrentSessionPrivateSnsSupported]) useEffect(() => { if (!standaloneSessionWindow) return setStandaloneInitialLoadRequested(false) setStandaloneLoadStage(normalizedInitialSessionId ? 'connecting' : 'idle') setFallbackDisplayName(normalizedStandaloneInitialDisplayName || null) setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl || null) }, [ standaloneSessionWindow, normalizedInitialSessionId, normalizedStandaloneInitialDisplayName, normalizedStandaloneInitialAvatarUrl ]) useEffect(() => { if (!standaloneSessionWindow) return if (!normalizedInitialSessionId) return if (normalizedStandaloneInitialDisplayName) { setFallbackDisplayName(normalizedStandaloneInitialDisplayName) } if (normalizedStandaloneInitialAvatarUrl) { setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl) } if (!currentSessionId) { setCurrentSession(normalizedInitialSessionId, { preserveMessages: false }) } if (!isConnected || isConnecting) { setStandaloneLoadStage('connecting') } }, [ standaloneSessionWindow, normalizedInitialSessionId, normalizedStandaloneInitialDisplayName, normalizedStandaloneInitialAvatarUrl, currentSessionId, isConnected, isConnecting, setCurrentSession ]) useEffect(() => { if (!standaloneSessionWindow) return if (!normalizedInitialSessionId) return if (!isConnected || isConnecting) return if (currentSessionId === normalizedInitialSessionId && standaloneInitialLoadRequested) return setStandaloneInitialLoadRequested(true) setStandaloneLoadStage('loading') selectSessionById(normalizedInitialSessionId, { force: currentSessionId === normalizedInitialSessionId }) }, [ standaloneSessionWindow, normalizedInitialSessionId, isConnected, isConnecting, currentSessionId, standaloneInitialLoadRequested, selectSessionById ]) useEffect(() => { if (!standaloneSessionWindow || !normalizedInitialSessionId) return if (!isConnected || isConnecting) { setStandaloneLoadStage('connecting') return } if (!standaloneInitialLoadRequested) { setStandaloneLoadStage('loading') return } if (currentSessionId !== normalizedInitialSessionId) { setStandaloneLoadStage('loading') return } if (isLoadingMessages || isSessionSwitching) { setStandaloneLoadStage('loading') return } setStandaloneLoadStage('ready') }, [ standaloneSessionWindow, normalizedInitialSessionId, isConnected, isConnecting, standaloneInitialLoadRequested, currentSessionId, isLoadingMessages, isSessionSwitching ]) // 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称 useEffect(() => { if (!currentSessionId) return const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined if (found) { if (found.displayName) setFallbackDisplayName(found.displayName) if (found.avatarUrl) setFallbackAvatarUrl(found.avatarUrl) return } loadContactInfoBatch([currentSessionId]).then(() => { const cached = senderAvatarCache.get(currentSessionId) if (cached?.displayName) setFallbackDisplayName(cached.displayName) if (cached?.avatarUrl) setFallbackAvatarUrl(cached.avatarUrl) }) }, [currentSessionId, sessions]) // 渲染日期分隔 const shouldShowDateDivider = (msg: Message, prevMsg?: Message): boolean => { if (!prevMsg) return true const date = new Date(msg.createTime * 1000).toDateString() const prevDate = new Date(prevMsg.createTime * 1000).toDateString() return date !== prevDate } const formatDateDivider = (timestamp: number): string => { if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间' const date = new Date(timestamp * 1000) const now = new Date() const isToday = date.toDateString() === now.toDateString() if (isToday) return '今天' const yesterday = new Date(now) yesterday.setDate(yesterday.getDate() - 1) if (date.toDateString() === yesterday.toDateString()) return '昨天' return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) } const handleRequireModelDownload = useCallback((sessionId: string, messageId: string) => { setPendingVoiceTranscriptRequest({ sessionId, messageId }) setShowVoiceTranscribeDialog(true) }, []) // 批量语音转文字 const handleBatchTranscribe = useCallback(async () => { if (!currentSessionId) return const session = sessions.find(s => s.username === currentSessionId) if (!session) { alert('未找到当前会话') return } if (isBatchTranscribing) return const result = await window.electronAPI.chat.getAllVoiceMessages(currentSessionId) if (!result.success || !result.messages) { alert(`获取语音消息失败: ${result.error || '未知错误'}`) return } const voiceMessages: Message[] = result.messages if (voiceMessages.length === 0) { alert('当前会话没有语音消息') return } const dateSet = new Set() voiceMessages.forEach(m => dateSet.add(new Date(m.createTime * 1000).toISOString().slice(0, 10))) const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a)) setBatchVoiceMessages(voiceMessages) setBatchVoiceCount(voiceMessages.length) setBatchVoiceDates(sortedDates) setBatchSelectedDates(new Set(sortedDates)) setBatchVoiceTaskType('transcribe') setShowBatchConfirm(true) }, [sessions, currentSessionId, isBatchTranscribing]) const handleBatchDecrypt = useCallback(async () => { if (!currentSessionId || isBatchDecrypting) return const session = sessions.find(s => s.username === currentSessionId) if (!session) { alert('未找到当前会话') return } const result = await window.electronAPI.chat.getAllImageMessages(currentSessionId) if (!result.success || !result.images) { alert(`获取图片消息失败: ${result.error || '未知错误'}`) return } if (result.images.length === 0) { alert('当前会话没有图片消息') return } const dateSet = new Set() result.images.forEach((img: BatchImageDecryptCandidate) => { if (img.createTime) dateSet.add(new Date(img.createTime * 1000).toISOString().slice(0, 10)) }) const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a)) setBatchImageMessages(result.images) setBatchImageDates(sortedDates) setBatchImageSelectedDates(new Set(sortedDates)) setShowBatchDecryptConfirm(true) }, [currentSessionId, isBatchDecrypting, sessions]) const handleExportCurrentSession = useCallback(() => { if (!currentSessionId) return if (inProgressExportSessionIds.has(currentSessionId) || isPreparingExportDialog) return const requestId = `chat-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const sessionName = currentSession?.displayName || currentSession?.username || currentSessionId pendingExportRequestIdRef.current = requestId setIsPreparingExportDialog(true) setExportPrepareHint('') if (exportPrepareLongWaitTimerRef.current) { window.clearTimeout(exportPrepareLongWaitTimerRef.current) exportPrepareLongWaitTimerRef.current = null } emitOpenSingleExport({ sessionId: currentSessionId, sessionName, requestId }) }, [currentSession, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog]) const handleGroupAnalytics = useCallback(() => { if (!currentSessionId || !isGroupChatSession(currentSessionId)) return navigate('/analytics/group', { state: { preselectGroupIds: [currentSessionId] } }) }, [currentSessionId, navigate, isGroupChatSession]) // 确认批量语音任务(解密/转写) const confirmBatchTranscribe = useCallback(async () => { if (!currentSessionId) return const selected = batchSelectedDates if (selected.size === 0) { alert('请至少选择一个日期') return } const messages = batchVoiceMessages if (!messages || messages.length === 0) { setShowBatchConfirm(false) return } const voiceMessages = messages.filter(m => selected.has(new Date(m.createTime * 1000).toISOString().slice(0, 10)) ) if (voiceMessages.length === 0) { alert('所选日期下没有语音消息') return } setShowBatchConfirm(false) setBatchVoiceMessages(null) setBatchVoiceDates([]) setBatchSelectedDates(new Set()) const session = sessions.find(s => s.username === currentSessionId) if (!session) return const taskType = batchVoiceTaskType startTranscribe(voiceMessages.length, session.displayName || session.username, taskType) if (taskType === 'transcribe') { // 检查模型状态 const modelStatus = await window.electronAPI.whisper.getModelStatus() if (!modelStatus?.exists) { alert('SenseVoice 模型未下载,请先在设置中下载模型') finishTranscribe(0, 0) return } } let successCount = 0 let failCount = 0 let completedCount = 0 const concurrency = taskType === 'decrypt' ? 12 : 10 const runOne = async (msg: Message) => { try { if (taskType === 'decrypt') { const result = await window.electronAPI.chat.getVoiceData( session.username, String(msg.localId), msg.createTime, msg.serverIdRaw || msg.serverId ) return { success: Boolean(result.success && result.data) } } const result = await window.electronAPI.chat.getVoiceTranscript( session.username, String(msg.localId), msg.createTime ) return { success: result.success } } catch { return { success: false } } } for (let i = 0; i < voiceMessages.length; i += concurrency) { const batch = voiceMessages.slice(i, i + concurrency) const results = await Promise.all(batch.map(msg => runOne(msg))) results.forEach(result => { if (result.success) successCount++ else failCount++ completedCount++ updateProgress(completedCount, voiceMessages.length) }) } finishTranscribe(successCount, failCount) }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateProgress, finishTranscribe]) // 批量转写:按日期的消息数量 const batchCountByDate = useMemo(() => { const map = new Map() if (!batchVoiceMessages) return map batchVoiceMessages.forEach(m => { const d = new Date(m.createTime * 1000).toISOString().slice(0, 10) map.set(d, (map.get(d) || 0) + 1) }) return map }, [batchVoiceMessages]) // 批量转写:选中日期对应的语音条数 const batchSelectedMessageCount = useMemo(() => { if (!batchVoiceMessages) return 0 return batchVoiceMessages.filter(m => batchSelectedDates.has(new Date(m.createTime * 1000).toISOString().slice(0, 10)) ).length }, [batchVoiceMessages, batchSelectedDates]) const batchVoiceTaskTitle = batchVoiceTaskType === 'decrypt' ? '批量解密语音' : '批量语音转文字' const batchVoiceTaskVerb = batchVoiceTaskType === 'decrypt' ? '解密' : '转写' const batchVoiceTaskMinutes = Math.ceil( batchSelectedMessageCount * (batchVoiceTaskType === 'decrypt' ? 0.6 : 2) / 60 ) const toggleBatchDate = useCallback((date: string) => { setBatchSelectedDates(prev => { const next = new Set(prev) if (next.has(date)) next.delete(date) else next.add(date) return next }) }, []) const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates]) const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), []) const confirmBatchDecrypt = useCallback(async () => { if (!currentSessionId) return const selected = batchImageSelectedDates if (selected.size === 0) { alert('请至少选择一个日期') return } const images = (batchImageMessages || []).filter(img => img.createTime && selected.has(new Date(img.createTime * 1000).toISOString().slice(0, 10)) ) if (images.length === 0) { alert('所选日期下没有图片消息') return } const session = sessions.find(s => s.username === currentSessionId) if (!session) return setShowBatchDecryptConfirm(false) setBatchImageMessages(null) setBatchImageDates([]) setBatchImageSelectedDates(new Set()) startDecrypt(images.length, session.displayName || session.username) let successCount = 0 let failCount = 0 let completed = 0 const concurrency = batchDecryptConcurrency const decryptOne = async (img: typeof images[0]) => { try { const r = await window.electronAPI.image.decrypt({ sessionId: session.username, imageMd5: img.imageMd5, imageDatName: img.imageDatName, force: true }) if (r?.success) successCount++ else failCount++ } catch { failCount++ } completed++ updateDecryptProgress(completed, images.length) } // 并发池:同时跑 concurrency 个任务 const pool = new Set>() for (const img of images) { const p = decryptOne(img).then(() => { pool.delete(p) }) pool.add(p) if (pool.size >= concurrency) { await Promise.race(pool) } } if (pool.size > 0) { await Promise.all(pool) } finishDecrypt(successCount, failCount) }, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress]) const batchImageCountByDate = useMemo(() => { const map = new Map() if (!batchImageMessages) return map batchImageMessages.forEach(img => { if (!img.createTime) return const d = new Date(img.createTime * 1000).toISOString().slice(0, 10) map.set(d, (map.get(d) ?? 0) + 1) }) return map }, [batchImageMessages]) const batchImageSelectedCount = useMemo(() => { if (!batchImageMessages) return 0 return batchImageMessages.filter(img => img.createTime && batchImageSelectedDates.has(new Date(img.createTime * 1000).toISOString().slice(0, 10)) ).length }, [batchImageMessages, batchImageSelectedDates]) const toggleBatchImageDate = useCallback((date: string) => { setBatchImageSelectedDates(prev => { const next = new Set(prev) if (next.has(date)) next.delete(date) else next.add(date) return next }) }, []) const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates]) const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), []) const lastSelectedKeyRef = useRef(null) const handleToggleSelection = useCallback((messageKey: string, isShiftKey: boolean = false) => { setSelectedMessages(prev => { const next = new Set(prev) // Range selection with Shift key if (isShiftKey && lastSelectedKeyRef.current !== null && lastSelectedKeyRef.current !== messageKey) { const currentMsgs = useChatStore.getState().messages || [] const idx1 = currentMsgs.findIndex(m => getMessageKey(m) === lastSelectedKeyRef.current) const idx2 = currentMsgs.findIndex(m => getMessageKey(m) === messageKey) if (idx1 !== -1 && idx2 !== -1) { const start = Math.min(idx1, idx2) const end = Math.max(idx1, idx2) for (let i = start; i <= end; i++) { next.add(getMessageKey(currentMsgs[i])) } } } else { // Normal toggle if (next.has(messageKey)) { next.delete(messageKey) lastSelectedKeyRef.current = null } else { next.add(messageKey) lastSelectedKeyRef.current = messageKey } } return next }) }, [getMessageKey]) const formatBatchDateLabel = useCallback((dateStr: string) => { const [y, m, d] = dateStr.split('-').map(Number) return `${y}年${m}月${d}日` }, []) const clampContextMenuPosition = useCallback((x: number, y: number) => { const viewportPadding = 12 const estimatedMenuWidth = 180 const estimatedMenuHeight = 188 const maxLeft = Math.max(viewportPadding, window.innerWidth - estimatedMenuWidth - viewportPadding) const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedMenuHeight - viewportPadding) return { x: Math.min(Math.max(x, viewportPadding), maxLeft), y: Math.min(Math.max(y, viewportPadding), maxTop) } }, []) // 消息右键菜单处理 const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => { e.preventDefault() const nextPos = clampContextMenuPosition(e.clientX, e.clientY) setContextMenu({ x: nextPos.x, y: nextPos.y, message }) }, [clampContextMenuPosition]) // 关闭右键菜单 useEffect(() => { const handleClick = () => { setContextMenu(null) } window.addEventListener('click', handleClick) return () => { window.removeEventListener('click', handleClick) } }, []) // 删除消息 - 触发确认弹窗 const handleDelete = useCallback((target: { message: Message } | null = null) => { const msg = target?.message || contextMenu?.message if (!currentSessionId || !msg) return setDeleteConfirm({ show: true, mode: 'single', message: msg }) setContextMenu(null) }, [contextMenu, currentSessionId]) // 执行单条删除动作 const performSingleDelete = async (msg: Message) => { try { const targetMessageKey = getMessageKey(msg) const dbPathHint = msg._db_path const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint) if (result.success) { const currentMessages = useChatStore.getState().messages || [] const newMessages = currentMessages.filter(m => getMessageKey(m) !== targetMessageKey) useChatStore.getState().setMessages(newMessages) } else { alert('删除失败: ' + (result.error || '原因未知')) } } catch (e) { console.error(e) alert('删除异常: ' + String(e)) } } // 修改消息 const handleEditMessage = useCallback(() => { if (contextMenu) { // 允许编辑所有类型的消息 // 如果是文本消息(1),使用 parsedContent // 如果是其他类型(如系统消息 10000),使用 rawContent 或 content 作为 XML 源码编辑 const isText = contextMenu.message.localType === 1 const rawXml = contextMenu.message.content || (contextMenu.message as any).rawContent || contextMenu.message.parsedContent || '' const contentToEdit = isText ? cleanMessageContent(contextMenu.message.parsedContent) : rawXml if (!isText) { const fields = parseXmlToFields(rawXml) setTempFields(fields) setEditMode(fields.length > 0 ? 'fields' : 'raw') } else { setEditMode('raw') setTempFields([]) } setEditingMessage({ message: contextMenu.message, content: contentToEdit }) setContextMenu(null) } }, [contextMenu]) // 确认修改消息 const handleSaveEdit = useCallback(async () => { if (editingMessage && currentSessionId) { let finalContent = editingMessage.content // 如果是字段编辑模式,先同步回 XML if (editMode === 'fields' && tempFields.length > 0) { finalContent = updateXmlWithFields(editingMessage.content, tempFields) } if (!finalContent.trim()) { handleDelete({ message: editingMessage.message }) setEditingMessage(null) return } try { const result = await (window as any).electronAPI.chat.updateMessage(currentSessionId, editingMessage.message.localId, editingMessage.message.createTime, finalContent) if (result.success) { const currentMessages = useChatStore.getState().messages || [] const newMessages = currentMessages.map(m => { if (getMessageKey(m) === getMessageKey(editingMessage.message)) { return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent } } return m }) useChatStore.getState().setMessages(newMessages) setEditingMessage(null) } else { alert('修改失败: ' + result.error) } } catch (e) { alert('修改异常: ' + String(e)) } } }, [editingMessage, currentSessionId, editMode, tempFields, handleDelete]) // 用于在异步循环中获取最新的取消状态 const cancelDeleteRef = useRef(false) const handleBatchDelete = () => { if (selectedMessages.size === 0) { alert('请先选择要删除的消息') return } if (!currentSessionId) return setDeleteConfirm({ show: true, mode: 'batch', count: selectedMessages.size }) } const performBatchDelete = async () => { setIsDeleting(true) setDeleteProgress({ current: 0, total: selectedMessages.size }) setCancelDeleteRequested(false) cancelDeleteRef.current = false try { const currentMessages = useChatStore.getState().messages || [] const selectedKeys = Array.from(selectedMessages) const deletedKeys = new Set() for (let i = 0; i < selectedKeys.length; i++) { if (cancelDeleteRef.current) break const key = selectedKeys[i] const msgObj = currentMessages.find(m => getMessageKey(m) === key) const dbPathHint = msgObj?._db_path const createTime = msgObj?.createTime || 0 const localId = msgObj?.localId || 0 if (!msgObj) { setDeleteProgress({ current: i + 1, total: selectedKeys.length }) continue } try { const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, localId, createTime, dbPathHint) if (result.success) { deletedKeys.add(key) } } catch (err) { console.error(`删除消息 ${localId} 失败:`, err) } setDeleteProgress({ current: i + 1, total: selectedKeys.length }) } const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedKeys.has(getMessageKey(m))) useChatStore.getState().setMessages(finalMessages) setIsSelectionMode(false) setSelectedMessages(new Set()) lastSelectedKeyRef.current = null if (cancelDeleteRef.current) { alert(`操作已中止。已删除 ${deletedKeys.size} 条,剩余记录保留。`) } } catch (e) { alert('批量删除出现错误: ' + String(e)) console.error(e) } finally { setIsDeleting(false) setCancelDeleteRequested(false) cancelDeleteRef.current = false } } const messageVirtuosoComponents = useMemo(() => ({ Header: () => ( hasMoreMessages ? (
{isLoadingMore ? ( <> 加载更多... ) : ( 向上滚动加载更多 )}
) : null ), Footer: () => ( hasMoreLater ? (
{isLoadingMore ? ( <> 正在加载后续消息... ) : ( 向下滚动查看更新消息 )}
) : null ) }), [hasMoreMessages, hasMoreLater, isLoadingMore]) const renderMessageListItem = useCallback((index: number, msg: Message) => { if (!currentSession) return null const prevMsg = index > 0 ? messages[index - 1] : undefined const showDateDivider = shouldShowDateDivider(msg, prevMsg) const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) const isSent = msg.isSend === 1 const isSystem = isSystemMessage(msg.localType) const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') const messageKey = getMessageKey(msg) return (
{showDateDivider && (
{formatDateDivider(msg.createTime)}
)}
) }, [ messages, highlightedMessageSet, getMessageKey, formatDateDivider, currentSession, myAvatarUrl, isCurrentSessionGroup, autoTranscribeVoiceEnabled, handleRequireModelDownload, handleContextMenu, isSelectionMode, selectedMessages, handleToggleSelection ]) return (
{/* 自定义删除确认对话框 */} {deleteConfirm.show && (

确认删除

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

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

正在彻底删除消息...

{deleteProgress.current} / {deleteProgress.total}

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

)} {/* 左侧会话列表 */} {!standaloneSessionWindow && (
{/* 普通 header */}
{ handleSearch(e.target.value) handleGlobalMsgSearch(e.target.value) }} /> {searchKeyword && ( )}
{/* 折叠群 header */}
折叠的群聊
{connectionError && (
{connectionError}
)} {/* 全局消息搜索结果 */} {globalMsgQuery && (
{globalMsgSearchError ? (

{globalMsgSearchError}

) : globalMsgResults.length > 0 ? ( <>
聊天记录: {globalMsgSearching && ( {globalMsgIsBackfilling ? `补全中 ${globalMsgAuthoritativeSessionCount > 0 ? `(${globalMsgAuthoritativeSessionCount})` : ''}...` : '搜索中...'} )} {!globalMsgSearching && globalMsgSearchPhase === 'done' && ( 已完成 )}
{groupedGlobalMsgResults.map(([sessionId, messages]) => { const session = sessionLookupMap.get(sessionId) const firstMsg = messages[0] const count = messages.length return (
{ if (session) { pendingInSessionSearchRef.current = { sessionId, keyword: globalMsgQuery, firstMsgTime: firstMsg.createTime || 0, results: messages } handleSelectSession(session) } }} >
{session?.displayName || sessionId}
{count > 1 && (
共 {count} 条相关聊天记录
)}
) })}
) : globalMsgSearching ? (
{globalMsgSearchPhase === 'seed' ? '搜索中...' : '补全中...'}
) : (

未找到相关消息

)}
)} {/* ... (previous content) ... */} {shouldShowSessionsSkeleton ? (
{[1, 2, 3, 4, 5].map(i => (
))}
) : (
{/* 普通会话列表 */}
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( <> {searchKeyword && (
联系人:
)}
{ isScrollingRef.current = true if (sessionScrollTimeoutRef.current) { clearTimeout(sessionScrollTimeoutRef.current) } sessionScrollTimeoutRef.current = window.setTimeout(() => { isScrollingRef.current = false sessionScrollTimeoutRef.current = null }, 200) }} > {filteredSessions.map(session => ( ))}
) : (

暂无会话

检查你的数据库配置

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

没有折叠的群聊

)}
)}
)} {!standaloneSessionWindow &&
} {/* 右侧消息区域 */}
{currentSession ? ( <>

{currentSession.displayName || currentSession.username}

{isCurrentSessionGroup && (
群聊
)}
{!standaloneSessionWindow && isCurrentSessionGroup && ( )} {isCurrentSessionGroup && ( )} {!standaloneSessionWindow && ( )} {!standaloneSessionWindow && isCurrentSessionPrivateSnsSupported && ( )} {!standaloneSessionWindow && ( )} {!standaloneSessionWindow && ( )}
{showJumpPopover && createPortal(
setShowJumpPopover(false)} onSelect={handleJumpDateSelect} messageDates={messageDates} hasLoadedMessageDates={hasLoadedMessageDates} messageDateCounts={messageDateCounts} loadingDates={loadingDates} loadingDateCounts={loadingDateCounts} style={{ position: 'static', top: 'auto', right: 'auto' }} />
, document.body )} {!shouldHideStandaloneDetailButton && ( )}
{isPreparingExportDialog && exportPrepareHint && (
{exportPrepareHint}
)} setChatSnsTimelineTarget(null)} /> {/* 会话内搜索浮窗 */} {showInSessionSearch && (
handleInSessionSearch(e.target.value)} className="search-input" /> {inSessionSearching && }
{inSessionQuery && (
{inSessionSearching ? '搜索中...' : inSessionSearchError ? '搜索失败' : `找到 ${inSessionResults.length} 条结果`}
)} {inSessionQuery && !inSessionSearching && inSessionSearchError && (

{inSessionSearchError}

)} {inSessionResults.length > 0 && (
{inSessionResults.map((msg, i) => { 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' }) : '' const resultKey = getMessageKey(msg) return (
handleInSessionResultJump(msg)}>
{senderName} {previewText}
{displayTime}
) })}
)} {inSessionQuery && !inSessionSearching && !inSessionSearchError && inSessionResults.length === 0 && (

未找到相关消息

)}
)}
{standaloneSessionWindow && standaloneLoadStage !== 'ready' && (
{standaloneLoadStage === 'connecting' ? '正在建立连接...' : '正在加载最近消息...'} {connectionError && {connectionError}}
)} {isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
{isSessionSwitching ? '切换会话中...' : '加载消息中...'}
)}
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? (
该联系人没有聊天记录
) : ( (atBottom || isMessageListAtBottomRef.current ? 'auto' : false)} atBottomThreshold={80} atBottomStateChange={handleMessageAtBottomStateChange} atTopStateChange={handleMessageAtTopStateChange} rangeChanged={handleMessageRangeChanged} computeItemKey={(_, msg) => getMessageKey(msg)} components={messageVirtuosoComponents} itemContent={renderMessageListItem} /> )} {/* 回到底部按钮 */}
回到底部
{/* 群成员面板 */} {showGroupMembersPanel && isCurrentSessionGroup && (

群成员

共 {groupPanelMembers.length} 人
setGroupMemberSearchKeyword(event.target.value)} placeholder="搜索成员" />
{isRefreshingGroupMembers && (
正在统计成员发言数...
)} {groupMembersError && groupPanelMembers.length > 0 && (
{groupMembersError}
)} {isLoadingGroupMembers ? (
{groupMembersLoadingHint || '加载群成员中...'}
) : groupMembersError && groupPanelMembers.length === 0 ? (
{groupMembersError}
) : filteredGroupPanelMembers.length === 0 ? (
{groupMemberSearchKeyword.trim() ? '暂无匹配成员' : '暂无群成员数据'}
) : (
{filteredGroupPanelMembers.map((member) => (
{member.displayName || member.username}
{member.isOwner && ( 群主 )} {member.isFriend && ( 好友 )}
{member.alias || member.username}
{member.messageCountStatus === 'loading' ? '统计中' : member.messageCountStatus === 'failed' ? '统计失败' : `${member.messageCount.toLocaleString()} 条`}
))}
)}
)} {/* 会话详情面板 */} {showDetailPanel && (

会话详情

{isLoadingDetail && !sessionDetail ? (
加载中...
) : sessionDetail ? (
微信ID {sessionDetail.wxid}
{sessionDetail.remark && (
备注 {sessionDetail.remark}
)} {sessionDetail.nickName && (
昵称 {sessionDetail.nickName}
)} {sessionDetail.alias && (
微信号 {sessionDetail.alias}
)}
消息统计(导出口径)
{isRefreshingDetailStats ? '统计刷新中...' : sessionDetail.statsUpdatedAt ? `${sessionDetail.statsStale ? '缓存于' : '更新于'} ${formatYmdHmDateTime(sessionDetail.statsUpdatedAt)}${sessionDetail.statsStale ? '(将后台刷新)' : ''}` : (isLoadingDetailExtra ? '统计加载中...' : '暂无统计缓存')}
消息总数 {Number.isFinite(sessionDetail.messageCount) ? sessionDetail.messageCount.toLocaleString() : ((isLoadingDetail || isLoadingDetailExtra) ? '统计中...' : '—')}
语音 {Number.isFinite(sessionDetail.voiceMessages) ? (sessionDetail.voiceMessages as number).toLocaleString() : (isLoadingDetailExtra ? '统计中...' : '—')}
图片 {Number.isFinite(sessionDetail.imageMessages) ? (sessionDetail.imageMessages as number).toLocaleString() : (isLoadingDetailExtra ? '统计中...' : '—')}
视频 {Number.isFinite(sessionDetail.videoMessages) ? (sessionDetail.videoMessages as number).toLocaleString() : (isLoadingDetailExtra ? '统计中...' : '—')}
表情包 {Number.isFinite(sessionDetail.emojiMessages) ? (sessionDetail.emojiMessages as number).toLocaleString() : (isLoadingDetailExtra ? '统计中...' : '—')}
转账消息数 {Number.isFinite(sessionDetail.transferMessages) ? (sessionDetail.transferMessages as number).toLocaleString() : (isLoadingDetailExtra ? '统计中...' : '—')}
红包消息数 {Number.isFinite(sessionDetail.redPacketMessages) ? (sessionDetail.redPacketMessages as number).toLocaleString() : (isLoadingDetailExtra ? '统计中...' : '—')}
通话消息数 {Number.isFinite(sessionDetail.callMessages) ? (sessionDetail.callMessages as number).toLocaleString() : (isLoadingDetailExtra ? '统计中...' : '—')}
{sessionDetail.wxid.includes('@chatroom') ? ( <>
我发的消息数 {Number.isFinite(sessionDetail.groupMyMessages) ? (sessionDetail.groupMyMessages as number).toLocaleString() : (isLoadingDetailExtra ? '统计中...' : '—')}
群人数 {Number.isFinite(sessionDetail.groupMemberCount) ? (sessionDetail.groupMemberCount as number).toLocaleString() : (isLoadingDetailExtra ? '统计中...' : '—')}
群发言人数 {Number.isFinite(sessionDetail.groupActiveSpeakers) ? (sessionDetail.groupActiveSpeakers as number).toLocaleString() : (isLoadingDetailExtra ? '统计中...' : '—')}
群共同好友数 {sessionDetail.relationStatsLoaded ? (Number.isFinite(sessionDetail.groupMutualFriends) ? (sessionDetail.groupMutualFriends as number).toLocaleString() : '—') : ( )}
) : (
共同群聊数 {sessionDetail.relationStatsLoaded ? (Number.isFinite(sessionDetail.privateMutualGroups) ? (sessionDetail.privateMutualGroups as number).toLocaleString() : '—') : ( )}
)}
首条消息 {sessionDetail.firstMessageTime ? formatYmdDateFromSeconds(sessionDetail.firstMessageTime) : (isLoadingDetailExtra ? '统计中...' : '—')}
最新消息 {sessionDetail.latestMessageTime ? formatYmdDateFromSeconds(sessionDetail.latestMessageTime) : (isLoadingDetailExtra ? '统计中...' : '—')}
数据库分布
{Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? (
{sessionDetail.messageTables.map((t, i) => (
{t.dbName} {t.count.toLocaleString()} 条
))}
) : (
{isLoadingDetailExtra ? '统计中...' : '暂无统计数据'}
)}
) : (
暂无详情
)}
)}
) : (

{standaloneSessionWindow ? '会话加载中或暂无会话记录' : '选择一个会话开始查看聊天记录'}

{standaloneSessionWindow && connectionError &&

{connectionError}

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

{batchVoiceTaskTitle}

先选择任务类型,再选择日期(仅显示有语音的日期),然后开始处理。

{batchVoiceDates.length > 0 && (
    {batchVoiceDates.map(dateStr => { const count = batchCountByDate.get(dateStr) ?? 0 const checked = batchSelectedDates.has(dateStr) return (
  • ) })}
)}
已选: {batchSelectedDates.size} 天有语音,共 {batchSelectedMessageCount} 条语音
预计耗时: 约 {batchVoiceTaskMinutes} 分钟
{batchVoiceTaskType === 'decrypt' ? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能。' : '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。'}
, document.body )} {/* 消息右键菜单 */} {showBatchDecryptConfirm && createPortal(
setShowBatchDecryptConfirm(false)}>
e.stopPropagation()}>

批量解密图片

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

{batchImageDates.length > 0 && (
    {batchImageDates.map(dateStr => { const count = batchImageCountByDate.get(dateStr) ?? 0 const checked = batchImageSelectedDates.has(dateStr) return (
  • ) })}
)}
已选: {batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片
并发数:
{showConcurrencyDropdown && (
{[ { value: 1, label: '1(最慢,最稳)' }, { value: 3, label: '3' }, { value: 6, label: '6(推荐)' }, { value: 10, label: '10' }, { value: 20, label: '20(最快,可能卡顿)' }, ].map(opt => ( ))}
)}
批量解密可能需要较长时间,进行中会在右下角显示非阻塞进度浮层。
, document.body )} {contextMenu && createPortal( <>
setContextMenu(null)} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 12040 }} />
e.stopPropagation()} >
{contextMenu.message.localType === 1 ? '修改消息' : '编辑源码'}
{ setIsSelectionMode(true) setSelectedMessages(new Set([getMessageKey(contextMenu.message)])) lastSelectedKeyRef.current = getMessageKey(contextMenu.message) setContextMenu(null) }}> 多选
{ e.stopPropagation(); handleDelete() }}> 删除消息
{ setShowMessageInfo(contextMenu.message); setContextMenu(null) }}> 查看消息信息
, document.body )} {/* 消息信息弹窗 */} {showMessageInfo && createPortal(
setShowMessageInfo(null)}>
e.stopPropagation()}>

消息详情

Local ID {showMessageInfo.localId}
Server ID {showMessageInfo.serverId}
消息类型 {showMessageInfo.localType}
发送者 {showMessageInfo.senderUsername || '-'} {showMessageInfo.senderUsername && ( )}
创建时间 {new Date(showMessageInfo.createTime * 1000).toLocaleString()}
发送状态 {showMessageInfo.isSend === 1 ? '发送' : '接收'}
{(showMessageInfo.imageMd5 || showMessageInfo.videoMd5 || showMessageInfo.voiceDurationSeconds != null) && (
媒体信息
{showMessageInfo.imageMd5 && (
Image MD5 {showMessageInfo.imageMd5}
)} {showMessageInfo.imageDatName && (
DAT 文件 {showMessageInfo.imageDatName}
)} {showMessageInfo.videoMd5 && (
Video MD5 {showMessageInfo.videoMd5}
)} {showMessageInfo.voiceDurationSeconds != null && (
语音时长 {showMessageInfo.voiceDurationSeconds}秒
)}
)} {(showMessageInfo.emojiMd5 || showMessageInfo.emojiCdnUrl) && (
表情包信息
{showMessageInfo.emojiMd5 && (
MD5 {showMessageInfo.emojiMd5}
)} {showMessageInfo.emojiCdnUrl && (
CDN URL {showMessageInfo.emojiCdnUrl}
)}
)} {showMessageInfo.localType !== 1 && (showMessageInfo.rawContent || showMessageInfo.content) && (
原始消息内容
{showMessageInfo.rawContent || showMessageInfo.content}
)}
, document.body )} {/* 修改消息弹窗 */} {editingMessage && createPortal(

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

{editMode === 'raw' ? (