import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2 } 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 JumpToDateDialog from '../components/JumpToDateDialog'
import * as configService from '../services/config'
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()
}
interface ChatPageProps {
// 保留接口以备将来扩展
}
interface SessionDetail {
wxid: string
displayName: string
remark?: string
nickName?: string
alias?: string
avatarUrl?: string
messageCount: number
firstMessageTime?: number
latestMessageTime?: number
messageTables: { dbName: string; tableName: string; count: 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]
)
return (
onSelect(session)}
>
{session.displayName || session.username}
{timeText}
{session.summary || '暂无消息'}
{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.isActive === nextProps.isActive
)
})
function ChatPage(_props: ChatPageProps) {
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 [currentOffset, setCurrentOffset] = useState(0)
const [jumpStartTime, setJumpStartTime] = useState(0)
const [jumpEndTime, setJumpEndTime] = useState(0)
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [messageDates, setMessageDates] = useState>(new Set())
const [loadingDates, setLoadingDates] = useState(false)
const messageDatesCache = useRef