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 } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' import type { 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 * as configService from '../services/config' import { emitOpenSingleExport, onExportSessionStatus, onSingleExportDialogStatus, requestExportSessionStatus } from '../services/exportBridge' import './ChatPage.scss' // 系统消息类型常量 const SYSTEM_MESSAGE_TYPES = [ 10000, // 系统消息 266287972401, // 拍一拍 ] 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 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 } 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' } 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 } // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts import { avatarLoadQueue } from '../utils/AvatarLoadQueue' import { Avatar } from '../components/Avatar' // 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染) // 会话项组件(使用 memo 优化,避免不必要的重渲染) const SessionItem = React.memo(function SessionItem({ session, isActive, onSelect, formatTime }: { session: ChatSession isActive: boolean onSelect: (session: ChatSession) => void formatTime: (timestamp: number) => 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)} >
折叠的群聊
{session.summary || ''}
) } return (
onSelect(session)} >
{session.displayName || session.username} {timeText}
{session.summary || '暂无消息'}
{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.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 ) }) function ChatPage(props: ChatPageProps) { const { standaloneSessionWindow = false, initialSessionId = null } = props const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId]) const navigate = useNavigate() const { isConnected, isConnecting, connectionError, sessions, filteredSessions, currentSessionId, isLoadingSessions, messages, isLoadingMessages, isLoadingMore, hasMoreMessages, searchKeyword, setConnected, setConnecting, setConnectionError, setSessions, setFilteredSessions, setCurrentSession, setLoadingSessions, setMessages, appendMessages, setLoadingMessages, setLoadingMore, setHasMoreMessages, hasMoreLater, setHasMoreLater, setSearchKeyword } = useChatStore() const messageListRef = useRef(null) const searchInputRef = useRef(null) const sidebarRef = useRef(null) const getMessageKey = useCallback((msg: Message): string => { if (msg.localId && msg.localId > 0) return `l:${msg.localId}` return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}` }, []) const 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(null) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState>(new Set()) const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false) 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, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore() const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore() 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 [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 [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 highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) const messageKeySetRef = useRef>(new Set()) const lastMessageTimeRef = useRef(0) const sessionMapRef = useRef>(new Map()) const sessionsRef = useRef([]) const currentSessionRef = useRef(null) const pendingSessionLoadRef = useRef(null) const sessionSwitchRequestSeqRef = useRef(0) const initialLoadRequestedSessionRef = useRef(null) const prevSessionRef = useRef(null) const isLoadingMessagesRef = useRef(false) const isLoadingMoreRef = useRef(false) 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 pendingExportRequestIdRef = useRef(null) const exportPrepareLongWaitTimerRef = useRef(null) const jumpDatesRequestSeqRef = useRef(0) const jumpDateCountsRequestSeqRef = useRef(0) const isGroupChatSession = useCallback((username: string) => { return username.includes('@chatroom') }, []) 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 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 { const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) if (requestSeq !== detailRequestSeqRef.current) 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 { const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } ) ]) if (requestSeq !== detailRequestSeqRef.current) 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 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 } }) } } } catch (e) { console.error('加载会话详情补充统计失败:', 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 setIsLoadingRelationStats(true) try { const relationResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } ) if (requestSeq !== detailRequestSeqRef.current) 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 { const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: 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, true) } } } catch (error) { console.error('刷新会话关系统计失败:', error) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsRefreshingDetailStats(false) } } })() } } catch (error) { console.error('加载会话关系统计失败:', 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() 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([]) setFilteredSessions([]) setMessages([]) 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, setFilteredSessions, setHasMoreLater, setHasMoreMessages, setMessages, setSearchKeyword, setSessionDetail, setShowDetailPanel, setShowGroupMembersPanel, setSessions ]) 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 }, [currentSessionId]) 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 = options?.silent ? mergeSessions(sessionsArray) : 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) setSessions(sessionsArray) sessionsRef.current = sessionsArray persistSessionListCache(scope, sessionsArray) void hydrateSessionStatuses(sessionsArray) void enrichSessionsContactInfo(sessionsArray) } } 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 (sessions.length === 0) return // 防止重复加载 if (isEnrichingRef.current) { return } isEnrichingRef.current = true enrichCancelledRef.current = false const totalStart = performance.now() // 移除初始 500ms 延迟,让后台加载与 UI 渲染并行 // 检查是否被取消 if (enrichCancelledRef.current) { isEnrichingRef.current = false return } try { // 找出需要加载联系人信息的会话(没有头像或者没有显示名称的) const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username) if (needEnrich.length === 0) { isEnrichingRef.current = false return } // 批量补齐联系人,平衡吞吐和 UI 流畅性 const batchSize = 8 let loadedCount = 0 for (let i = 0; i < needEnrich.length; i += batchSize) { // 如果正在滚动,暂停加载 if (isScrollingRef.current) { // 等待滚动结束 while (isScrollingRef.current && !enrichCancelledRef.current) { await new Promise(resolve => setTimeout(resolve, 120)) } if (enrichCancelledRef.current) break } // 检查是否被取消 if (enrichCancelledRef.current) break const batchStart = performance.now() const batch = needEnrich.slice(i, i + batchSize) const usernames = batch.map(s => s.username) // 使用 requestIdleCallback 延迟执行,避免阻塞UI await new Promise((resolve) => { if ('requestIdleCallback' in window) { window.requestIdleCallback(() => { void loadContactInfoBatch(usernames).then(() => resolve()) }, { timeout: 700 }) } else { setTimeout(() => { void loadContactInfoBatch(usernames).then(() => resolve()) }, 80) } }) loadedCount += batch.length const batchTime = performance.now() - batchStart if (batchTime > 200) { console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`) } // 批次间延迟,给UI更多时间(DLL调用可能阻塞,需要更长的延迟) if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) { const delay = isScrollingRef.current ? 260 : 120 await new Promise(resolve => setTimeout(resolve, delay)) } } const totalTime = performance.now() - totalStart if (!enrichCancelledRef.current) { } else { } } catch (e) { console.error('加载联系人信息失败:', e) } finally { isEnrichingRef.current = false } } // 联系人信息更新队列(防抖批量更新,避免频繁重渲染) 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 if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl) { hasChanges = true return { ...session, displayName: newDisplayName, avatarUrl: newAvatarUrl } } } return session }) if (hasChanges) { const updateStart = performance.now() setSessions(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)) { contactUpdateQueueRef.current.set(username, contact) // 如果是自己的信息且当前个人头像为空,同步更新 if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) { setMyAvatarUrl(contact.avatarUrl) } // 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用 senderAvatarCache.set(username, { avatarUrl: contact.avatarUrl, displayName: contact.displayName }) } // 触发批量更新 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(() => { 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(() => { 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) { setLoadingMessages(true) // 切会话时保留旧内容作为过渡,避免大面积闪烁 setHasInitialMessages(true) } else { setLoadingMore(true) } // 记录加载前的第一条消息元素 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; error?: string } if (options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current) { return } if (currentSessionRef.current !== sessionId) { return } if (result.success && result.messages) { if (offset === 0) { setMessages(result.messages) persistSessionPreviewCache(sessionId, result.messages) if (result.messages.length === 0) { setNoMessageTable(true) setHasMoreMessages(false) } // 群聊发送者信息补齐改为非阻塞执行,避免影响首屏切换 const isGroup = sessionId.includes('@chatroom') if (isGroup && result.messages.length > 0) { const unknownSenders = [...new Set(result.messages .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) } } // 日期跳转时滚动到顶部,否则滚动到底部 requestAnimationFrame(() => { if (messageListRef.current) { if (isDateJumpRef.current) { messageListRef.current.scrollTop = 0 isDateJumpRef.current = false } else { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } } }) } else { appendMessages(result.messages, true) // 加载更多也同样处理发送者信息预取 const isGroup = sessionId.includes('@chatroom') if (isGroup) { const unknownSenders = [...new Set(result.messages .filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername)) .map(m => m.senderUsername as string) )] if (unknownSenders.length > 0) { warmupGroupSenderProfiles(unknownSenders, false) } } // 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置 if (firstMsgEl && listEl) { requestAnimationFrame(() => { 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) } } } setCurrentOffset(offset + result.messages.length) } 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 handleJumpDateSelect = useCallback((date: Date) => { if (!currentSessionId) 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(currentSessionId, 0, 0, end, false) }, [currentSessionId, loadMessages]) // 加载更晚的消息 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) => { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId || normalizedSessionId === currentSessionId) return const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1 sessionSwitchRequestSeqRef.current = switchRequestSeq setCurrentSession(normalizedSessionId, { preserveMessages: false }) setNoMessageTable(false) const restoredFromWindowCache = restoreSessionWindowCache(normalizedSessionId) if (restoredFromWindowCache) { pendingSessionLoadRef.current = null initialLoadRequestedSessionRef.current = null setIsSessionSwitching(false) 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 ]) // 选择会话 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 scrollTimeoutRef = useRef(null) const handleScroll = useCallback(() => { if (!messageListRef.current) return // 节流:延迟执行,避免滚动时频繁计算 if (scrollTimeoutRef.current) { cancelAnimationFrame(scrollTimeoutRef.current) } scrollTimeoutRef.current = requestAnimationFrame(() => { if (!messageListRef.current) return const { scrollTop, clientHeight, scrollHeight } = messageListRef.current // 显示回到底部按钮:距离底部超过 300px const distanceFromBottom = scrollHeight - scrollTop - clientHeight setShowScrollToBottom(distanceFromBottom > 300) // 预加载:当滚动到顶部 30% 区域时开始加载 if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { const threshold = clientHeight * 0.3 if (scrollTop < threshold) { loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) } } // 预加载更晚的消息 if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) { const threshold = clientHeight * 0.3 const distanceFromBottom = scrollHeight - scrollTop - clientHeight if (distanceFromBottom < threshold) { loadLaterMessages() } } }) }, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages]) const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => { return ( prev.username === next.username && prev.type === next.type && prev.unreadCount === next.unreadCount && prev.summary === next.summary && prev.sortTimestamp === next.sortTimestamp && prev.lastTimestamp === next.lastTimestamp && prev.lastMsgType === next.lastMsgType && prev.displayName === next.displayName && prev.avatarUrl === next.avatarUrl ) }, []) const mergeSessions = useCallback((nextSessions: ChatSession[]) => { // 确保输入是数组 if (!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 } const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s])) return nextSessions.map((next) => { const prev = prevMap.get(next.username) if (!prev) return next return isSameSession(prev, next) ? prev : next }) }, [isSameSession]) const flashNewMessages = useCallback((keys: string[]) => { if (keys.length === 0) return setHighlightedMessageKeys((prev) => [...prev, ...keys]) window.setTimeout(() => { setHighlightedMessageKeys((prev) => prev.filter((k) => !keys.includes(k))) }, 2500) }, []) // 滚动到底部 const scrollToBottom = useCallback(() => { if (messageListRef.current) { messageListRef.current.scrollTo({ top: messageListRef.current.scrollHeight, behavior: 'smooth' }) } }, []) // 拖动调节侧边栏宽度 const handleResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault() setIsResizing(true) const startX = e.clientX const startWidth = sidebarWidth const handleMouseMove = (e: MouseEvent) => { const delta = e.clientX - startX const newWidth = Math.min(Math.max(startWidth + delta, 200), 400) setSidebarWidth(newWidth) } const handleMouseUp = () => { setIsResizing(false) document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) }, [sidebarWidth]) // 初始化连接 useEffect(() => { if (!isConnected && !isConnecting) { connect() } // 组件卸载时清理 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 (contactUpdateTimerRef.current) { clearTimeout(contactUpdateTimerRef.current) } if (sessionScrollTimeoutRef.current) { clearTimeout(sessionScrollTimeoutRef.current) } contactUpdateQueueRef.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(() => { 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(() => { sessionsRef.current = Array.isArray(sessions) ? sessions : [] }, [sessions]) useEffect(() => { isLoadingMessagesRef.current = isLoadingMessages isLoadingMoreRef.current = isLoadingMore }, [isLoadingMessages, 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 && 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, messages.length, isLoadingMessages, isLoadingMore, noMessageTable]) useEffect(() => { return () => { if (initialRevealTimerRef.current !== null) { window.clearTimeout(initialRevealTimerRef.current) initialRevealTimerRef.current = null } } }, []) useEffect(() => { isConnectedRef.current = isConnected }, [isConnected]) 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(() => { 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 入口 useEffect(() => { if (!Array.isArray(sessions)) { setFilteredSessions([]) return } const visible = sessions.filter(s => { if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false return true }) if (!searchKeyword.trim()) { setFilteredSessions(visible) return } const lower = searchKeyword.toLowerCase() setFilteredSessions(visible.filter(s => s.displayName?.toLowerCase().includes(lower) || s.username.toLowerCase().includes(lower) || s.summary.toLowerCase().includes(lower) )) }, [sessions, searchKeyword, setFilteredSessions]) // 折叠群列表(独立计算,供折叠 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.filter(s => s.displayName?.toLowerCase().includes(lower) || s.username.toLowerCase().includes(lower) || s.summary.toLowerCase().includes(lower) ) }, [sessions, searchKeyword, foldedView]) 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 || !currentSessionId) return found return { username: currentSessionId, type: 0, unreadCount: 0, summary: '', sortTimestamp: 0, lastTimestamp: 0, lastMsgType: 0, displayName: fallbackDisplayName || currentSessionId, } 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 useEffect(() => { if (!standaloneSessionWindow) return if (!normalizedInitialSessionId) return if (!isConnected || isConnecting) return if (currentSessionId === normalizedInitialSessionId) return selectSessionById(normalizedInitialSessionId) }, [ standaloneSessionWindow, normalizedInitialSessionId, isConnected, isConnecting, currentSessionId, selectSessionById ]) // 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称 useEffect(() => { if (!currentSessionId) return const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined if (found) { setFallbackDisplayName(null) return } loadContactInfoBatch([currentSessionId]).then(() => { const cached = senderAvatarCache.get(currentSessionId) if (cached?.displayName) setFallbackDisplayName(cached.displayName) }) }, [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)) 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('/group-analytics', { 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 startTranscribe(voiceMessages.length, session.displayName || session.username) // 检查模型状态 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 = 10 const transcribeOne = async (msg: Message) => { try { 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 => transcribeOne(msg))) results.forEach(result => { if (result.success) successCount++ else failCount++ completedCount++ updateProgress(completedCount, voiceMessages.length) }) } finishTranscribe(successCount, failCount) }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, 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 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: Promise[] = [] for (const img of images) { const p = decryptOne(img) pool.push(p) if (pool.length >= concurrency) { await Promise.race(pool) // 移除已完成的 for (let j = pool.length - 1; j >= 0; j--) { const settled = await Promise.race([pool[j].then(() => true), Promise.resolve(false)]) if (settled) pool.splice(j, 1) } } } 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 lastSelectedIdRef = useRef(null) const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => { setSelectedMessages(prev => { const next = new Set(prev) // Range selection with Shift key if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) { const currentMsgs = useChatStore.getState().messages || [] const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current) const idx2 = currentMsgs.findIndex(m => m.localId === localId) 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(currentMsgs[i].localId) } } } else { // Normal toggle if (next.has(localId)) { next.delete(localId) lastSelectedIdRef.current = null // Reset last selection on uncheck? Or keep? Usually keep last interaction. } else { next.add(localId) lastSelectedIdRef.current = localId } } return next }) }, []) const formatBatchDateLabel = useCallback((dateStr: string) => { const [y, m, d] = dateStr.split('-').map(Number) return `${y}年${m}月${d}日` }, []) // 消息右键菜单处理 const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => { e.preventDefault() setContextMenu({ x: e.clientX, y: e.clientY, message }) }, []) // 关闭右键菜单 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 dbPathHint = (msg as any)._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 => m.localId !== msg.localId) 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 (m.localId === editingMessage.message.localId) { 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 selectedIds = Array.from(selectedMessages) const deletedIds = new Set() for (let i = 0; i < selectedIds.length; i++) { if (cancelDeleteRef.current) break const id = selectedIds[i] const msgObj = currentMessages.find(m => m.localId === id) const dbPathHint = (msgObj as any)?._db_path const createTime = msgObj?.createTime || 0 try { const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, id, createTime, dbPathHint) if (result.success) { deletedIds.add(id) } } catch (err) { console.error(`删除消息 ${id} 失败:`, err) } setDeleteProgress({ current: i + 1, total: selectedIds.length }) } const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedIds.has(m.localId)) useChatStore.getState().setMessages(finalMessages) setIsSelectionMode(false) setSelectedMessages(new Set()) if (cancelDeleteRef.current) { alert(`操作已中止。已删除 ${deletedIds.size} 条,剩余记录保留。`) } } catch (e) { alert('批量删除出现错误: ' + String(e)) console.error(e) } finally { setIsDeleting(false) setCancelDeleteRequested(false) cancelDeleteRef.current = false } } return (
{/* 自定义删除确认对话框 */} {deleteConfirm.show && (

确认删除

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

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

正在彻底删除消息...

{deleteProgress.current} / {deleteProgress.total}

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

)} {/* 左侧会话列表 */} {!standaloneSessionWindow && (
{/* 普通 header */}
handleSearch(e.target.value)} /> {searchKeyword && ( )}
{isSessionListSyncing && (
同步中
)}
{/* 折叠群 header */}
折叠的群聊
{connectionError && (
{connectionError}
)} {/* ... (previous content) ... */} {shouldShowSessionsSkeleton ? (
{[1, 2, 3, 4, 5].map(i => (
))}
) : (
{/* 普通会话列表 */}
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
{ 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}

{isGroupChatSession(currentSession.username) && (
群聊
)}
{!standaloneSessionWindow && isGroupChatSession(currentSession.username) && ( )} {isGroupChatSession(currentSession.username) && ( )} {!standaloneSessionWindow && ( )} {!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 )}
{isPreparingExportDialog && exportPrepareHint && (
{exportPrepareHint}
)}
{isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
{isSessionSwitching ? '切换会话中...' : '加载消息中...'}
)}
{hasMoreMessages && (
{isLoadingMore ? ( <> 加载更多... ) : ( 向上滚动加载更多 )}
)} {!isLoadingMessages && messages.length === 0 && !hasMoreMessages && (
该联系人没有聊天记录
)} {(messages || []).map((msg, index) => { const prevMsg = index > 0 ? messages[index - 1] : undefined const showDateDivider = shouldShowDateDivider(msg, prevMsg) // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) const isSent = msg.isSend === 1 const isSystem = isSystemMessage(msg.localType) // 系统消息居中显示 const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') const messageKey = getMessageKey(msg) return (
{showDateDivider && (
{formatDateDivider(msg.createTime)}
)}
) })} {hasMoreLater && (
{isLoadingMore ? ( <> 正在加载后续消息... ) : ( 向下滚动查看更新消息 )}
)} {/* 回到底部按钮 */}
回到底部
{/* 群成员面板 */} {showGroupMembersPanel && isGroupChatSession(currentSession.username) && (

群成员

共 {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) { // 清除缓存中的请求标记,让组件可以重新尝试 const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}` // 不直接调用转写,而是让组件自己重试 // 通过触发一个自定义事件来通知所有 MessageBubble 组件 window.dispatchEvent(new CustomEvent('model-downloaded', { detail: { messageId: pendingVoiceTranscriptRequest.messageId } })) } setPendingVoiceTranscriptRequest(null) }} /> )} {/* 批量转写确认对话框 */} {showBatchConfirm && createPortal(
setShowBatchConfirm(false)}>
e.stopPropagation()}>

批量语音转文字

选择要转写的日期(仅显示有语音的日期),然后开始转写。

{batchVoiceDates.length > 0 && (
    {batchVoiceDates.map(dateStr => { const count = batchCountByDate.get(dateStr) ?? 0 const checked = batchSelectedDates.has(dateStr) return (
  • ) })}
)}
已选: {batchSelectedDates.size} 天有语音,共 {batchSelectedMessageCount} 条语音
预计耗时: 约 {Math.ceil(batchSelectedMessageCount * 2 / 60)} 分钟
批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。
, 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: 9998 }} />
e.stopPropagation()} >
{contextMenu.message.localType === 1 ? '修改消息' : '编辑源码'}
{ setIsSelectionMode(true) setSelectedMessages(new Set([contextMenu.message.localId])) 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' ? (