mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat: 所有数据解析完全后台进行以解决页面未响应的问题;优化了头像渲染逻辑以提升渲染速度
fix: 修复了虚拟机上无法索引到wxkey的问题;修复图片密钥扫描的问题;修复年度报告错误;修复了年度报告和数据分析中的发送者错误问题;修复了部分页面偶发的未渲染名称问题;修复了头像偶发渲染失败的问题;修复了部分图片无法解密的问题
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import './AnalyticsPage.scss'
|
||||
import './DataManagementPage.scss'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
|
||||
function AnalyticsPage() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -28,7 +30,7 @@ function AnalyticsPage() {
|
||||
|
||||
try {
|
||||
setLoadingStatus('正在统计消息数据...')
|
||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics()
|
||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatistics(statsResult.data)
|
||||
} else {
|
||||
@@ -55,7 +57,12 @@ function AnalyticsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const force = location.state?.forceRefresh === true
|
||||
loadData(force)
|
||||
}, [location.state])
|
||||
|
||||
const handleRefresh = () => loadData(true)
|
||||
|
||||
@@ -289,7 +296,7 @@ function AnalyticsPage() {
|
||||
<div key={contact.username} className="ranking-item">
|
||||
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||
<div className="contact-avatar">
|
||||
{contact.avatarUrl ? <img src={contact.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} />
|
||||
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
|
||||
119
src/pages/AnalyticsWelcomePage.scss
Normal file
119
src/pages/AnalyticsWelcomePage.scss
Normal file
@@ -0,0 +1,119 @@
|
||||
.analytics-welcome-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 40px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
|
||||
.icon-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 24px;
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #07c160;
|
||||
|
||||
svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 30px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
border-color: #07c160;
|
||||
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.1);
|
||||
|
||||
.card-icon {
|
||||
color: #07c160;
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
63
src/pages/AnalyticsWelcomePage.tsx
Normal file
63
src/pages/AnalyticsWelcomePage.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { BarChart2, History, RefreshCcw } from 'lucide-react'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import './AnalyticsWelcomePage.scss'
|
||||
|
||||
function AnalyticsWelcomePage() {
|
||||
const navigate = useNavigate()
|
||||
// 检查是否有任何缓存数据加载或基本的存储状态表明它已准备好。
|
||||
// 实际上,如果 store 没有持久化,`isLoaded` 可能会在应用刷新时重置。
|
||||
// 如果用户点击“加载缓存”但缓存为空,AnalyticsPage 的逻辑(loadData 不带 force)将尝试从后端缓存加载。
|
||||
// 如果后端缓存也为空,则会重新计算。
|
||||
|
||||
// 我们也可以检查 `lastLoadTime` 来显示“上次更新:xxx”(如果已持久化)。
|
||||
const { lastLoadTime } = useAnalyticsStore()
|
||||
|
||||
const handleLoadCache = () => {
|
||||
navigate('/analytics/view')
|
||||
}
|
||||
|
||||
const handleNewAnalysis = () => {
|
||||
navigate('/analytics/view', { state: { forceRefresh: true } })
|
||||
}
|
||||
|
||||
const formatLastTime = (ts: number | null) => {
|
||||
if (!ts) return '无记录'
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="analytics-welcome-container">
|
||||
<div className="welcome-content">
|
||||
<div className="icon-wrapper">
|
||||
<BarChart2 size={40} />
|
||||
</div>
|
||||
<h1>私聊数据分析</h1>
|
||||
<p>
|
||||
WeFlow 可以分析您的聊天记录,生成详细的统计报表。<br />
|
||||
您可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
|
||||
</p>
|
||||
|
||||
<div className="action-cards">
|
||||
<button onClick={handleLoadCache}>
|
||||
<div className="card-icon">
|
||||
<History size={24} />
|
||||
</div>
|
||||
<h3>加载缓存</h3>
|
||||
<span>查看上次分析结果<br />(上次更新: {formatLastTime(lastLoadTime)})</span>
|
||||
</button>
|
||||
|
||||
<button onClick={handleNewAnalysis}>
|
||||
<div className="card-icon">
|
||||
<RefreshCcw size={24} />
|
||||
</div>
|
||||
<h3>新的分析</h3>
|
||||
<span>重新扫描并计算数据<br />(可能需要几分钟)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnalyticsWelcomePage
|
||||
@@ -1426,10 +1426,12 @@
|
||||
height: 6px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
50% {
|
||||
height: 16px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
height: 6px;
|
||||
opacity: 0.5;
|
||||
@@ -1875,4 +1877,4 @@
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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 } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import type { ChatSession, Message } from '../types/models'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
@@ -23,65 +24,10 @@ interface SessionDetail {
|
||||
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()
|
||||
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
||||
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
|
||||
// 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染)
|
||||
// 会话项组件(使用 memo 优化,避免不必要的重渲染)
|
||||
@@ -107,7 +53,12 @@ const SessionItem = React.memo(function SessionItem({
|
||||
className={`session-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => onSelect(session)}
|
||||
>
|
||||
<SessionAvatar session={session} size={48} />
|
||||
<Avatar
|
||||
src={session.avatarUrl}
|
||||
name={session.displayName || session.username}
|
||||
size={48}
|
||||
className={session.username.includes('@chatroom') ? 'group' : ''}
|
||||
/>
|
||||
<div className="session-info">
|
||||
<div className="session-top">
|
||||
<span className="session-name">{session.displayName || session.username}</span>
|
||||
@@ -138,109 +89,7 @@ const SessionItem = React.memo(function SessionItem({
|
||||
)
|
||||
})
|
||||
|
||||
const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) {
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [shouldLoad, setShouldLoad] = useState(false)
|
||||
const [isInQueue, setIsInQueue] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isGroup = session.username.includes('@chatroom')
|
||||
|
||||
const getAvatarLetter = (): string => {
|
||||
const name = session.displayName || session.username
|
||||
if (!name) return '?'
|
||||
const chars = [...name]
|
||||
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(() => {
|
||||
setImageLoaded(false)
|
||||
setImageError(false)
|
||||
setShouldLoad(false)
|
||||
setIsInQueue(false)
|
||||
}, [session.avatarUrl])
|
||||
|
||||
// 检查图片是否已经从缓存加载完成
|
||||
useEffect(() => {
|
||||
if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
||||
setImageLoaded(true)
|
||||
}
|
||||
}, [session.avatarUrl, shouldLoad])
|
||||
|
||||
const hasValidUrl = session.avatarUrl && !imageError && shouldLoad
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`session-avatar ${isGroup ? 'group' : ''} ${hasValidUrl && !imageLoaded ? 'loading' : ''}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{hasValidUrl ? (
|
||||
<>
|
||||
{!imageLoaded && <div className="avatar-skeleton" />}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={session.avatarUrl}
|
||||
alt=""
|
||||
className={imageLoaded ? 'loaded' : ''}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span className="avatar-letter">{getAvatarLetter()}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数,只在关键属性变化时重渲染
|
||||
return (
|
||||
prevProps.session.username === nextProps.session.username &&
|
||||
prevProps.session.displayName === nextProps.session.displayName &&
|
||||
prevProps.session.avatarUrl === nextProps.session.avatarUrl &&
|
||||
prevProps.size === nextProps.size
|
||||
)
|
||||
})
|
||||
|
||||
function ChatPage(_props: ChatPageProps) {
|
||||
const {
|
||||
@@ -380,10 +229,8 @@ function ChatPage(_props: ChatPageProps) {
|
||||
// 确保 nextSessions 也是数组
|
||||
if (Array.isArray(nextSessions)) {
|
||||
setSessions(nextSessions)
|
||||
// 延迟启动联系人信息加载,确保UI先渲染完成
|
||||
setTimeout(() => {
|
||||
void enrichSessionsContactInfo(nextSessions)
|
||||
}, 500)
|
||||
// 立即启动联系人信息加载,不再延迟 500ms
|
||||
void enrichSessionsContactInfo(nextSessions)
|
||||
} else {
|
||||
console.error('mergeSessions returned non-array:', nextSessions)
|
||||
setSessions(sessionsArray)
|
||||
@@ -420,8 +267,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`)
|
||||
const totalStart = performance.now()
|
||||
|
||||
// 延迟启动,等待UI渲染完成
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
// 移除初始 500ms 延迟,让后台加载与 UI 渲染并行
|
||||
|
||||
// 检查是否被取消
|
||||
if (enrichCancelledRef.current) {
|
||||
@@ -430,8 +276,8 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
|
||||
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)
|
||||
if (needEnrich.length === 0) {
|
||||
console.log('[性能监控] 所有联系人信息已缓存,跳过加载')
|
||||
isEnrichingRef.current = false
|
||||
@@ -584,9 +430,15 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
|
||||
if (result.success && result.contacts) {
|
||||
// 将更新加入队列,而不是立即更新
|
||||
// 将更新加入队列,用于侧边栏更新
|
||||
for (const [username, contact] of Object.entries(result.contacts)) {
|
||||
contactUpdateQueueRef.current.set(username, contact)
|
||||
|
||||
// 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用
|
||||
senderAvatarCache.set(username, {
|
||||
avatarUrl: contact.avatarUrl,
|
||||
displayName: contact.displayName
|
||||
})
|
||||
}
|
||||
// 触发批量更新
|
||||
flushContactUpdates()
|
||||
@@ -660,6 +512,31 @@ function ChatPage(_props: ChatPageProps) {
|
||||
if (result.success && result.messages) {
|
||||
if (offset === 0) {
|
||||
setMessages(result.messages)
|
||||
|
||||
// 预取发送者信息:在关闭加载遮罩前处理
|
||||
const unreadCount = session?.unreadCount ?? 0
|
||||
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) {
|
||||
console.log(`[性能监控] 预取消息发送者信息: ${unknownSenders.length} 个`)
|
||||
// 在批量请求前,先将这些发送者标记为加载中,防止 MessageBubble 触发重复请求
|
||||
const batchPromise = loadContactInfoBatch(unknownSenders)
|
||||
unknownSenders.forEach(username => {
|
||||
if (!senderAvatarLoading.has(username)) {
|
||||
senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null))
|
||||
}
|
||||
})
|
||||
// 确保在请求完成后清理 loading 状态
|
||||
batchPromise.finally(() => {
|
||||
unknownSenders.forEach(username => senderAvatarLoading.delete(username))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 首次加载滚动到底部
|
||||
requestAnimationFrame(() => {
|
||||
if (messageListRef.current) {
|
||||
@@ -668,6 +545,27 @@ function ChatPage(_props: ChatPageProps) {
|
||||
})
|
||||
} 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) {
|
||||
const batchPromise = loadContactInfoBatch(unknownSenders)
|
||||
unknownSenders.forEach(username => {
|
||||
if (!senderAvatarLoading.has(username)) {
|
||||
senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null))
|
||||
}
|
||||
})
|
||||
batchPromise.finally(() => {
|
||||
unknownSenders.forEach(username => senderAvatarLoading.delete(username))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置
|
||||
if (firstMsgEl && listEl) {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1101,8 +999,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ... (previous content) ... */}
|
||||
{isLoadingSessions ? (
|
||||
<div className="loading-sessions">
|
||||
{/* ... (skeleton items) ... */}
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="skeleton-item">
|
||||
<div className="skeleton-avatar" />
|
||||
@@ -1118,12 +1018,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
className="session-list"
|
||||
ref={sessionListRef}
|
||||
onScroll={() => {
|
||||
// 标记正在滚动,暂停联系人信息加载
|
||||
isScrollingRef.current = true
|
||||
if (sessionScrollTimeoutRef.current) {
|
||||
clearTimeout(sessionScrollTimeoutRef.current)
|
||||
}
|
||||
// 滚动结束后200ms才认为滚动停止
|
||||
sessionScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
isScrollingRef.current = false
|
||||
sessionScrollTimeoutRef.current = null
|
||||
@@ -1147,6 +1045,8 @@ function ChatPage(_props: ChatPageProps) {
|
||||
<p className="hint">请先在数据管理页面解密数据库</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* 拖动调节条 */}
|
||||
@@ -1157,7 +1057,12 @@ function ChatPage(_props: ChatPageProps) {
|
||||
{currentSession ? (
|
||||
<>
|
||||
<div className="message-header">
|
||||
<SessionAvatar session={currentSession} size={40} />
|
||||
<Avatar
|
||||
src={currentSession.avatarUrl}
|
||||
name={currentSession.displayName || currentSession.username}
|
||||
size={40}
|
||||
className={isGroupChat(currentSession.username) ? 'group session-avatar' : 'session-avatar'}
|
||||
/>
|
||||
<div className="header-info">
|
||||
<h3>{currentSession.displayName || currentSession.username}</h3>
|
||||
{isGroupChat(currentSession.username) && (
|
||||
@@ -1767,13 +1672,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showImagePreview && (
|
||||
{showImagePreview && createPortal(
|
||||
<div className="image-preview-overlay" onClick={() => setShowImagePreview(false)}>
|
||||
<img src={imageLocalPath} alt="图片预览" onClick={(e) => e.stopPropagation()} />
|
||||
<button className="image-preview-close" onClick={() => setShowImagePreview(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -1920,11 +1826,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
||||
)}
|
||||
<div className={`message-bubble ${bubbleClass} ${isEmoji && message.emojiCdnUrl && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVoice ? 'voice' : ''}`}>
|
||||
<div className="bubble-avatar">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" />
|
||||
) : (
|
||||
<span className="avatar-letter">{avatarLetter}</span>
|
||||
)}
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={!isSent ? (isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username)) : '我'}
|
||||
size={36}
|
||||
className="bubble-avatar"
|
||||
// If it's sent by me (isSent), we might not want 'group' class even if it's a group chat.
|
||||
// But 'group' class mainly handles default avatar icon.
|
||||
// Let's rely on standard Avatar behavior.
|
||||
/>
|
||||
</div>
|
||||
<div className="bubble-body">
|
||||
{/* 群聊中显示发送者名称 */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import DateRangePicker from '../components/DateRangePicker'
|
||||
import './GroupAnalyticsPage.scss'
|
||||
@@ -177,7 +178,7 @@ function GroupAnalyticsPage() {
|
||||
|
||||
const getMediaOption = () => {
|
||||
if (!mediaStats || mediaStats.typeCounts.length === 0) return {}
|
||||
|
||||
|
||||
// 定义颜色映射
|
||||
const colorMap: Record<number, string> = {
|
||||
1: '#3b82f6', // 文本 - 蓝色
|
||||
@@ -188,13 +189,13 @@ function GroupAnalyticsPage() {
|
||||
49: '#14b8a6', // 链接/文件 - 青色
|
||||
[-1]: '#6b7280', // 其他 - 灰色
|
||||
}
|
||||
|
||||
|
||||
const data = mediaStats.typeCounts.map(item => ({
|
||||
name: item.name,
|
||||
value: item.count,
|
||||
itemStyle: { color: colorMap[item.type] || '#6b7280' }
|
||||
}))
|
||||
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
series: [{
|
||||
@@ -202,8 +203,8 @@ function GroupAnalyticsPage() {
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '50%'],
|
||||
itemStyle: { borderRadius: 8, borderColor: 'rgba(255,255,255,0.1)', borderWidth: 2 },
|
||||
label: {
|
||||
show: true,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { name: string; percent: number }) => {
|
||||
// 只显示占比大于3%的标签
|
||||
return params.percent > 3 ? `${params.name}\n${params.percent.toFixed(1)}%` : ''
|
||||
@@ -256,11 +257,7 @@ function GroupAnalyticsPage() {
|
||||
</button>
|
||||
<div className="modal-content">
|
||||
<div className="member-avatar large">
|
||||
{selectedMember.avatarUrl ? (
|
||||
<img src={selectedMember.avatarUrl} alt="" />
|
||||
) : (
|
||||
<div className="avatar-placeholder"><User size={48} /></div>
|
||||
)}
|
||||
<Avatar src={selectedMember.avatarUrl} name={selectedMember.displayName} size={96} />
|
||||
</div>
|
||||
<h3 className="member-display-name">{selectedMember.displayName}</h3>
|
||||
<div className="member-details">
|
||||
@@ -334,7 +331,7 @@ function GroupAnalyticsPage() {
|
||||
onClick={() => handleGroupSelect(group)}
|
||||
>
|
||||
<div className="group-avatar">
|
||||
{group.avatarUrl ? <img src={group.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={20} /></div>}
|
||||
<Avatar src={group.avatarUrl} name={group.displayName} size={44} />
|
||||
</div>
|
||||
<div className="group-info">
|
||||
<span className="group-name">{group.displayName}</span>
|
||||
@@ -352,7 +349,7 @@ function GroupAnalyticsPage() {
|
||||
<div className="function-menu">
|
||||
<div className="selected-group-info">
|
||||
<div className="group-avatar large">
|
||||
{selectedGroup?.avatarUrl ? <img src={selectedGroup.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={40} /></div>}
|
||||
<Avatar src={selectedGroup?.avatarUrl} name={selectedGroup?.displayName} size={80} />
|
||||
</div>
|
||||
<h2>{selectedGroup?.displayName}</h2>
|
||||
<p>{selectedGroup?.memberCount} 位成员</p>
|
||||
@@ -424,7 +421,7 @@ function GroupAnalyticsPage() {
|
||||
{members.map(member => (
|
||||
<div key={member.username} className="member-card" onClick={() => handleMemberClick(member)}>
|
||||
<div className="member-avatar">
|
||||
{member.avatarUrl ? <img src={member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
<Avatar src={member.avatarUrl} name={member.displayName} size={48} />
|
||||
</div>
|
||||
<span className="member-name">{member.displayName}</span>
|
||||
</div>
|
||||
@@ -437,7 +434,7 @@ function GroupAnalyticsPage() {
|
||||
<div key={item.member.username} className="ranking-item">
|
||||
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||
<div className="contact-avatar">
|
||||
{item.member.avatarUrl ? <img src={item.member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
<Avatar src={item.member.avatarUrl} name={item.member.displayName} size={40} />
|
||||
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
|
||||
@@ -484,6 +484,7 @@ function SettingsPage() {
|
||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||
</button>
|
||||
{imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>}
|
||||
{isFetchingImageKey && <div className="form-hint status-text">正在扫描内存,请稍候...</div>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
|
||||
@@ -506,7 +506,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
||||
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
||||
<div className="field-hint">如果获取秘钥失败 请在微信打开后等待10秒后再登录</div>
|
||||
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -533,6 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
</button>
|
||||
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
||||
<div className="field-hint">如获取失败,请先打开朋友圈图片再重试</div>
|
||||
{isFetchingImageKey && <div className="field-hint status-text">正在扫描内存,请稍候...</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user