重构ChatWindow和MessageEnter组件以提高性能和可维护性。利用useCallback和useMemo进行备忘录化,增强文件类型处理,并简化消息呈现逻辑。在MessageList中引入SessionItem组件以更好地管理会话。
This commit is contained in:
@@ -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 />
|
||||
聊天记录
|
||||
</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 />
|
||||
聊天记录
|
||||
</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}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user