新增同步状态提示栏,优化消息列表组件的同步逻辑,支持逐页同步并即时更新UI,提升用户体验和代码可读性。

This commit is contained in:
超级老白兔
2025-11-28 16:32:23 +08:00
parent 9e76d39707
commit 23de3cac7e
3 changed files with 276 additions and 216 deletions

View File

@@ -242,3 +242,41 @@
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: 8px;
} }
// 同步状态提示栏样式
.syncStatusBar {
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #f0f0f0;
background-color: #fafafa;
padding: 0 16px;
flex-shrink: 0;
}
.syncStatusContent {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: space-between;
padding: 0px 20px;
}
.syncStatusText {
font-size: 14px;
color: #666;
}
.syncButton {
color: green;
cursor: pointer;
font-size: 14px;
&:hover {
color: green;
}
&:active {
transform: scale(0.98);
}
}

View File

@@ -1,20 +1,13 @@
import React, { useEffect, useState, useRef } from "react"; import React, { useEffect, useState, useRef } from "react";
import { import { List, Avatar, Badge, Modal, Input, message } from "antd";
List,
Avatar,
Badge,
Modal,
Input,
message,
Skeleton,
Spin,
} from "antd";
import { import {
UserOutlined, UserOutlined,
TeamOutlined, TeamOutlined,
PushpinOutlined, PushpinOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
LoadingOutlined,
CheckCircleOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import styles from "./com.module.scss"; import styles from "./com.module.scss";
import { import {
@@ -47,14 +40,13 @@ const MessageList: React.FC<MessageListProps> = () => {
// Store状态 // Store状态
const { const {
loading,
hasLoadedOnce, hasLoadedOnce,
setLoading,
setHasLoadedOnce, setHasLoadedOnce,
sessions, sessions,
setSessions: setSessionState, setSessions: setSessionState,
} = useMessageStore(); } = useMessageStore();
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]); const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]);
const [syncing, setSyncing] = useState(false); // 同步状态
// 右键菜单相关状态 // 右键菜单相关状态
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
@@ -306,70 +298,104 @@ const MessageList: React.FC<MessageListProps> = () => {
// ==================== 数据加载 ==================== // ==================== 数据加载 ====================
// 与服务器同步数据 // 与服务器同步数据优化版逐页同步立即更新UI
const syncWithServer = async () => { const syncWithServer = async () => {
if (!currentUserId) return; if (!currentUserId) return;
setSyncing(true); // 开始同步,显示同步状态栏
try { try {
// 获取会话列表数据(分页获取所有数据)
let allMessages: any[] = [];
let page = 1; let page = 1;
const limit = 500; const limit = 500;
let hasMore = true; let hasMore = true;
let totalProcessed = 0;
let successCount = 0;
let failCount = 0;
// 分页获取会话列表 // 分页获取会话列表,每页成功后立即同步
while (hasMore) { while (hasMore) {
const result: any = await getMessageList({ try {
page, const result: any = await getMessageList({
limit, page,
}); limit,
});
if (!result || !Array.isArray(result) || result.length === 0) { if (!result || !Array.isArray(result) || result.length === 0) {
hasMore = false; hasMore = false;
break; break;
} }
allMessages = [...allMessages, ...result]; // 立即处理这一页的数据
const friends = result.filter(
(msg: any) => msg.dataType === "friend" || !msg.chatroomId,
);
const groups = result
.filter((msg: any) => msg.dataType === "group" || msg.chatroomId)
.map((msg: any) => ({
...msg,
chatroomAvatar: msg.chatroomAvatar || msg.avatar || "",
}));
if (result.length < limit) { // 立即同步这一页到数据库会触发UI更新
hasMore = false; // 分页同步时跳过删除检查,避免误删其他页的会话
} else { await MessageManager.syncSessions(
currentUserId,
{
friends,
groups,
},
{ skipDelete: true },
);
totalProcessed += result.length;
successCount++;
// 判断是否还有下一页
if (result.length < limit) {
hasMore = false;
} else {
page++;
}
} catch (error) {
// 忽略单页失败,继续处理下一页
console.error(`${page}页同步失败:`, error);
failCount++;
// 如果连续失败太多,停止同步
if (failCount >= 3) {
console.warn("连续失败次数过多,停止同步");
break;
}
// 继续下一页
page++; page++;
if (page > 100) {
// 防止无限循环
hasMore = false;
}
} }
} }
// 分离好友和群聊数据
const friends = allMessages.filter(
(msg: any) => msg.dataType === "friend" || !msg.chatroomId,
);
const groups = allMessages
.filter((msg: any) => msg.dataType === "group" || msg.chatroomId)
.map((msg: any) => {
// 确保群聊数据包含正确的头像字段
// 如果接口返回的是 avatar 字段,需要映射到 chatroomAvatar
return {
...msg,
chatroomAvatar: msg.chatroomAvatar || msg.avatar || "",
};
});
// 执行增量同步
const syncResult = await MessageManager.syncSessions(currentUserId, {
friends,
groups,
});
// 同步后验证数据
const verifySession = await MessageManager.getUserSessions(currentUserId);
console.log("同步后的会话数据示例:", verifySession[0]);
console.log( console.log(
`会话同步完成: 新增${syncResult.added}, 更新${syncResult.updated}, 删除${syncResult.deleted}`, `会话同步完成: 成功${successCount}, 失败${failCount}, 共处理${totalProcessed}条数据`,
); );
// 会话管理器会在有变更时触发订阅回调
} catch (error) { } catch (error) {
console.error("同步服务器数据失败:", error); console.error("同步服务器数据失败:", error);
} finally {
setSyncing(false); // 同步完成,更新状态栏
}
};
// 手动触发同步的函数
const handleManualSync = async () => {
if (syncing) return; // 如果正在同步,不重复触发
setSyncing(true);
try {
await syncWithServer();
} catch (error) {
console.error("手动同步失败:", error);
} finally {
setSyncing(false);
} }
}; };
@@ -394,8 +420,6 @@ const MessageList: React.FC<MessageListProps> = () => {
const requestId = ++loadRequestRef.current; const requestId = ++loadRequestRef.current;
const initializeSessions = async () => { const initializeSessions = async () => {
setLoading(true);
try { try {
const cachedSessions = const cachedSessions =
await MessageManager.getUserSessions(currentUserId); await MessageManager.getUserSessions(currentUserId);
@@ -404,6 +428,7 @@ const MessageList: React.FC<MessageListProps> = () => {
return; return;
} }
// 有缓存数据立即显示
if (cachedSessions.length > 0) { if (cachedSessions.length > 0) {
setSessionState(cachedSessions); setSessionState(cachedSessions);
} }
@@ -411,12 +436,18 @@ const MessageList: React.FC<MessageListProps> = () => {
const needsFullSync = cachedSessions.length === 0 || !hasLoadedOnce; const needsFullSync = cachedSessions.length === 0 || !hasLoadedOnce;
if (needsFullSync) { if (needsFullSync) {
await syncWithServer(); // 不等待同步完成让它在后台进行第一页数据同步后会立即更新UI
if (isCancelled || loadRequestRef.current !== requestId) { syncWithServer()
return; .then(() => {
} if (!isCancelled && loadRequestRef.current === requestId) {
setHasLoadedOnce(true); setHasLoadedOnce(true);
}
})
.catch(error => {
console.error("同步失败:", error);
});
} else { } else {
// 后台同步
syncWithServer().catch(error => { syncWithServer().catch(error => {
console.error("后台同步失败:", error); console.error("后台同步失败:", error);
}); });
@@ -425,10 +456,6 @@ const MessageList: React.FC<MessageListProps> = () => {
if (!isCancelled) { if (!isCancelled) {
console.error("初始化会话列表失败:", error); console.error("初始化会话列表失败:", error);
} }
} finally {
if (!isCancelled && loadRequestRef.current === requestId) {
setLoading(false);
}
} }
}; };
@@ -526,13 +553,11 @@ const MessageList: React.FC<MessageListProps> = () => {
// 渲染完毕后自动点击第一个聊天记录 // 渲染完毕后自动点击第一个聊天记录
useEffect(() => { useEffect(() => {
// 只在以下条件满足时自动点击: // 只在以下条件满足时自动点击:
// 1. 不在加载状态 // 1. 有过滤后的会话列表
// 2. 有过滤后的会话列表 // 2. 当前没有选中的联系人
// 3. 当前没有选中的联系人 // 3. 还没有自动点击过
// 4. 还没有自动点击过 // 4. 不在搜索状态(避免搜索时自动切换)
// 5. 不在搜索状态(避免搜索时自动切换)
if ( if (
!loading &&
filteredSessions.length > 0 && filteredSessions.length > 0 &&
!currentContract && !currentContract &&
!autoClickRef.current && !autoClickRef.current &&
@@ -550,7 +575,7 @@ const MessageList: React.FC<MessageListProps> = () => {
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, filteredSessions, currentContract, searchKeyword]); }, [filteredSessions, currentContract, searchKeyword]);
// ==================== WebSocket消息处理 ==================== // ==================== WebSocket消息处理 ====================
@@ -796,156 +821,146 @@ const MessageList: React.FC<MessageListProps> = () => {
} }
}; };
// 渲染骨架屏 // 渲染同步状态提示栏
const renderSkeleton = () => ( const renderSyncStatusBar = () => (
<div className={styles.skeletonContainer}> <div className={styles.syncStatusBar}>
{Array(8) {syncing ? (
.fill(null) <div className={styles.syncStatusContent}>
.map((_, index) => ( <span className={styles.syncStatusText}>
<div key={`skeleton-${index}`} className={styles.skeletonItem}> <LoadingOutlined style={{ marginRight: "10px" }} /> ...
<Skeleton.Avatar active size={48} shape="circle" /> </span>
<div className={styles.skeletonContent}> </div>
<div className={styles.skeletonHeader}> ) : (
<Skeleton.Input active size="small" style={{ width: "40%" }} /> <div className={styles.syncStatusContent}>
<Skeleton.Input active size="small" style={{ width: "20%" }} /> <span className={styles.syncStatusText}>
</div> <CheckCircleOutlined
<Skeleton.Input style={{ color: "green", marginRight: "10px" }}
active />
size="small"
style={{ width: "70%", marginTop: "8px" }} </span>
/> <span className={styles.syncButton} onClick={handleManualSync}>
</div>
</div> </span>
))} </div>
</div> )}
);
// 渲染加载中状态(带旋转动画)
const renderLoading = () => (
<div className={styles.loadingContainer}>
<Spin size="large" tip="加载中...">
<div className={styles.loadingContent}>{renderSkeleton()}</div>
</Spin>
</div> </div>
); );
return ( return (
<div className={styles.messageList}> <div className={styles.messageList}>
{loading ? ( {/* 同步状态提示栏 */}
// 加载状态:显示加载动画和骨架屏 {renderSyncStatusBar()}
renderLoading()
) : ( <List
<> dataSource={filteredSessions as any[]}
<List renderItem={session => (
dataSource={filteredSessions as any[]} <List.Item
renderItem={session => ( key={session.id}
<List.Item className={`${styles.messageItem} ${
key={session.id} currentContract?.id === session.id ? styles.active : ""
className={`${styles.messageItem} ${ } ${(session.config as any)?.top ? styles.pinned : ""}`}
currentContract?.id === session.id ? styles.active : "" onClick={() => onContactClick(session)}
} ${(session.config as any)?.top ? styles.pinned : ""}`} onContextMenu={e => handleContextMenu(e, session)}
onClick={() => onContactClick(session)} >
onContextMenu={e => handleContextMenu(e, session)} <div className={styles.messageInfo}>
> <Badge count={session.config.unreadCount || 0} size="small">
<div className={styles.messageInfo}> <Avatar
<Badge count={session.config.unreadCount || 0} size="small"> size={48}
<Avatar src={session.avatar}
size={48} icon={
src={session.avatar} session?.type === "group" ? (
icon={ <TeamOutlined />
session?.type === "group" ? ( ) : (
<TeamOutlined /> <UserOutlined />
) : ( )
<UserOutlined /> }
) />
} </Badge>
/> <div className={styles.messageDetails}>
</Badge> <div className={styles.messageHeader}>
<div className={styles.messageDetails}> <div className={styles.messageName}>
<div className={styles.messageHeader}> {session.conRemark || session.nickname || session.wechatId}
<div className={styles.messageName}> </div>
{session.conRemark || <div className={styles.messageTime}>
session.nickname || {formatWechatTime(session?.lastUpdateTime)}
session.wechatId}
</div>
<div className={styles.messageTime}>
{formatWechatTime(session?.lastUpdateTime)}
</div>
</div>
<div className={styles.messageContent}>
{messageFilter(session.content)}
</div>
</div> </div>
</div> </div>
</List.Item> <div className={styles.messageContent}>
)} {messageFilter(session.content)}
/> </div>
{/* 右键菜单 */}
{contextMenu.visible && contextMenu.session && (
<div
ref={contextMenuRef}
className={styles.contextMenu}
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
zIndex: 1000,
}}
>
<div
className={styles.menuItem}
onClick={() => handleTogglePin(contextMenu.session!)}
>
<PushpinOutlined />
{(contextMenu.session.config as any)?.top ? "取消置顶" : "置顶"}
</div>
<div
className={styles.menuItem}
onClick={() => handleEditRemark(contextMenu.session!)}
>
<EditOutlined />
</div>
<div
className={styles.menuItem}
onClick={() => handleDelete(contextMenu.session!)}
>
<DeleteOutlined />
</div> </div>
</div> </div>
)} </List.Item>
)}
locale={{
emptyText:
filteredSessions.length === 0 && !syncing ? "暂无会话" : null,
}}
/>
{/* 修改备注Modal */} {/* 右键菜单 */}
<Modal {contextMenu.visible && contextMenu.session && (
title="修改备注" <div
open={editRemarkModal.visible} ref={contextMenuRef}
onOk={handleSaveRemark} className={styles.contextMenu}
onCancel={() => style={{
setEditRemarkModal({ position: "fixed",
visible: false, left: contextMenu.x,
session: null, top: contextMenu.y,
remark: "", zIndex: 1000,
}) }}
} >
okText="保存" <div
cancelText="取消" className={styles.menuItem}
onClick={() => handleTogglePin(contextMenu.session!)}
> >
<Input <PushpinOutlined />
value={editRemarkModal.remark} {(contextMenu.session.config as any)?.top ? "取消置顶" : "置顶"}
onChange={e => </div>
setEditRemarkModal(prev => ({ <div
...prev, className={styles.menuItem}
remark: e.target.value, onClick={() => handleEditRemark(contextMenu.session!)}
})) >
} <EditOutlined />
placeholder="请输入备注"
maxLength={20} </div>
/> <div
</Modal> className={styles.menuItem}
</> onClick={() => handleDelete(contextMenu.session!)}
>
<DeleteOutlined />
</div>
</div>
)} )}
{/* 修改备注Modal */}
<Modal
title="修改备注"
open={editRemarkModal.visible}
onOk={handleSaveRemark}
onCancel={() =>
setEditRemarkModal({
visible: false,
session: null,
remark: "",
})
}
okText="保存"
cancelText="取消"
>
<Input
value={editRemarkModal.remark}
onChange={e =>
setEditRemarkModal(prev => ({
...prev,
remark: e.target.value,
}))
}
placeholder="请输入备注"
maxLength={20}
/>
</Modal>
</div> </div>
); );
}; };

View File

@@ -259,6 +259,8 @@ export class MessageManager {
* 增量同步会话数据 * 增量同步会话数据
* @param userId 用户ID * @param userId 用户ID
* @param serverData 服务器数据 * @param serverData 服务器数据
* @param options 同步选项
* @param options.skipDelete 是否跳过删除检查(用于分页增量同步)
* @returns 同步结果统计 * @returns 同步结果统计
*/ */
static async syncSessions( static async syncSessions(
@@ -267,6 +269,9 @@ export class MessageManager {
friends?: ContractData[]; friends?: ContractData[];
groups?: weChatGroup[]; groups?: weChatGroup[];
}, },
options?: {
skipDelete?: boolean; // 是否跳过删除检查(用于分页增量同步)
},
): Promise<{ ): Promise<{
added: number; added: number;
updated: number; updated: number;
@@ -323,10 +328,12 @@ export class MessageManager {
} }
} }
// 检查删除 // 检查删除(仅在非增量同步模式下执行)
for (const localSession of localSessions) { if (!options?.skipDelete) {
if (!serverSessionMap.has(localSession.serverId)) { for (const localSession of localSessions) {
toDelete.push(localSession.serverId); if (!serverSessionMap.has(localSession.serverId)) {
toDelete.push(localSession.serverId);
}
} }
} }