重构ChatWindow和MessageEnter组件以提高性能和可维护性。利用useCallback和useMemo进行备忘录化,增强文件类型处理,并简化消息呈现逻辑。在MessageList中引入SessionItem组件以更好地管理会话。

This commit is contained in:
乘风
2025-12-03 09:46:19 +08:00
parent b5d6214521
commit 56a9a015f1
4 changed files with 900 additions and 698 deletions

View File

@@ -1,15 +1,5 @@
import React, { useEffect, useState, useRef } from "react";
import {
Layout,
Input,
Button,
Modal,
message,
Tooltip,
AutoComplete,
Input as AntInput,
Spin,
} from "antd";
import React, { useEffect, useState, useCallback } from "react";
import { Layout, Button, message, Tooltip } from "antd";
import {
SendOutlined,
FolderOutlined,
@@ -36,7 +26,6 @@ import {
import { useContactStore } from "@/store/module/weChat/contacts";
import SelectMap from "./components/selectMap";
const { Footer } = Layout;
const { TextArea } = Input;
interface MessageEnterProps {
contract: ContractData | weChatGroup;
@@ -44,6 +33,159 @@ interface MessageEnterProps {
const { sendCommand } = useWebSocketStore.getState();
const FileType = {
TEXT: 1,
IMAGE: 2,
VIDEO: 3,
AUDIO: 4,
FILE: 5,
};
const IMAGE_FORMATS = [
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"webp",
"svg",
"ico",
];
const VIDEO_FORMATS = [
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"3gp",
"rmvb",
];
// 根据文件格式判断消息类型(纯函数,放在组件外避免重复创建)
const getMsgTypeByFileFormat = (filePath: string): number => {
const extension = filePath.toLowerCase().split(".").pop() || "";
if (IMAGE_FORMATS.includes(extension)) {
return 3; // 图片
}
if (VIDEO_FORMATS.includes(extension)) {
return 43; // 视频
}
// 其他格式默认为文件
return 49; // 文件
};
const InputToolbar = React.memo(
({
isAiAssist,
isAiTakeover,
isLoadingAiChat,
onEmojiSelect,
onFileUploaded,
onAudioUploaded,
onOpenMap,
onManualTriggerAi,
onOpenChatRecord,
}: {
isAiAssist: boolean;
isAiTakeover: boolean;
isLoadingAiChat: boolean;
onEmojiSelect: (emoji: EmojiInfo) => void;
onFileUploaded: (
filePath: { url: string; name: string; durationMs?: number },
fileType: number,
) => void;
onAudioUploaded: (audioData: {
name: string;
url: string;
durationMs?: number;
}) => void;
onOpenMap: () => void;
onManualTriggerAi: () => void;
onOpenChatRecord: () => void;
}) => {
return (
<div className={styles.inputToolbar}>
<div className={styles.leftTool}>
<EmojiPicker onEmojiSelect={onEmojiSelect} />
<SimpleFileUpload
onFileUploaded={fileInfo => onFileUploaded(fileInfo, FileType.FILE)}
maxSize={10}
type={4}
slot={
<Button
className={styles.toolbarButton}
type="text"
icon={<FolderOutlined />}
/>
}
/>
<SimpleFileUpload
onFileUploaded={fileInfo =>
onFileUploaded(fileInfo, FileType.IMAGE)
}
maxSize={10}
type={1}
slot={
<Button
className={styles.toolbarButton}
type="text"
icon={<PictureOutlined />}
/>
}
/>
<AudioRecorder
onAudioUploaded={onAudioUploaded}
className={styles.toolbarButton}
/>
<Button
className={styles.toolbarButton}
type="text"
icon={<EnvironmentOutlined />}
onClick={onOpenMap}
/>
{/* AI模式下显示重新生成按钮 */}
{(isAiAssist || isAiTakeover) && (
<Tooltip title="重新生成AI回复">
<Button
className={styles.toolbarButton}
type="text"
icon={<ReloadOutlined />}
onClick={onManualTriggerAi}
disabled={isLoadingAiChat}
/>
</Tooltip>
)}
</div>
<div className={styles.rightTool}>
<ToContract className={styles.rightToolItem} />
<div
style={{
fontSize: "12px",
cursor: "pointer",
color: "#666",
}}
onClick={onOpenChatRecord}
>
<MessageOutlined />
&nbsp;
</div>
</div>
</div>
);
},
);
const MemoSelectMap: React.FC<React.ComponentProps<typeof SelectMap>> =
React.memo(props => <SelectMap {...props} />);
const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const [inputValue, setInputValue] = useState("");
const EnterModule = useWeChatStore(state => state.EnterModule);
@@ -78,270 +220,267 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const isAiTakeover = aiQuoteMessageContent === 2; // AI接管
// 取消AI生成
const handleCancelAi = () => {
// 清除AI请求定时器和队列
const handleCancelAi = useCallback(() => {
clearAiRequestQueue("用户手动取消");
// 停止AI加载状态
updateIsLoadingAiChat(false);
// 清空AI回复内容
updateQuoteMessageContent("");
message.info("已取消AI生成");
};
}, [updateIsLoadingAiChat, updateQuoteMessageContent]);
// 监听输入框变化 - 用户开始输入时取消AI
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
// 如果用户开始输入且不是AI填充的内容取消AI请求
if (newValue && newValue !== quoteMessageContent) {
if (isLoadingAiChat) {
// 如果用户开始输入且不是AI填充的内容取消AI请求
if (newValue && newValue !== quoteMessageContent && isLoadingAiChat) {
console.log("👤 用户开始输入取消AI生成");
clearAiRequestQueue("用户开始输入");
updateIsLoadingAiChat(false);
updateQuoteMessageContent("");
}
}
};
},
[
isLoadingAiChat,
quoteMessageContent,
updateIsLoadingAiChat,
updateQuoteMessageContent,
],
);
// 手动触发AI生成
const handleManualTriggerAi = async () => {
const handleManualTriggerAi = useCallback(async () => {
const success = await manualTriggerAi();
if (success) {
message.success("AI正在生成回复...");
} else {
message.warning("无法生成AI回复请检查消息记录");
}
};
}, []);
// 发送消息(支持传入内容参数,避免闭包问题)
const handleSend = async (content?: string) => {
const messageContent = content || inputValue; // 优先使用传入的内容
const handleSend = useCallback(
async (content?: string) => {
const messageContent = content || inputValue; // 优先使用传入的内容
if (!messageContent || !messageContent.trim()) {
console.warn("消息内容为空,取消发送");
return;
}
if (!messageContent || !messageContent.trim()) {
console.warn("消息内容为空,取消发送");
return;
}
// 用户主动发送消息时取消AI请求
if (!content && isLoadingAiChat) {
console.log("👤 用户主动发送消息取消AI生成");
clearAiRequestQueue("用户主动发送");
updateIsLoadingAiChat(false);
}
// 用户主动发送消息时取消AI请求
if (!content && isLoadingAiChat) {
console.log("👤 用户主动发送消息取消AI生成");
clearAiRequestQueue("用户主动发送");
updateIsLoadingAiChat(false);
}
console.log("handleSend", messageContent);
const messageId = +Date.now();
// 构造本地消息对象
const localMessage: ChatRecord = {
id: messageId, // 使用时间戳作为临时ID
wechatAccountId: contract.wechatAccountId,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
tenantId: 0,
accountId: 0,
synergyAccountId: 0,
content: messageContent,
msgType: 1,
msgSubType: 0,
msgSvrId: "",
isSend: true, // 标记为发送中
createTime: new Date().toISOString(),
isDeleted: false,
deleteTime: "",
sendStatus: 1,
wechatTime: Date.now(),
origin: 0,
msgId: 0,
recalled: false,
seq: messageId,
};
// 先插入本地数据
addMessage(localMessage);
console.log("handleSend", messageContent);
const messageId = +Date.now();
// 构造本地消息对象
const localMessage: ChatRecord = {
id: messageId, // 使用时间戳作为临时ID
wechatAccountId: contract.wechatAccountId,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
tenantId: 0,
accountId: 0,
synergyAccountId: 0,
content: messageContent,
msgType: 1,
msgSubType: 0,
msgSvrId: "",
isSend: true, // 标记为发送中
createTime: new Date().toISOString(),
isDeleted: false,
deleteTime: "",
sendStatus: 1,
wechatTime: Date.now(),
origin: 0,
msgId: 0,
recalled: false,
seq: messageId,
};
// 先插入本地数据
addMessage(localMessage);
// 再发送消息到服务器
const params = {
wechatAccountId: contract.wechatAccountId,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
msgSubType: 0,
msgType: 1,
content: messageContent,
seq: messageId,
};
sendCommand("CmdSendMessage", params);
// 再发送消息到服务器
const params = {
wechatAccountId: contract.wechatAccountId,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
msgSubType: 0,
msgType: 1,
content: messageContent,
seq: messageId,
};
sendCommand("CmdSendMessage", params);
// 清空输入框和AI回复内容
setInputValue("");
updateQuoteMessageContent("");
};
// 清空输入框和AI回复内容
setInputValue("");
updateQuoteMessageContent("");
},
[
addMessage,
contract.id,
contract.wechatAccountId,
contract?.chatroomId,
inputValue,
isLoadingAiChat,
updateIsLoadingAiChat,
updateQuoteMessageContent,
],
);
// AI 消息处理 - 只处理AI辅助模式
// AI接管模式已经在weChat.ts中直接发送不经过此组件
// 快捷语填充:当 quoteMessageContent 更新时,填充到输入框
useEffect(() => {
if (quoteMessageContent) {
if (isAiAssist) {
// AI辅助模式直接填充输入框
setInputValue(quoteMessageContent);
} else {
// 快捷语模式:直接填充输入框(用户主动点击快捷语,应该替换当前内容)
setInputValue(quoteMessageContent);
}
// AI辅助模式 & 快捷语模式:都直接填充输入框
setInputValue(quoteMessageContent);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quoteMessageContent, aiQuoteMessageContent, isAiAssist]);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
handleSend();
}
// Ctrl+Enter 换行由 TextArea 自动处理,不需要阻止默认行为
};
const handleKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// 中文等输入法候选阶段,忽略 Enter避免误触发送和卡顿感
if ((e.nativeEvent as any).isComposing) {
return;
}
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
handleSend();
}
// Ctrl+Enter 换行由浏览器原生 textarea 处理,不需要阻止默认行为
},
[handleSend],
);
// 处理表情选择
const handleEmojiSelect = (emoji: EmojiInfo) => {
const handleEmojiSelect = useCallback((emoji: EmojiInfo) => {
setInputValue(prevValue => prevValue + `[${emoji.name}]`);
};
}, []);
// 根据文件格式判断消息类型
const getMsgTypeByFileFormat = (filePath: string): number => {
const extension = filePath.toLowerCase().split(".").pop() || "";
const handleFileUploaded = useCallback(
(
filePath: { url: string; name: string; durationMs?: number },
fileType: number,
) => {
console.log("handleFileUploaded: ", fileType, filePath);
// 图片格式
const imageFormats = [
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"webp",
"svg",
"ico",
];
if (imageFormats.includes(extension)) {
return 3; // 图片
}
// 视频格式
const videoFormats = [
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"3gp",
"rmvb",
];
if (videoFormats.includes(extension)) {
return 43; // 视频
}
// 其他格式默认为文件
return 49; // 文件
};
const FileType = {
TEXT: 1,
IMAGE: 2,
VIDEO: 3,
AUDIO: 4,
FILE: 5,
};
const handleFileUploaded = (
filePath: { url: string; name: string; durationMs?: number },
fileType: number,
) => {
console.log("handleFileUploaded: ", fileType, filePath);
// msgType(1:文本 3:图片 43:视频 47:动图表情包gif、其他表情包 49:小程序/其他:图文、文件)
let msgType = 1;
let content: any = "";
if ([FileType.TEXT].includes(fileType)) {
msgType = getMsgTypeByFileFormat(filePath.url);
} else if ([FileType.IMAGE].includes(fileType)) {
msgType = 3;
content = filePath.url;
} else if ([FileType.AUDIO].includes(fileType)) {
msgType = 34;
content = JSON.stringify({
url: filePath.url,
durationMs: filePath.durationMs,
});
} else if ([FileType.FILE].includes(fileType)) {
msgType = getMsgTypeByFileFormat(filePath.url);
if (msgType === 3) {
// msgType(1:文本 3:图片 43:视频 47:动图表情包gif、其他表情包 49:小程序/其他:图文、文件)
let msgType = 1;
let content: any = "";
if ([FileType.TEXT].includes(fileType)) {
msgType = getMsgTypeByFileFormat(filePath.url);
} else if ([FileType.IMAGE].includes(fileType)) {
msgType = 3;
content = filePath.url;
}
if (msgType === 43) {
content = filePath.url;
}
if (msgType === 49) {
} else if ([FileType.AUDIO].includes(fileType)) {
msgType = 34;
content = JSON.stringify({
type: "file",
title: filePath.name,
url: filePath.url,
durationMs: filePath.durationMs,
});
} else if ([FileType.FILE].includes(fileType)) {
msgType = getMsgTypeByFileFormat(filePath.url);
if (msgType === 3) {
content = filePath.url;
}
if (msgType === 43) {
content = filePath.url;
}
if (msgType === 49) {
content = JSON.stringify({
type: "file",
title: filePath.name,
url: filePath.url,
});
}
}
}
const messageId = +Date.now();
const messageId = +Date.now();
const params = {
wechatAccountId: contract.wechatAccountId,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
msgSubType: 0,
msgType,
content: content,
seq: messageId,
};
const params = {
wechatAccountId: contract.wechatAccountId,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
msgSubType: 0,
msgType,
content: content,
seq: messageId,
};
// 构造本地消息对象
const localMessage: ChatRecord = {
id: messageId, // 使用时间戳作为临时ID
wechatAccountId: contract.wechatAccountId,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
tenantId: 0,
accountId: 0,
synergyAccountId: 0,
content: params.content,
msgType: msgType,
msgSubType: 0,
msgSvrId: "",
isSend: true, // 标记为发送中
createTime: new Date().toISOString(),
isDeleted: false,
deleteTime: "",
sendStatus: 1,
wechatTime: Date.now(),
origin: 0,
msgId: 0,
recalled: false,
seq: messageId,
};
// 先插入本地数据
addMessage(localMessage);
// 构造本地消息对象
const localMessage: ChatRecord = {
id: messageId, // 使用时间戳作为临时ID
wechatAccountId: contract.wechatAccountId,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
tenantId: 0,
accountId: 0,
synergyAccountId: 0,
content: params.content,
msgType: msgType,
msgSubType: 0,
msgSvrId: "",
isSend: true, // 标记为发送中
createTime: new Date().toISOString(),
isDeleted: false,
deleteTime: "",
sendStatus: 1,
wechatTime: Date.now(),
origin: 0,
msgId: 0,
recalled: false,
seq: messageId,
};
// 先插入本地数据
addMessage(localMessage);
sendCommand("CmdSendMessage", params);
};
sendCommand("CmdSendMessage", params);
},
[addMessage, contract.wechatAccountId, contract.chatroomId, contract.id],
);
const handleCancelAction = () => {
const handleCancelAction = useCallback(() => {
if (!EnterModule) return;
updateShowCheckbox(false);
updateEnterModule("common");
};
const handTurnRignt = () => {
}, [EnterModule, updateShowCheckbox, updateEnterModule]);
const handTurnRignt = useCallback(() => {
setTransmitModal(true);
};
const openChatRecordModel = () => {
}, [setTransmitModal]);
const openChatRecordModel = useCallback(() => {
updateShowChatRecordModel(!showChatRecordModel);
};
}, [showChatRecordModel, updateShowChatRecordModel]);
const [mapVisible, setMapVisible] = useState(false);
const handleOpenMap = useCallback(() => {
setMapVisible(true);
}, []);
const handleAudioUploaded = useCallback(
(audioData: { name: string; url: string; durationMs?: number }) => {
handleFileUploaded(
{
name: audioData.name,
url: audioData.url,
durationMs: audioData.durationMs,
},
FileType.AUDIO,
);
},
[handleFileUploaded],
);
return (
<>
{/* 聊天输入 */}
@@ -394,96 +533,27 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
<>
{["common"].includes(EnterModule) && (
<div className={styles.inputContainer}>
<div className={styles.inputToolbar}>
<div className={styles.leftTool}>
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
<SimpleFileUpload
onFileUploaded={fileInfo =>
handleFileUploaded(fileInfo, FileType.FILE)
}
maxSize={10}
type={4}
slot={
<Button
className={styles.toolbarButton}
type="text"
icon={<FolderOutlined />}
/>
}
/>
<SimpleFileUpload
onFileUploaded={fileInfo =>
handleFileUploaded(fileInfo, FileType.IMAGE)
}
maxSize={10}
type={1}
slot={
<Button
className={styles.toolbarButton}
type="text"
icon={<PictureOutlined />}
/>
}
/>
<AudioRecorder
onAudioUploaded={audioData =>
handleFileUploaded(
{
name: audioData.name,
url: audioData.url,
durationMs: audioData.durationMs,
},
FileType.AUDIO,
)
}
className={styles.toolbarButton}
/>
<Button
className={styles.toolbarButton}
type="text"
icon={<EnvironmentOutlined />}
onClick={() => setMapVisible(true)}
/>
{/* AI模式下显示重新生成按钮 */}
{(isAiAssist || isAiTakeover) && (
<Tooltip title="重新生成AI回复">
<Button
className={styles.toolbarButton}
type="text"
icon={<ReloadOutlined />}
onClick={handleManualTriggerAi}
disabled={isLoadingAiChat}
/>
</Tooltip>
)}
</div>
<div className={styles.rightTool}>
<ToContract className={styles.rightToolItem} />
<div
style={{
fontSize: "12px",
cursor: "pointer",
color: "#666",
}}
onClick={openChatRecordModel}
>
<MessageOutlined />
&nbsp;
</div>
</div>
</div>
<InputToolbar
isAiAssist={isAiAssist}
isAiTakeover={isAiTakeover}
isLoadingAiChat={isLoadingAiChat}
onEmojiSelect={handleEmojiSelect}
onFileUploaded={handleFileUploaded}
onAudioUploaded={handleAudioUploaded}
onOpenMap={handleOpenMap}
onManualTriggerAi={handleManualTriggerAi}
onOpenChatRecord={openChatRecordModel}
/>
<div className={styles.inputArea}>
<div className={styles.inputWrapper}>
<TextArea
<textarea
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyPress}
placeholder="输入消息..."
className={styles.messageInput}
autoSize={{ minRows: 2, maxRows: 6 }}
rows={2}
/>
<div className={styles.sendButtonArea}>
@@ -524,7 +594,7 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
</>
)}
</Footer>
<SelectMap
<MemoSelectMap
visible={mapVisible}
onClose={() => setMapVisible(false)}
contract={contract}

View File

@@ -1,4 +1,11 @@
import React, { CSSProperties, useEffect, useRef, useState } from "react";
import React, {
CSSProperties,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Avatar, Checkbox } from "antd";
import { UserOutlined, LoadingOutlined } from "@ant-design/icons";
import AudioMessage from "./components/AudioMessage/AudioMessage";
@@ -148,6 +155,161 @@ type GroupRenderItem = {
wechatId?: string;
};
interface MessageItemProps {
msg: ChatRecord;
contract: ContractData | weChatGroup;
isGroup: boolean;
showCheckbox: boolean;
isSelected: boolean;
currentCustomerAvatar?: string;
renderGroupUser: (msg: ChatRecord) => { avatar: string; nickname: string };
clearWechatidInContent: (sender: any, content: string) => string;
parseMessageContent: (
content: string | null | undefined,
msg: ChatRecord,
msgType?: number,
) => React.ReactNode;
onCheckboxChange: (checked: boolean, msg: ChatRecord) => void;
onContextMenu: (e: React.MouseEvent, msg: ChatRecord, isOwn: boolean) => void;
}
const MessageItem: React.FC<MessageItemProps> = React.memo(
({
msg,
contract,
isGroup,
showCheckbox,
isSelected,
currentCustomerAvatar,
renderGroupUser,
clearWechatidInContent,
parseMessageContent,
onCheckboxChange,
onContextMenu,
}) => {
if (!msg) return null;
const isOwn = msg?.isSend;
return (
<div
className={`${styles.messageItem} ${
isOwn ? styles.ownMessage : styles.otherMessage
}`}
onContextMenu={e => onContextMenu(e, msg, !!isOwn)}
>
<div className={styles.messageContent}>
{/* 单聊,自己不是发送方 */}
{!isGroup && !isOwn && (
<>
{showCheckbox && (
<div className={styles.checkboxContainer}>
<Checkbox
checked={isSelected}
onChange={e => onCheckboxChange(e.target.checked, msg)}
/>
</div>
)}
<Avatar
size={32}
src={contract.avatar}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div>
{!isOwn && (
<div className={styles.messageSender}>
{contract.nickname}
</div>
)}
<>{parseMessageContent(msg?.content, msg, msg?.msgType)}</>
</div>
</>
)}
{/* 群聊,自己不是发送方 */}
{isGroup && !isOwn && (
<>
{showCheckbox && (
<div className={styles.checkboxContainer}>
<Checkbox
checked={isSelected}
onChange={e => onCheckboxChange(e.target.checked, msg)}
/>
</div>
)}
<Avatar
size={32}
src={renderGroupUser(msg)?.avatar}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div>
{!isOwn && (
<div className={styles.messageSender}>
{renderGroupUser(msg)?.nickname}
</div>
)}
<>
{parseMessageContent(
clearWechatidInContent(msg?.sender, msg?.content),
msg,
msg?.msgType,
)}
</>
</div>
</>
)}
{/* 自己发送的消息 */}
{!!isOwn && (
<>
{showCheckbox && (
<div className={styles.checkboxContainer}>
<Checkbox
checked={isSelected}
onChange={e => onCheckboxChange(e.target.checked, msg)}
/>
</div>
)}
<Avatar
size={32}
src={currentCustomerAvatar || ""}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div>{parseMessageContent(msg?.content, msg, msg?.msgType)}</div>
{msg.sendStatus === 1 && (
<div
style={{
marginRight: "8px",
display: "flex",
alignItems: "center",
}}
>
<LoadingOutlined
spin
style={{ fontSize: "16px", color: "#1890ff" }}
/>
</div>
)}
</>
)}
</div>
</div>
);
},
(prev, next) =>
prev.msg === next.msg &&
prev.isGroup === next.isGroup &&
prev.showCheckbox === next.showCheckbox &&
prev.isSelected === next.isSelected &&
prev.currentCustomerAvatar === next.currentCustomerAvatar &&
prev.contract === next.contract,
);
MessageItem.displayName = "MessageItem";
const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
@@ -204,7 +366,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
};
// 解析表情包文字格式[表情名称]并替换为img标签
const parseEmojiText = (text: string): React.ReactNode[] => {
const parseEmojiText = useCallback((text: string): React.ReactNode[] => {
const emojiRegex = /\[([^\]]+)\]/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
@@ -252,143 +414,153 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
}
return parts;
};
}, []);
const renderUnknownContent = (
rawContent: string,
trimmedContent: string,
msg?: ChatRecord,
contract?: ContractData | weChatGroup,
) => {
if (isLegacyEmojiContent(trimmedContent)) {
return renderEmojiContent(rawContent);
}
const jsonData = tryParseContentJson(trimmedContent);
if (jsonData && typeof jsonData === "object") {
// 判断是否为红包消息
if (
jsonData.nativeurl &&
typeof jsonData.nativeurl === "string" &&
jsonData.nativeurl.includes(
"wxpay://c2cbizmessagehandler/hongbao/receivehongbao",
)
) {
return (
<RedPacketMessage
content={rawContent}
msg={msg}
contract={contract}
/>
);
const renderUnknownContent = useCallback(
(
rawContent: string,
trimmedContent: string,
msg?: ChatRecord,
contractParam?: ContractData | weChatGroup,
) => {
if (isLegacyEmojiContent(trimmedContent)) {
return renderEmojiContent(rawContent);
}
// 判断是否为转账消息
if (
jsonData.title === "微信转账" ||
(jsonData.transferid && jsonData.feedesc)
) {
return (
<TransferMessage content={rawContent} msg={msg} contract={contract} />
);
}
const jsonData = tryParseContentJson(trimmedContent);
if (jsonData.type === "file" && msg && contract) {
return (
<SmallProgramMessage
content={rawContent}
msg={msg}
contract={contract}
/>
);
}
if (jsonData && typeof jsonData === "object") {
// 判断是否为红包消息
if (
jsonData.nativeurl &&
typeof jsonData.nativeurl === "string" &&
jsonData.nativeurl.includes(
"wxpay://c2cbizmessagehandler/hongbao/receivehongbao",
)
) {
return (
<RedPacketMessage
content={rawContent}
msg={msg}
contract={contractParam}
/>
);
}
if (jsonData.type === "link" && jsonData.title && jsonData.url) {
const { title, desc, thumbPath, url } = jsonData;
// 判断是否为转账消息
if (
jsonData.title === "微信转账" ||
(jsonData.transferid && jsonData.feedesc)
) {
return (
<TransferMessage
content={rawContent}
msg={msg}
contract={contractParam}
/>
);
}
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
>
if (jsonData.type === "file" && msg && contractParam) {
return (
<SmallProgramMessage
content={rawContent}
msg={msg}
contract={contractParam}
/>
);
}
if (jsonData.type === "link" && jsonData.title && jsonData.url) {
const { title, desc, thumbPath, url } = jsonData;
return (
<div
className={`${styles.miniProgramCard} ${styles.linkCard}`}
onClick={() => openInNewTab(url)}
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
>
{thumbPath && (
<div
className={`${styles.miniProgramCard} ${styles.linkCard}`}
onClick={() => openInNewTab(url)}
>
{thumbPath && (
<img
src={thumbPath}
alt="链接缩略图"
className={styles.miniProgramThumb}
onError={event => {
const target = event.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
{desc && <div className={styles.linkDescription}>{desc}</div>}
</div>
</div>
<div className={styles.miniProgramApp}></div>
</div>
);
}
if (
jsonData.previewImage &&
(jsonData.tencentUrl || jsonData.videoUrl)
) {
const previewImageUrl = String(jsonData.previewImage).replace(
/[`"']/g,
"",
);
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<img
src={thumbPath}
alt="链接缩略图"
className={styles.miniProgramThumb}
src={previewImageUrl}
alt="视频预览"
className={styles.videoPreview}
onClick={() => {
const videoUrl = jsonData.videoUrl || jsonData.tencentUrl;
if (videoUrl) {
openInNewTab(videoUrl);
}
}}
onError={event => {
const target = event.target as HTMLImageElement;
target.style.display = "none";
const parent = target.parentElement?.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
)}
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
{desc && <div className={styles.linkDescription}>{desc}</div>}
<div className={styles.playButton}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
</div>
<div className={styles.miniProgramApp}></div>
</div>
);
);
}
}
if (jsonData.previewImage && (jsonData.tencentUrl || jsonData.videoUrl)) {
const previewImageUrl = String(jsonData.previewImage).replace(
/[`"']/g,
"",
);
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoPreview}
onClick={() => {
const videoUrl = jsonData.videoUrl || jsonData.tencentUrl;
if (videoUrl) {
openInNewTab(videoUrl);
}
}}
onError={event => {
const target = event.target as HTMLImageElement;
const parent = target.parentElement?.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
<div className={styles.playButton}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
</div>
);
if (isHttpImageUrl(trimmedContent)) {
return renderImageContent({
src: rawContent,
alt: "图片消息",
fallbackText: "[图片加载失败]",
});
}
}
if (isHttpImageUrl(trimmedContent)) {
return renderImageContent({
src: rawContent,
alt: "图片消息",
fallbackText: "[图片加载失败]",
});
}
if (isFileUrl(trimmedContent)) {
return renderFileContent(trimmedContent);
}
if (isFileUrl(trimmedContent)) {
return renderFileContent(trimmedContent);
}
return (
<div className={styles.messageText}>{parseEmojiText(rawContent)}</div>
);
};
return (
<div className={styles.messageText}>{parseEmojiText(rawContent)}</div>
);
},
[parseEmojiText],
);
useEffect(() => {
const fetchGroupMembers = async () => {
@@ -407,21 +579,33 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
fetchGroupMembers();
}, [contract.id, contract.chatroomId]);
const renderGroupUser = (msg: ChatRecord) => {
if (!msg) {
return { avatar: "", nickname: "" };
}
const groupMemberMap = useMemo(() => {
const map = new Map<string, GroupRenderItem>();
groupRender.forEach(member => {
if (member?.identifier) {
map.set(member.identifier, member);
}
});
return map;
}, [groupRender]);
const member = groupRender.find(
user => user?.identifier === msg?.senderWechatId,
);
console.log(member, "member");
const renderGroupUser = useCallback(
(msg: ChatRecord) => {
if (!msg) {
return { avatar: "", nickname: "" };
}
return {
avatar: member?.avatar || msg?.avatar,
nickname: member?.nickname || msg?.senderNickname,
};
};
const member = msg.senderWechatId
? groupMemberMap.get(msg.senderWechatId)
: undefined;
return {
avatar: member?.avatar || msg?.avatar,
nickname: member?.nickname || msg?.senderNickname,
};
},
[groupMemberMap],
);
useEffect(() => {
const prevMessages = prevMessagesRef.current;
@@ -596,29 +780,28 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
// 获取群成员头像
// 清理微信ID前缀
const clearWechatidInContent = (sender: any, content: string) => {
const clearWechatidInContent = useCallback((sender: any, content: string) => {
try {
return content.replace(new RegExp(`${sender?.wechatId}:\n`, "g"), "");
} catch (err) {
return "-";
}
};
}, []);
// 右键菜单事件处理
const handleContextMenu = (
e: React.MouseEvent,
msg: ChatRecord,
isOwn: boolean,
) => {
e.preventDefault();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
messageData: msg,
});
setNowIsOwn(isOwn);
};
const handleContextMenu = useCallback(
(e: React.MouseEvent, msg: ChatRecord, isOwn: boolean) => {
e.preventDefault();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
messageData: msg,
});
setNowIsOwn(isOwn);
},
[],
);
const handleCloseContextMenu = () => {
setContextMenu({
@@ -630,16 +813,21 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
};
// 处理checkbox选中状态变化
const handleCheckboxChange = (checked: boolean, msg: ChatRecord) => {
if (checked) {
// 添加到选中记录
setSelectedRecords(prev => [...prev, msg]);
} else {
// 从选中记录中移除
setSelectedRecords(prev => prev.filter(record => record.id !== msg.id));
}
updateSelectedChatRecords(selectedRecords);
};
const handleCheckboxChange = useCallback(
(checked: boolean, msg: ChatRecord) => {
setSelectedRecords(prev => {
let next: ChatRecord[];
if (checked) {
next = [...prev, msg];
} else {
next = prev.filter(record => record.id !== msg.id);
}
updateSelectedChatRecords(next);
return next;
});
},
[updateSelectedChatRecords],
);
// 检查消息是否被选中
const isMessageSelected = (msg: ChatRecord) => {
@@ -662,124 +850,12 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
}));
};
// 渲染单条消息
const renderMessage = (msg: ChatRecord) => {
// 添加null检查防止访问null对象的属性
if (!msg) return null;
const groupedMessages = useMemo(
() => groupMessagesByTime(currentMessages),
[currentMessages],
);
const isOwn = msg?.isSend;
const isGroup = !!contract.chatroomId;
return (
<div
key={msg.id || `msg-${Date.now()}`}
className={`${styles.messageItem} ${
isOwn ? styles.ownMessage : styles.otherMessage
}`}
onContextMenu={e => handleContextMenu(e, msg, isOwn)}
>
<div className={styles.messageContent}>
{/* 如果不是群聊 */}
{!isGroup && !isOwn && (
<>
{/* Checkbox 显示控制 */}
{showCheckbox && (
<div className={styles.checkboxContainer}>
<Checkbox
checked={isMessageSelected(msg)}
onChange={e => handleCheckboxChange(e.target.checked, msg)}
/>
</div>
)}
<Avatar
size={32}
src={contract.avatar}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div>
{!isOwn && (
<div className={styles.messageSender}>
{contract.nickname}
</div>
)}
<>{parseMessageContent(msg?.content, msg, msg?.msgType)}</>
</div>
</>
)}
{/* 如果是群聊 */}
{isGroup && !isOwn && (
<>
{/* 群聊场景下根据消息发送者匹配头像与昵称 */}
{showCheckbox && (
<div className={styles.checkboxContainer}>
<Checkbox
checked={isMessageSelected(msg)}
onChange={e => handleCheckboxChange(e.target.checked, msg)}
/>
</div>
)}
<Avatar
size={32}
src={renderGroupUser(msg)?.avatar}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div>
{!isOwn && (
<div className={styles.messageSender}>
{renderGroupUser(msg)?.nickname}
</div>
)}
<>
{parseMessageContent(
clearWechatidInContent(msg?.sender, msg?.content),
msg,
msg?.msgType,
)}
</>
</div>
</>
)}
{!!isOwn && (
<>
{/* Checkbox 显示控制 */}
{showCheckbox && (
<div className={styles.checkboxContainer}>
<Checkbox
checked={isMessageSelected(msg)}
onChange={e => handleCheckboxChange(e.target.checked, msg)}
/>
</div>
)}
<Avatar
size={32}
src={currentCustomer?.avatar || ""}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div>{parseMessageContent(msg?.content, msg, msg?.msgType)}</div>
{/* 发送状态 loading 图标 */}
{msg.sendStatus === 1 && (
<div
style={{
marginRight: "8px",
display: "flex",
alignItems: "center",
}}
>
<LoadingOutlined
spin
style={{ fontSize: "16px", color: "#1890ff" }}
/>
</div>
)}
</>
)}
</div>
</div>
);
};
const isGroupChat = !!contract.chatroomId;
const loadMoreMessages = () => {
if (messagesLoading || !currentMessagesHasMore) {
return;
@@ -871,7 +947,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
<div ref={messagesContainerRef} className={styles.messagesContainer}>
<div
className={styles.loadMore}
onClick={() => loadMoreMessages()}
onClick={loadMoreMessages}
style={{
cursor:
currentMessagesHasMore && !messagesLoading ? "pointer" : "default",
@@ -881,7 +957,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
{currentMessagesHasMore ? "点击加载更早的信息" : "已经没有更早的消息了"}
{messagesLoading ? <LoadingOutlined /> : ""}
</div>
{groupMessagesByTime(currentMessages).map((group, groupIndex) => (
{groupedMessages.map((group, groupIndex) => (
<React.Fragment key={`group-${groupIndex}`}>
{group.messages
.filter(v => [10000, -10001].includes(v.msgType))
@@ -923,7 +999,23 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
{group.messages
.filter(v => ![10000, 570425393, 90000, -10001].includes(v.msgType))
.map(msg => {
return renderMessage(msg);
if (!msg) return null;
return (
<MessageItem
key={msg.id}
msg={msg}
contract={contract}
isGroup={isGroupChat}
showCheckbox={showCheckbox}
isSelected={isMessageSelected(msg)}
currentCustomerAvatar={currentCustomer?.avatar || ""}
renderGroupUser={renderGroupUser}
clearWechatidInContent={clearWechatidInContent}
parseMessageContent={parseMessageContent}
onCheckboxChange={handleCheckboxChange}
onContextMenu={handleContextMenu}
/>
);
})}
</React.Fragment>
))}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
Layout,
Button,
@@ -60,104 +60,119 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
const [followupModalVisible, setFollowupModalVisible] = useState(false);
const [todoModalVisible, setTodoModalVisible] = useState(false);
const onToggleProfile = () => {
setShowProfile(!showProfile);
};
const onToggleProfile = useCallback(() => {
setShowProfile(prev => !prev);
}, []);
const handleFollowupClick = () => {
const handleFollowupClick = useCallback(() => {
setFollowupModalVisible(true);
};
}, []);
const handleFollowupModalClose = () => {
const handleFollowupModalClose = useCallback(() => {
setFollowupModalVisible(false);
};
}, []);
const handleTodoClick = () => {
const handleTodoClick = useCallback(() => {
setTodoModalVisible(true);
};
}, []);
const handleTodoModalClose = () => {
const handleTodoModalClose = useCallback(() => {
setTodoModalVisible(false);
};
}, []);
const [currentConfig, setCurrentConfig] = useState(
typeOptions.find(option => option.value === aiQuoteMessageContent),
typeOptions.find(option => option.value === aiQuoteMessageContent) ||
typeOptions[0],
);
useEffect(() => {
setCurrentConfig(
typeOptions.find(option => option.value === aiQuoteMessageContent),
);
const found =
typeOptions.find(option => option.value === aiQuoteMessageContent) ||
typeOptions[0];
setCurrentConfig(found);
}, [aiQuoteMessageContent]);
// 处理配置选择
const handleConfigChange = async option => {
setCurrentConfig({
value: option.value,
label: option.label,
});
try {
// 1. 保存配置到后端
await setFriendInjectConfig({
type: option.value,
wechatAccountId: contract.wechatAccountId,
friendId: contract.id,
const handleConfigChange = useCallback(
async (option: { value: number; label: string }) => {
setCurrentConfig({
value: option.value,
label: option.label,
});
// 2. 更新 Store 中的 AI 配置状态
updateAiQuoteMessageContent(option.value);
try {
// 1. 保存配置到后端
await setFriendInjectConfig({
type: option.value,
wechatAccountId: contract.wechatAccountId,
friendId: contract.id,
});
// 3. 确定联系人类型
const contactType: "friend" | "group" = contract.chatroomId
? "group"
: "friend";
const aiType = option.value;
// 2. 更新 Store 中的 AI 配置状态
updateAiQuoteMessageContent(option.value);
console.log(
`开始更新 aiType: contactId=${contract.id}, type=${contactType}, aiType=${aiType}`,
);
// 3. 确定联系人类型
const contactType: "friend" | "group" = contract.chatroomId
? "group"
: "friend";
const aiType = option.value;
// 4. 更新会话列表数据库的 aiType
await MessageManager.updateSession({
userId: currentUserId,
id: contract.id!,
type: contactType,
aiType: aiType,
});
console.log("✅ 会话列表数据库 aiType 更新成功");
console.log(
`开始更新 aiType: contactId=${contract.id}, type=${contactType}, aiType=${aiType}`,
);
// 5. 更新联系人数据库的 aiType
const contactInDb = await ContactManager.getContactByIdAndType(
currentUserId,
contract.id!,
contactType,
);
if (contactInDb) {
await ContactManager.updateContact({
...contactInDb,
// 4. 更新会话列表数据库的 aiType
await MessageManager.updateSession({
userId: currentUserId,
id: contract.id!,
type: contactType,
aiType: aiType,
});
console.log("✅ 联系人数据库 aiType 更新成功");
} else {
console.warn("⚠️ 联系人数据库中未找到该联系人");
console.log("✅ 会话列表数据库 aiType 更新成功");
// 5. 更新联系人数据库的 aiType
const contactInDb = await ContactManager.getContactByIdAndType(
currentUserId,
contract.id!,
contactType,
);
if (contactInDb) {
await ContactManager.updateContact({
...contactInDb,
aiType: aiType,
});
console.log("✅ 联系人数据库 aiType 更新成功");
} else {
console.warn("⚠️ 联系人数据库中未找到该联系人");
}
// 6. 更新 Store 中的 currentContract通过重新设置
const updatedContract = {
...contract,
aiType: aiType,
};
setCurrentContact(updatedContract);
console.log("✅ Store currentContract aiType 更新成功");
message.success(`已切换为${option.label}`);
} catch (error) {
console.error("更新 AI 配置失败:", error);
message.error("配置更新失败,请重试");
}
},
[contract, currentUserId, setCurrentContact, updateAiQuoteMessageContent],
);
// 6. 更新 Store 中的 currentContract通过重新设置
const updatedContract = {
...contract,
aiType: aiType,
};
setCurrentContact(updatedContract);
console.log("✅ Store currentContract aiType 更新成功");
message.success(`已切换为${option.label}`);
} catch (error) {
console.error("更新 AI 配置失败:", error);
message.error("配置更新失败,请重试");
}
};
const aiTypeMenuItems = useMemo(
() =>
typeOptions.map(option => ({
key: option.value,
label: option.label,
onClick: () => handleConfigChange(option),
})),
[handleConfigChange],
);
return (
<Layout className={styles.chatWindow}>
@@ -183,11 +198,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
{!contract.chatroomId && (
<Dropdown
menu={{
items: typeOptions.map(option => ({
key: option.value,
label: option.label,
onClick: () => handleConfigChange(option),
})),
items: aiTypeMenuItems,
}}
trigger={["click"]}
placement="bottomRight"

View File

@@ -30,6 +30,56 @@ import { messageFilter } from "@/utils/filter";
import { ChatSession } from "@/utils/db";
interface MessageListProps {}
interface SessionItemProps {
session: ChatSession;
isActive: boolean;
onClick: (session: ChatSession) => void;
onContextMenu: (e: React.MouseEvent, session: ChatSession) => void;
}
const SessionItem: React.FC<SessionItemProps> = React.memo(
({ session, isActive, onClick, onContextMenu }) => {
return (
<List.Item
className={`${styles.messageItem} ${isActive ? styles.active : ""} ${
(session.config as any)?.top ? styles.pinned : ""
}`}
onClick={() => onClick(session)}
onContextMenu={e => onContextMenu(e, session)}
>
<div className={styles.messageInfo}>
<Badge count={session.config.unreadCount || 0} size="small">
<Avatar
size={48}
src={session.avatar}
icon={
session?.type === "group" ? <TeamOutlined /> : <UserOutlined />
}
/>
</Badge>
<div className={styles.messageDetails}>
<div className={styles.messageHeader}>
<div className={styles.messageName}>
{session.conRemark || session.nickname || session.wechatId}
</div>
<div className={styles.messageTime}>
{formatWechatTime(session?.lastUpdateTime)}
</div>
</div>
<div className={styles.messageContent}>
{messageFilter(session.content)}
</div>
</div>
</div>
</List.Item>
);
},
(prev, next) =>
prev.isActive === next.isActive && prev.session === next.session,
) as React.FC<SessionItemProps>;
SessionItem.displayName = "SessionItem";
const MessageList: React.FC<MessageListProps> = () => {
const searchKeyword = useContactStore(state => state.searchKeyword);
const { setCurrentContact, currentContract } = useWeChatStore();
@@ -630,10 +680,10 @@ const MessageList: React.FC<MessageListProps> = () => {
);
}
// 根据搜索关键词进行模糊匹配(支持搜索昵称、备注名、微信号)
if (searchKeyword.trim()) {
const keyword = searchKeyword.toLowerCase();
const keyword = searchKeyword.trim().toLowerCase();
// 根据搜索关键词进行模糊匹配(支持搜索昵称、备注名、微信号)
if (keyword) {
// 如果搜索关键词可能是微信号,需要从联系人表补充 wechatId
const sessionsNeedingWechatId = filtered.filter(
v => !v.wechatId && v.type === "friend",
@@ -682,7 +732,16 @@ const MessageList: React.FC<MessageListProps> = () => {
setFilteredSessions(filtered);
};
filterSessions();
// 搜索过滤做简单防抖,减少频繁重算
const timer = window.setTimeout(() => {
filterSessions();
}, 200);
return () => {
if (timer) {
window.clearTimeout(timer);
}
};
}, [sessions, currentCustomer, searchKeyword, currentUserId]);
// 渲染完毕后自动点击第一个聊天记录
@@ -989,43 +1048,13 @@ const MessageList: React.FC<MessageListProps> = () => {
<List
dataSource={filteredSessions as any[]}
renderItem={session => (
<List.Item
<SessionItem
key={session.id}
className={`${styles.messageItem} ${
currentContract?.id === session.id ? styles.active : ""
} ${(session.config as any)?.top ? styles.pinned : ""}`}
onClick={() => onContactClick(session)}
onContextMenu={e => handleContextMenu(e, session)}
>
<div className={styles.messageInfo}>
<Badge count={session.config.unreadCount || 0} size="small">
<Avatar
size={48}
src={session.avatar}
icon={
session?.type === "group" ? (
<TeamOutlined />
) : (
<UserOutlined />
)
}
/>
</Badge>
<div className={styles.messageDetails}>
<div className={styles.messageHeader}>
<div className={styles.messageName}>
{session.conRemark || session.nickname || session.wechatId}
</div>
<div className={styles.messageTime}>
{formatWechatTime(session?.lastUpdateTime)}
</div>
</div>
<div className={styles.messageContent}>
{messageFilter(session.content)}
</div>
</div>
</div>
</List.Item>
session={session}
isActive={!!currentContract && currentContract.id === session.id}
onClick={onContactClick}
onContextMenu={handleContextMenu}
/>
)}
locale={{
emptyText: