This commit is contained in:
cc
2026-01-14 19:44:34 +08:00
4 changed files with 312 additions and 140 deletions

7
.gitignore vendored
View File

@@ -13,6 +13,7 @@ dist
dist-electron dist-electron
dist-ssr dist-ssr
*.local *.local
test/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
@@ -42,6 +43,10 @@ release
# OS # OS
Thumbs.db Thumbs.db
# Electron dev cache
.electron/
.cache/
# 忽略 Visual Studio 临时文件夹 # 忽略 Visual Studio 临时文件夹
@@ -50,4 +55,4 @@ Thumbs.db
*.ipch *.ipch
*.aps *.aps
wcdb/ wcdb/

View File

@@ -19,6 +19,7 @@ interface ChatLabMeta {
platform: string platform: string
type: 'group' | 'private' type: 'group' | 'private'
groupId?: string groupId?: string
groupAvatar?: string
} }
interface ChatLabMember { interface ChatLabMember {
@@ -425,6 +426,81 @@ class ExportService {
return { rows, memberSet, firstTime, lastTime } return { rows, memberSet, firstTime, lastTime }
} }
// 补齐群成员,避免只导出发言者导致头像缺失
private async mergeGroupMembers(
chatroomId: string,
memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>,
includeAvatars: boolean
): Promise<void> {
const result = await wcdbService.getGroupMembers(chatroomId)
if (!result.success || !result.members || result.members.length === 0) return
const rawMembers = result.members as Array<{
username?: string
avatarUrl?: string
nickname?: string
displayName?: string
remark?: string
originalName?: string
}>
const usernames = rawMembers
.map((member) => member.username)
.filter((username): username is string => Boolean(username))
if (usernames.length === 0) return
const lookupUsernames = new Set<string>()
for (const username of usernames) {
lookupUsernames.add(username)
const cleaned = this.cleanAccountDirName(username)
if (cleaned && cleaned !== username) {
lookupUsernames.add(cleaned)
}
}
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(Array.from(lookupUsernames)),
includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} })
])
for (const member of rawMembers) {
const username = member.username
if (!username) continue
const cleaned = this.cleanAccountDirName(username)
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || (cleaned ? displayNames.map[cleaned] : undefined) || username)
: username
const groupNickname = member.nickname || member.displayName || member.remark || member.originalName
const avatarUrl = includeAvatars && avatarUrls.success && avatarUrls.map
? (avatarUrls.map[username] || (cleaned ? avatarUrls.map[cleaned] : undefined) || member.avatarUrl)
: member.avatarUrl
const existing = memberSet.get(username)
if (existing) {
if (displayName && existing.member.accountName === existing.member.platformId && displayName !== existing.member.platformId) {
existing.member.accountName = displayName
}
if (groupNickname && !existing.member.groupNickname) {
existing.member.groupNickname = groupNickname
}
if (!existing.avatarUrl && avatarUrl) {
existing.avatarUrl = avatarUrl
}
memberSet.set(username, existing)
continue
}
const chatlabMember: ChatLabMember = {
platformId: username,
accountName: displayName
}
if (groupNickname) {
chatlabMember.groupNickname = groupNickname
}
memberSet.set(username, { member: chatlabMember, avatarUrl })
}
}
private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null { private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null {
if (!avatarUrl) return null if (!avatarUrl) return null
if (avatarUrl.startsWith('data:')) { if (avatarUrl.startsWith('data:')) {
@@ -567,6 +643,9 @@ class ExportService {
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages = collected.rows const allMessages = collected.rows
if (isGroup) {
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
allMessages.sort((a, b) => a.createTime - b.createTime) allMessages.sort((a, b) => a.createTime - b.createTime)
@@ -585,6 +664,7 @@ class ExportService {
return { return {
sender: msg.senderUsername, sender: msg.senderUsername,
accountName: memberInfo.accountName, accountName: memberInfo.accountName,
groupNickname: memberInfo.groupNickname,
timestamp: msg.createTime, timestamp: msg.createTime,
type: this.convertMessageType(msg.localType, msg.content), type: this.convertMessageType(msg.localType, msg.content),
content: this.parseMessageContent(msg.content, msg.localType) content: this.parseMessageContent(msg.content, msg.localType)
@@ -603,6 +683,7 @@ class ExportService {
) )
: new Map<string, string>() : new Map<string, string>()
const sessionAvatar = avatarMap.get(sessionId)
const members = Array.from(collected.memberSet.values()).map((info) => { const members = Array.from(collected.memberSet.values()).map((info) => {
const avatar = avatarMap.get(info.member.platformId) const avatar = avatarMap.get(info.member.platformId)
return avatar ? { ...info.member, avatar } : info.member return avatar ? { ...info.member, avatar } : info.member
@@ -618,7 +699,8 @@ class ExportService {
name: sessionInfo.displayName, name: sessionInfo.displayName,
platform: 'wechat', platform: 'wechat',
type: isGroup ? 'group' : 'private', type: isGroup ? 'group' : 'private',
...(isGroup && { groupId: sessionId }) ...(isGroup && { groupId: sessionId }),
...(sessionAvatar && { groupAvatar: sessionAvatar })
}, },
members, members,
messages: chatLabMessages messages: chatLabMessages

View File

@@ -5,25 +5,6 @@ import type { ChatSession, Message } from '../types/models'
import { getEmojiPath } from 'wechat-emojis' import { getEmojiPath } from 'wechat-emojis'
import './ChatPage.scss' import './ChatPage.scss'
const SESSION_MESSAGE_CACHE_LIMIT = 150
const SESSION_MESSAGE_CACHE_MAX_ENTRIES = 200
const sessionMessageCache = new Map<string, Message[]>()
const cacheSessionMessages = (sessionId: string, messages: Message[]) => {
if (!sessionId) return
const trimmed = messages.length > SESSION_MESSAGE_CACHE_LIMIT
? messages.slice(-SESSION_MESSAGE_CACHE_LIMIT)
: messages.slice()
sessionMessageCache.set(sessionId, trimmed)
if (sessionMessageCache.size > SESSION_MESSAGE_CACHE_MAX_ENTRIES) {
const oldestKey = sessionMessageCache.keys().next().value
if (oldestKey) {
sessionMessageCache.delete(oldestKey)
}
}
}
interface ChatPageProps { interface ChatPageProps {
// 保留接口以备将来扩展 // 保留接口以备将来扩展
} }
@@ -42,6 +23,66 @@ interface SessionDetail {
messageTables: { dbName: string; tableName: string; count: number }[] messageTables: { dbName: string; tableName: string; count: number }[]
} }
// 全局头像加载队列管理器(限制并发,避免卡顿)
class AvatarLoadQueue {
private queue: Array<{ url: string; resolve: () => void; reject: () => void }> = []
private loading = new Set<string>()
private readonly maxConcurrent = 1 // 一次只加载1个头像避免卡顿
private readonly delayBetweenBatches = 100 // 批次间延迟100ms给UI喘息时间
async enqueue(url: string): Promise<void> {
// 如果已经在加载中,直接返回
if (this.loading.has(url)) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
this.queue.push({ url, resolve, reject })
this.processQueue()
})
}
private async processQueue() {
// 如果已达到最大并发数,等待
if (this.loading.size >= this.maxConcurrent) {
return
}
// 如果队列为空,返回
if (this.queue.length === 0) {
return
}
// 取出一个任务
const task = this.queue.shift()
if (!task) return
this.loading.add(task.url)
// 加载图片
const img = new Image()
img.onload = () => {
this.loading.delete(task.url)
task.resolve()
// 延迟一下再处理下一个,避免一次性加载太多
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
}
img.onerror = () => {
this.loading.delete(task.url)
task.reject()
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
}
img.src = task.url
}
clear() {
this.queue = []
this.loading.clear()
}
}
const avatarLoadQueue = new AvatarLoadQueue()
// 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染) // 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染)
// 会话项组件(使用 memo 优化,避免不必要的重渲染) // 会话项组件(使用 memo 优化,避免不必要的重渲染)
const SessionItem = React.memo(function SessionItem({ const SessionItem = React.memo(function SessionItem({
@@ -56,7 +97,7 @@ const SessionItem = React.memo(function SessionItem({
formatTime: (timestamp: number) => string formatTime: (timestamp: number) => string
}) { }) {
// 缓存格式化的时间 // 缓存格式化的时间
const timeText = useMemo(() => const timeText = useMemo(() =>
formatTime(session.lastTimestamp || session.sortTimestamp), formatTime(session.lastTimestamp || session.sortTimestamp),
[formatTime, session.lastTimestamp, session.sortTimestamp] [formatTime, session.lastTimestamp, session.sortTimestamp]
) )
@@ -101,6 +142,7 @@ const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }:
const [imageLoaded, setImageLoaded] = useState(false) const [imageLoaded, setImageLoaded] = useState(false)
const [imageError, setImageError] = useState(false) const [imageError, setImageError] = useState(false)
const [shouldLoad, setShouldLoad] = useState(false) const [shouldLoad, setShouldLoad] = useState(false)
const [isInQueue, setIsInQueue] = useState(false)
const imgRef = useRef<HTMLImageElement>(null) const imgRef = useRef<HTMLImageElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const isGroup = session.username.includes('@chatroom') const isGroup = session.username.includes('@chatroom')
@@ -112,13 +154,59 @@ const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }:
return chars[0] || '?' return chars[0] || '?'
} }
// 使用 Intersection Observer 实现懒加载(优化性能)
useEffect(() => {
if (!containerRef.current || shouldLoad || isInQueue) return
if (!session.avatarUrl) {
// 没有头像URL不需要加载
return
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isInQueue) {
// 加入加载队列,而不是立即加载
setIsInQueue(true)
avatarLoadQueue.enqueue(session.avatarUrl!).then(() => {
setShouldLoad(true)
}).catch(() => {
setImageError(true)
}).finally(() => {
setIsInQueue(false)
})
observer.disconnect()
}
})
},
{
rootMargin: '50px' // 减少预加载距离只提前50px
}
)
observer.observe(containerRef.current)
return () => {
observer.disconnect()
}
}, [session.avatarUrl, shouldLoad, isInQueue])
// 当 avatarUrl 变化时重置状态
useEffect(() => { useEffect(() => {
setImageLoaded(false) setImageLoaded(false)
setImageError(false) setImageError(false)
setShouldLoad(Boolean(session.avatarUrl)) setShouldLoad(false)
setIsInQueue(false)
}, [session.avatarUrl]) }, [session.avatarUrl])
const hasValidUrl = Boolean(session.avatarUrl && !imageError && shouldLoad) // 检查图片是否已经从缓存加载完成
useEffect(() => {
if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
setImageLoaded(true)
}
}, [session.avatarUrl, shouldLoad])
const hasValidUrl = session.avatarUrl && !imageError && shouldLoad
return ( return (
<div <div
@@ -199,7 +287,7 @@ function ChatPage(_props: ChatPageProps) {
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([]) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
const [hasInitialMessages, setHasInitialMessages] = useState(false) const [hasInitialMessages, setHasInitialMessages] = useState(false)
// 联系人信息加载控制 // 联系人信息加载控制
const isEnrichingRef = useRef(false) const isEnrichingRef = useRef(false)
const enrichCancelledRef = useRef(false) const enrichCancelledRef = useRef(false)
@@ -292,10 +380,10 @@ function ChatPage(_props: ChatPageProps) {
// 确保 nextSessions 也是数组 // 确保 nextSessions 也是数组
if (Array.isArray(nextSessions)) { if (Array.isArray(nextSessions)) {
setSessions(nextSessions) setSessions(nextSessions)
// 启动联系人信息加载,UI 已经渲染完成 // 延迟启动联系人信息加载,确保UI先渲染完成
setTimeout(() => { setTimeout(() => {
void enrichSessionsContactInfo(nextSessions) void enrichSessionsContactInfo(nextSessions)
}, 0) }, 500)
} else { } else {
console.error('mergeSessions returned non-array:', nextSessions) console.error('mergeSessions returned non-array:', nextSessions)
setSessions(sessionsArray) setSessions(sessionsArray)
@@ -319,25 +407,28 @@ function ChatPage(_props: ChatPageProps) {
// 分批异步加载联系人信息(优化性能:防止重复加载,滚动时暂停,只在空闲时加载) // 分批异步加载联系人信息(优化性能:防止重复加载,滚动时暂停,只在空闲时加载)
const enrichSessionsContactInfo = async (sessions: ChatSession[]) => { const enrichSessionsContactInfo = async (sessions: ChatSession[]) => {
if (sessions.length === 0) return if (sessions.length === 0) return
// 防止重复加载 // 防止重复加载
if (isEnrichingRef.current) { if (isEnrichingRef.current) {
console.log('[性能监控] 联系人信息正在加载中,跳过重复请求') console.log('[性能监控] 联系人信息正在加载中,跳过重复请求')
return return
} }
isEnrichingRef.current = true isEnrichingRef.current = true
enrichCancelledRef.current = false enrichCancelledRef.current = false
console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`) console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`)
const totalStart = performance.now() const totalStart = performance.now()
// 延迟启动等待UI渲染完成
await new Promise(resolve => setTimeout(resolve, 500))
// 检查是否被取消 // 检查是否被取消
if (enrichCancelledRef.current) { if (enrichCancelledRef.current) {
isEnrichingRef.current = false isEnrichingRef.current = false
return return
} }
try { try {
// 找出需要加载联系人信息的会话(没有缓存的) // 找出需要加载联系人信息的会话(没有缓存的)
const needEnrich = sessions.filter(s => !s.avatarUrl && (!s.displayName || s.displayName === s.username)) const needEnrich = sessions.filter(s => !s.avatarUrl && (!s.displayName || s.displayName === s.username))
@@ -349,10 +440,10 @@ function ChatPage(_props: ChatPageProps) {
console.log(`[性能监控] 需要加载的联系人信息: ${needEnrich.length}`) console.log(`[性能监控] 需要加载的联系人信息: ${needEnrich.length}`)
// 每次最多查询更多联系人,减少批次数 // 进一步减少批次大小每批3个避免DLL调用阻塞
const batchSize = 20 const batchSize = 3
let loadedCount = 0 let loadedCount = 0
for (let i = 0; i < needEnrich.length; i += batchSize) { for (let i = 0; i < needEnrich.length; i += batchSize) {
// 如果正在滚动,暂停加载 // 如果正在滚动,暂停加载
if (isScrollingRef.current) { if (isScrollingRef.current) {
@@ -363,25 +454,41 @@ function ChatPage(_props: ChatPageProps) {
} }
if (enrichCancelledRef.current) break if (enrichCancelledRef.current) break
} }
// 检查是否被取消 // 检查是否被取消
if (enrichCancelledRef.current) break if (enrichCancelledRef.current) break
const batchStart = performance.now() const batchStart = performance.now()
const batch = needEnrich.slice(i, i + batchSize) const batch = needEnrich.slice(i, i + batchSize)
const usernames = batch.map(s => s.username) const usernames = batch.map(s => s.username)
// 在执行 DLL 请求前让出控制权以保持响应 // 使用 requestIdleCallback 延迟执行避免阻塞UI
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise<void>((resolve) => {
await loadContactInfoBatch(usernames) if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => {
void loadContactInfoBatch(usernames).then(() => resolve())
}, { timeout: 2000 })
} else {
setTimeout(() => {
void loadContactInfoBatch(usernames).then(() => resolve())
}, 300)
}
})
loadedCount += batch.length loadedCount += batch.length
const batchTime = performance.now() - batchStart const batchTime = performance.now() - batchStart
if (batchTime > 200) { if (batchTime > 200) {
console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`) 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 ? 1000 : 800
await new Promise(resolve => setTimeout(resolve, delay))
}
} }
const totalTime = performance.now() - totalStart const totalTime = performance.now() - totalStart
if (!enrichCancelledRef.current) { if (!enrichCancelledRef.current) {
console.log(`[性能监控] 联系人信息加载完成,总耗时: ${totalTime.toFixed(2)}ms, 已加载: ${loadedCount}/${needEnrich.length}`) console.log(`[性能监控] 联系人信息加载完成,总耗时: ${totalTime.toFixed(2)}ms, 已加载: ${loadedCount}/${needEnrich.length}`)
@@ -463,19 +570,19 @@ function ChatPage(_props: ChatPageProps) {
try { try {
// 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate // 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
const dllStart = performance.now() const dllStart = performance.now()
const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
const dllTime = performance.now() - dllStart const dllTime = performance.now() - dllStart
// DLL 调用后再次让出控制权 // DLL 调用后再次让出控制权
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
const totalTime = performance.now() - startTime const totalTime = performance.now() - startTime
if (dllTime > 50 || totalTime > 100) { if (dllTime > 50 || totalTime > 100) {
console.warn(`[性能监控] DLL调用耗时: ${dllTime.toFixed(2)}ms, 总耗时: ${totalTime.toFixed(2)}ms, usernames: ${usernames.length}`) console.warn(`[性能监控] DLL调用耗时: ${dllTime.toFixed(2)}ms, 总耗时: ${totalTime.toFixed(2)}ms, usernames: ${usernames.length}`)
} }
if (result.success && result.contacts) { if (result.success && result.contacts) {
// 将更新加入队列,而不是立即更新 // 将更新加入队列,而不是立即更新
for (const [username, contact] of Object.entries(result.contacts)) { for (const [username, contact] of Object.entries(result.contacts)) {
@@ -531,41 +638,16 @@ function ChatPage(_props: ChatPageProps) {
} }
} }
const loadCachedMessagesForSession = async (sessionId: string) => {
if (!sessionId) return
const cached = sessionMessageCache.get(sessionId)
if (cached && cached.length > 0) {
setMessages(cached)
setHasInitialMessages(true)
return
}
try {
const result = await window.electronAPI.chat.getCachedMessages(sessionId)
if (result.success && Array.isArray(result.messages) && result.messages.length > 0) {
const trimmed = result.messages.length > SESSION_MESSAGE_CACHE_LIMIT
? result.messages.slice(-SESSION_MESSAGE_CACHE_LIMIT)
: result.messages
sessionMessageCache.set(sessionId, trimmed)
setMessages(trimmed)
setHasInitialMessages(true)
return
}
} catch (error) {
console.error('加载缓存消息失败:', error)
}
setMessages([])
setHasInitialMessages(false)
}
// 加载消息 // 加载消息
const loadMessages = async (sessionId: string, offset = 0) => { const loadMessages = async (sessionId: string, offset = 0) => {
const listEl = messageListRef.current const listEl = messageListRef.current
const session = sessionMapRef.current.get(sessionId) const session = sessionMapRef.current.get(sessionId)
const unreadCount = session?.unreadCount ?? 0 const unreadCount = session?.unreadCount ?? 0
const messageLimit = offset === 0 && unreadCount > 99 ? 30 : 50 const messageLimit = offset === 0 && unreadCount > 99 ? 30 : 50
if (offset === 0) { if (offset === 0) {
setLoadingMessages(true) setLoadingMessages(true)
setMessages([])
} else { } else {
setLoadingMore(true) setLoadingMore(true)
} }
@@ -578,7 +660,6 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.messages) { if (result.success && result.messages) {
if (offset === 0) { if (offset === 0) {
setMessages(result.messages) setMessages(result.messages)
cacheSessionMessages(sessionId, result.messages)
// 首次加载滚动到底部 // 首次加载滚动到底部
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (messageListRef.current) { if (messageListRef.current) {
@@ -613,18 +694,14 @@ function ChatPage(_props: ChatPageProps) {
// 选择会话 // 选择会话
const handleSelectSession = (session: ChatSession) => { const handleSelectSession = (session: ChatSession) => {
if (session.username === currentSessionId) return if (session.username === currentSessionId) return
const sessionId = session.username setCurrentSession(session.username)
setCurrentSession(sessionId)
setCurrentOffset(0) setCurrentOffset(0)
loadMessages(session.username, 0)
// 重置详情面板 // 重置详情面板
setSessionDetail(null) setSessionDetail(null)
if (showDetailPanel) { if (showDetailPanel) {
loadSessionDetail(sessionId) loadSessionDetail(session.username)
} }
void (async () => {
await loadCachedMessagesForSession(sessionId)
loadMessages(sessionId, 0)
})()
} }
// 搜索过滤 // 搜索过滤
@@ -665,7 +742,7 @@ function ChatPage(_props: ChatPageProps) {
scrollTimeoutRef.current = requestAnimationFrame(() => { scrollTimeoutRef.current = requestAnimationFrame(() => {
if (!messageListRef.current) return if (!messageListRef.current) return
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current const { scrollTop, clientHeight, scrollHeight } = messageListRef.current
// 显示回到底部按钮:距离底部超过 300px // 显示回到底部按钮:距离底部超过 300px
@@ -765,9 +842,10 @@ function ChatPage(_props: ChatPageProps) {
if (!isConnected && !isConnecting) { if (!isConnected && !isConnecting) {
connect() connect()
} }
// 组件卸载时清理 // 组件卸载时清理
return () => { return () => {
avatarLoadQueue.clear()
if (contactUpdateTimerRef.current) { if (contactUpdateTimerRef.current) {
clearTimeout(contactUpdateTimerRef.current) clearTimeout(contactUpdateTimerRef.current)
} }
@@ -828,7 +906,7 @@ function ChatPage(_props: ChatPageProps) {
}) })
} }
if (payloads.length > 0) { if (payloads.length > 0) {
window.electronAPI.image.preload(payloads).catch(() => {}) window.electronAPI.image.preload(payloads).catch(() => { })
} }
}, [currentSessionId, messages]) }, [currentSessionId, messages])
@@ -1036,7 +1114,7 @@ function ChatPage(_props: ChatPageProps) {
))} ))}
</div> </div>
) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( ) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
<div <div
className="session-list" className="session-list"
ref={sessionListRef} ref={sessionListRef}
onScroll={() => { onScroll={() => {
@@ -1117,56 +1195,56 @@ function ChatPage(_props: ChatPageProps) {
ref={messageListRef} ref={messageListRef}
onScroll={handleScroll} onScroll={handleScroll}
> >
{hasMoreMessages && ( {hasMoreMessages && (
<div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}> <div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? ( {isLoadingMore ? (
<> <>
<Loader2 size={14} /> <Loader2 size={14} />
<span>...</span> <span>...</span>
</> </>
) : ( ) : (
<span></span> <span></span>
)} )}
</div>
)}
{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 = msg.localType === 10000
// 系统消息居中显示
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
const messageKey = getMessageKey(msg)
return (
<div key={messageKey} className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
{showDateDivider && (
<div className="date-divider">
<span>{formatDateDivider(msg.createTime)}</span>
</div>
)}
<MessageBubble
message={msg}
session={currentSession}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChat(currentSession.username)}
/>
</div>
)
})}
{/* 回到底部按钮 */}
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
<ChevronDown size={16} />
<span></span>
</div> </div>
)}
{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 = msg.localType === 10000
// 系统消息居中显示
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
const messageKey = getMessageKey(msg)
return (
<div key={messageKey} className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
{showDateDivider && (
<div className="date-divider">
<span>{formatDateDivider(msg.createTime)}</span>
</div>
)}
<MessageBubble
message={msg}
session={currentSession}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChat(currentSession.username)}
/>
</div>
)
})}
{/* 回到底部按钮 */}
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
<ChevronDown size={16} />
<span></span>
</div> </div>
</div>
{/* 会话详情面板 */} {/* 会话详情面板 */}
{showDetailPanel && ( {showDetailPanel && (
@@ -1356,7 +1434,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) { bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
return 'image/webp' return 'image/webp'
} }
} catch {} } catch { }
return 'image/jpeg' return 'image/jpeg'
}, []) }, [])
@@ -1423,7 +1501,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
setSenderAvatarUrl(result.avatarUrl) setSenderAvatarUrl(result.avatarUrl)
setSenderName(result.displayName) setSenderName(result.displayName)
} }
}).catch(() => {}).finally(() => { }).catch(() => { }).finally(() => {
senderAvatarLoading.delete(sender) senderAvatarLoading.delete(sender)
}) })
} }
@@ -1519,7 +1597,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
} }
setImageHasUpdate(Boolean(result.hasUpdate)) setImageHasUpdate(Boolean(result.hasUpdate))
} }
}).catch(() => {}) }).catch(() => { })
return () => { return () => {
cancelled = true cancelled = true
} }
@@ -1607,6 +1685,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
// 是否有引用消息 // 是否有引用消息
const hasQuote = message.quotedContent && message.quotedContent.length > 0 const hasQuote = message.quotedContent && message.quotedContent.length > 0
// 去除企业微信 ID 前缀
const cleanMessageContent = (content: string) => {
if (!content) return ''
return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '')
}
// 解析混合文本和表情 // 解析混合文本和表情
const renderTextWithEmoji = (text: string) => { const renderTextWithEmoji = (text: string) => {
if (!text) return text if (!text) return text
@@ -1817,14 +1901,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
<div className="bubble-content"> <div className="bubble-content">
<div className="quoted-message"> <div className="quoted-message">
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>} {message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(message.quotedContent || '')}</span> <span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}</span>
</div> </div>
<div className="message-text">{renderTextWithEmoji(message.parsedContent)}</div> <div className="message-text">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
</div> </div>
) )
} }
// 普通消息 // 普通消息
return <div className="bubble-content">{renderTextWithEmoji(message.parsedContent)}</div> return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
} }
return ( return (

View File

@@ -506,6 +506,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>} {dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
<div className="field-hint"></div> <div className="field-hint"></div>
<div className="field-hint"> 10</div>
</div> </div>
)} )}