重构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 React, { useEffect, useState, useCallback } from "react";
import { import { Layout, Button, message, Tooltip } from "antd";
Layout,
Input,
Button,
Modal,
message,
Tooltip,
AutoComplete,
Input as AntInput,
Spin,
} from "antd";
import { import {
SendOutlined, SendOutlined,
FolderOutlined, FolderOutlined,
@@ -36,7 +26,6 @@ import {
import { useContactStore } from "@/store/module/weChat/contacts"; import { useContactStore } from "@/store/module/weChat/contacts";
import SelectMap from "./components/selectMap"; import SelectMap from "./components/selectMap";
const { Footer } = Layout; const { Footer } = Layout;
const { TextArea } = Input;
interface MessageEnterProps { interface MessageEnterProps {
contract: ContractData | weChatGroup; contract: ContractData | weChatGroup;
@@ -44,6 +33,159 @@ interface MessageEnterProps {
const { sendCommand } = useWebSocketStore.getState(); 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 MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const EnterModule = useWeChatStore(state => state.EnterModule); const EnterModule = useWeChatStore(state => state.EnterModule);
@@ -78,270 +220,267 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const isAiTakeover = aiQuoteMessageContent === 2; // AI接管 const isAiTakeover = aiQuoteMessageContent === 2; // AI接管
// 取消AI生成 // 取消AI生成
const handleCancelAi = () => { const handleCancelAi = useCallback(() => {
// 清除AI请求定时器和队列
clearAiRequestQueue("用户手动取消"); clearAiRequestQueue("用户手动取消");
// 停止AI加载状态
updateIsLoadingAiChat(false); updateIsLoadingAiChat(false);
// 清空AI回复内容
updateQuoteMessageContent(""); updateQuoteMessageContent("");
message.info("已取消AI生成"); message.info("已取消AI生成");
}; }, [updateIsLoadingAiChat, updateQuoteMessageContent]);
// 监听输入框变化 - 用户开始输入时取消AI // 监听输入框变化 - 用户开始输入时取消AI
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = useCallback(
const newValue = e.target.value; (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInputValue(newValue); const newValue = e.target.value;
setInputValue(newValue);
// 如果用户开始输入且不是AI填充的内容取消AI请求 // 如果用户开始输入且不是AI填充的内容取消AI请求
if (newValue && newValue !== quoteMessageContent) { if (newValue && newValue !== quoteMessageContent && isLoadingAiChat) {
if (isLoadingAiChat) {
console.log("👤 用户开始输入取消AI生成"); console.log("👤 用户开始输入取消AI生成");
clearAiRequestQueue("用户开始输入"); clearAiRequestQueue("用户开始输入");
updateIsLoadingAiChat(false); updateIsLoadingAiChat(false);
updateQuoteMessageContent(""); updateQuoteMessageContent("");
} }
} },
}; [
isLoadingAiChat,
quoteMessageContent,
updateIsLoadingAiChat,
updateQuoteMessageContent,
],
);
// 手动触发AI生成 // 手动触发AI生成
const handleManualTriggerAi = async () => { const handleManualTriggerAi = useCallback(async () => {
const success = await manualTriggerAi(); const success = await manualTriggerAi();
if (success) { if (success) {
message.success("AI正在生成回复..."); message.success("AI正在生成回复...");
} else { } else {
message.warning("无法生成AI回复请检查消息记录"); message.warning("无法生成AI回复请检查消息记录");
} }
}; }, []);
// 发送消息(支持传入内容参数,避免闭包问题) // 发送消息(支持传入内容参数,避免闭包问题)
const handleSend = async (content?: string) => { const handleSend = useCallback(
const messageContent = content || inputValue; // 优先使用传入的内容 async (content?: string) => {
const messageContent = content || inputValue; // 优先使用传入的内容
if (!messageContent || !messageContent.trim()) { if (!messageContent || !messageContent.trim()) {
console.warn("消息内容为空,取消发送"); console.warn("消息内容为空,取消发送");
return; return;
} }
// 用户主动发送消息时取消AI请求 // 用户主动发送消息时取消AI请求
if (!content && isLoadingAiChat) { if (!content && isLoadingAiChat) {
console.log("👤 用户主动发送消息取消AI生成"); console.log("👤 用户主动发送消息取消AI生成");
clearAiRequestQueue("用户主动发送"); clearAiRequestQueue("用户主动发送");
updateIsLoadingAiChat(false); updateIsLoadingAiChat(false);
} }
console.log("handleSend", messageContent); console.log("handleSend", messageContent);
const messageId = +Date.now(); const messageId = +Date.now();
// 构造本地消息对象 // 构造本地消息对象
const localMessage: ChatRecord = { const localMessage: ChatRecord = {
id: messageId, // 使用时间戳作为临时ID id: messageId, // 使用时间戳作为临时ID
wechatAccountId: contract.wechatAccountId, wechatAccountId: contract.wechatAccountId,
wechatFriendId: contract?.chatroomId ? 0 : contract.id, wechatFriendId: contract?.chatroomId ? 0 : contract.id,
wechatChatroomId: contract?.chatroomId ? contract.id : 0, wechatChatroomId: contract?.chatroomId ? contract.id : 0,
tenantId: 0, tenantId: 0,
accountId: 0, accountId: 0,
synergyAccountId: 0, synergyAccountId: 0,
content: messageContent, content: messageContent,
msgType: 1, msgType: 1,
msgSubType: 0, msgSubType: 0,
msgSvrId: "", msgSvrId: "",
isSend: true, // 标记为发送中 isSend: true, // 标记为发送中
createTime: new Date().toISOString(), createTime: new Date().toISOString(),
isDeleted: false, isDeleted: false,
deleteTime: "", deleteTime: "",
sendStatus: 1, sendStatus: 1,
wechatTime: Date.now(), wechatTime: Date.now(),
origin: 0, origin: 0,
msgId: 0, msgId: 0,
recalled: false, recalled: false,
seq: messageId, seq: messageId,
}; };
// 先插入本地数据 // 先插入本地数据
addMessage(localMessage); addMessage(localMessage);
// 再发送消息到服务器 // 再发送消息到服务器
const params = { const params = {
wechatAccountId: contract.wechatAccountId, wechatAccountId: contract.wechatAccountId,
wechatChatroomId: contract?.chatroomId ? contract.id : 0, wechatChatroomId: contract?.chatroomId ? contract.id : 0,
wechatFriendId: contract?.chatroomId ? 0 : contract.id, wechatFriendId: contract?.chatroomId ? 0 : contract.id,
msgSubType: 0, msgSubType: 0,
msgType: 1, msgType: 1,
content: messageContent, content: messageContent,
seq: messageId, seq: messageId,
}; };
sendCommand("CmdSendMessage", params); sendCommand("CmdSendMessage", params);
// 清空输入框和AI回复内容 // 清空输入框和AI回复内容
setInputValue(""); setInputValue("");
updateQuoteMessageContent(""); updateQuoteMessageContent("");
}; },
[
addMessage,
contract.id,
contract.wechatAccountId,
contract?.chatroomId,
inputValue,
isLoadingAiChat,
updateIsLoadingAiChat,
updateQuoteMessageContent,
],
);
// AI 消息处理 - 只处理AI辅助模式 // AI 消息处理 - 只处理AI辅助模式
// AI接管模式已经在weChat.ts中直接发送不经过此组件 // AI接管模式已经在weChat.ts中直接发送不经过此组件
// 快捷语填充:当 quoteMessageContent 更新时,填充到输入框 // 快捷语填充:当 quoteMessageContent 更新时,填充到输入框
useEffect(() => { useEffect(() => {
if (quoteMessageContent) { if (quoteMessageContent) {
if (isAiAssist) { // AI辅助模式 & 快捷语模式:都直接填充输入框
// AI辅助模式直接填充输入框 setInputValue(quoteMessageContent);
setInputValue(quoteMessageContent);
} else {
// 快捷语模式:直接填充输入框(用户主动点击快捷语,应该替换当前内容)
setInputValue(quoteMessageContent);
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [quoteMessageContent, aiQuoteMessageContent, isAiAssist]); }, [quoteMessageContent, aiQuoteMessageContent, isAiAssist]);
const handleKeyPress = (e: React.KeyboardEvent) => { const handleKeyPress = useCallback(
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey) { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
e.preventDefault(); // 中文等输入法候选阶段,忽略 Enter避免误触发送和卡顿感
handleSend(); if ((e.nativeEvent as any).isComposing) {
} return;
// Ctrl+Enter 换行由 TextArea 自动处理,不需要阻止默认行为 }
};
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}]`); setInputValue(prevValue => prevValue + `[${emoji.name}]`);
}; }, []);
// 根据文件格式判断消息类型 const handleFileUploaded = useCallback(
const getMsgTypeByFileFormat = (filePath: string): number => { (
const extension = filePath.toLowerCase().split(".").pop() || ""; filePath: { url: string; name: string; durationMs?: number },
fileType: number,
) => {
console.log("handleFileUploaded: ", fileType, filePath);
// 图片格式 // msgType(1:文本 3:图片 43:视频 47:动图表情包gif、其他表情包 49:小程序/其他:图文、文件)
const imageFormats = [ let msgType = 1;
"jpg", let content: any = "";
"jpeg", if ([FileType.TEXT].includes(fileType)) {
"png", msgType = getMsgTypeByFileFormat(filePath.url);
"gif", } else if ([FileType.IMAGE].includes(fileType)) {
"bmp", msgType = 3;
"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) {
content = filePath.url; content = filePath.url;
} } else if ([FileType.AUDIO].includes(fileType)) {
if (msgType === 43) { msgType = 34;
content = filePath.url;
}
if (msgType === 49) {
content = JSON.stringify({ content = JSON.stringify({
type: "file",
title: filePath.name,
url: filePath.url, 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 = { const params = {
wechatAccountId: contract.wechatAccountId, wechatAccountId: contract.wechatAccountId,
wechatChatroomId: contract?.chatroomId ? contract.id : 0, wechatChatroomId: contract?.chatroomId ? contract.id : 0,
wechatFriendId: contract?.chatroomId ? 0 : contract.id, wechatFriendId: contract?.chatroomId ? 0 : contract.id,
msgSubType: 0, msgSubType: 0,
msgType, msgType,
content: content, content: content,
seq: messageId, seq: messageId,
}; };
// 构造本地消息对象 // 构造本地消息对象
const localMessage: ChatRecord = { const localMessage: ChatRecord = {
id: messageId, // 使用时间戳作为临时ID id: messageId, // 使用时间戳作为临时ID
wechatAccountId: contract.wechatAccountId, wechatAccountId: contract.wechatAccountId,
wechatFriendId: contract?.chatroomId ? 0 : contract.id, wechatFriendId: contract?.chatroomId ? 0 : contract.id,
wechatChatroomId: contract?.chatroomId ? contract.id : 0, wechatChatroomId: contract?.chatroomId ? contract.id : 0,
tenantId: 0, tenantId: 0,
accountId: 0, accountId: 0,
synergyAccountId: 0, synergyAccountId: 0,
content: params.content, content: params.content,
msgType: msgType, msgType: msgType,
msgSubType: 0, msgSubType: 0,
msgSvrId: "", msgSvrId: "",
isSend: true, // 标记为发送中 isSend: true, // 标记为发送中
createTime: new Date().toISOString(), createTime: new Date().toISOString(),
isDeleted: false, isDeleted: false,
deleteTime: "", deleteTime: "",
sendStatus: 1, sendStatus: 1,
wechatTime: Date.now(), wechatTime: Date.now(),
origin: 0, origin: 0,
msgId: 0, msgId: 0,
recalled: false, recalled: false,
seq: messageId, seq: messageId,
}; };
// 先插入本地数据 // 先插入本地数据
addMessage(localMessage); 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); updateShowCheckbox(false);
updateEnterModule("common"); updateEnterModule("common");
}; }, [EnterModule, updateShowCheckbox, updateEnterModule]);
const handTurnRignt = () => {
const handTurnRignt = useCallback(() => {
setTransmitModal(true); setTransmitModal(true);
}; }, [setTransmitModal]);
const openChatRecordModel = () => {
const openChatRecordModel = useCallback(() => {
updateShowChatRecordModel(!showChatRecordModel); updateShowChatRecordModel(!showChatRecordModel);
}; }, [showChatRecordModel, updateShowChatRecordModel]);
const [mapVisible, setMapVisible] = useState(false); 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 ( return (
<> <>
{/* 聊天输入 */} {/* 聊天输入 */}
@@ -394,96 +533,27 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
<> <>
{["common"].includes(EnterModule) && ( {["common"].includes(EnterModule) && (
<div className={styles.inputContainer}> <div className={styles.inputContainer}>
<div className={styles.inputToolbar}> <InputToolbar
<div className={styles.leftTool}> isAiAssist={isAiAssist}
<EmojiPicker onEmojiSelect={handleEmojiSelect} /> isAiTakeover={isAiTakeover}
<SimpleFileUpload isLoadingAiChat={isLoadingAiChat}
onFileUploaded={fileInfo => onEmojiSelect={handleEmojiSelect}
handleFileUploaded(fileInfo, FileType.FILE) onFileUploaded={handleFileUploaded}
} onAudioUploaded={handleAudioUploaded}
maxSize={10} onOpenMap={handleOpenMap}
type={4} onManualTriggerAi={handleManualTriggerAi}
slot={ onOpenChatRecord={openChatRecordModel}
<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>
<div className={styles.inputArea}> <div className={styles.inputArea}>
<div className={styles.inputWrapper}> <div className={styles.inputWrapper}>
<TextArea <textarea
value={inputValue} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyPress} onKeyDown={handleKeyPress}
placeholder="输入消息..." placeholder="输入消息..."
className={styles.messageInput} className={styles.messageInput}
autoSize={{ minRows: 2, maxRows: 6 }} rows={2}
/> />
<div className={styles.sendButtonArea}> <div className={styles.sendButtonArea}>
@@ -524,7 +594,7 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
</> </>
)} )}
</Footer> </Footer>
<SelectMap <MemoSelectMap
visible={mapVisible} visible={mapVisible}
onClose={() => setMapVisible(false)} onClose={() => setMapVisible(false)}
contract={contract} 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 { Avatar, Checkbox } from "antd";
import { UserOutlined, LoadingOutlined } from "@ant-design/icons"; import { UserOutlined, LoadingOutlined } from "@ant-design/icons";
import AudioMessage from "./components/AudioMessage/AudioMessage"; import AudioMessage from "./components/AudioMessage/AudioMessage";
@@ -148,6 +155,161 @@ type GroupRenderItem = {
wechatId?: string; 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 MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
@@ -204,7 +366,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
}; };
// 解析表情包文字格式[表情名称]并替换为img标签 // 解析表情包文字格式[表情名称]并替换为img标签
const parseEmojiText = (text: string): React.ReactNode[] => { const parseEmojiText = useCallback((text: string): React.ReactNode[] => {
const emojiRegex = /\[([^\]]+)\]/g; const emojiRegex = /\[([^\]]+)\]/g;
const parts: React.ReactNode[] = []; const parts: React.ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
@@ -252,143 +414,153 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
} }
return parts; return parts;
}; }, []);
const renderUnknownContent = ( const renderUnknownContent = useCallback(
rawContent: string, (
trimmedContent: string, rawContent: string,
msg?: ChatRecord, trimmedContent: string,
contract?: ContractData | weChatGroup, msg?: ChatRecord,
) => { contractParam?: ContractData | weChatGroup,
if (isLegacyEmojiContent(trimmedContent)) { ) => {
return renderEmojiContent(rawContent); 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 jsonData = tryParseContentJson(trimmedContent);
if (
jsonData.title === "微信转账" ||
(jsonData.transferid && jsonData.feedesc)
) {
return (
<TransferMessage content={rawContent} msg={msg} contract={contract} />
);
}
if (jsonData.type === "file" && msg && contract) { if (jsonData && typeof jsonData === "object") {
return ( // 判断是否为红包消息
<SmallProgramMessage if (
content={rawContent} jsonData.nativeurl &&
msg={msg} typeof jsonData.nativeurl === "string" &&
contract={contract} 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 ( if (jsonData.type === "file" && msg && contractParam) {
<div return (
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`} <SmallProgramMessage
> content={rawContent}
msg={msg}
contract={contractParam}
/>
);
}
if (jsonData.type === "link" && jsonData.title && jsonData.url) {
const { title, desc, thumbPath, url } = jsonData;
return (
<div <div
className={`${styles.miniProgramCard} ${styles.linkCard}`} className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
onClick={() => openInNewTab(url)}
> >
{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 <img
src={thumbPath} src={previewImageUrl}
alt="链接缩略图" alt="视频预览"
className={styles.miniProgramThumb} className={styles.videoPreview}
onClick={() => {
const videoUrl = jsonData.videoUrl || jsonData.tencentUrl;
if (videoUrl) {
openInNewTab(videoUrl);
}
}}
onError={event => { onError={event => {
const target = event.target as HTMLImageElement; 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.playButton}>
<div className={styles.miniProgramInfo}> <svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<div className={styles.miniProgramTitle}>{title}</div> <path d="M8 5v14l11-7z" />
{desc && <div className={styles.linkDescription}>{desc}</div>} </svg>
</div>
</div> </div>
</div> </div>
<div className={styles.miniProgramApp}></div> );
</div> }
);
} }
if (jsonData.previewImage && (jsonData.tencentUrl || jsonData.videoUrl)) { if (isHttpImageUrl(trimmedContent)) {
const previewImageUrl = String(jsonData.previewImage).replace( return renderImageContent({
/[`"']/g, src: rawContent,
"", alt: "图片消息",
); fallbackText: "[图片加载失败]",
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)) { if (isFileUrl(trimmedContent)) {
return renderImageContent({ return renderFileContent(trimmedContent);
src: rawContent, }
alt: "图片消息",
fallbackText: "[图片加载失败]",
});
}
if (isFileUrl(trimmedContent)) { return (
return renderFileContent(trimmedContent); <div className={styles.messageText}>{parseEmojiText(rawContent)}</div>
} );
},
return ( [parseEmojiText],
<div className={styles.messageText}>{parseEmojiText(rawContent)}</div> );
);
};
useEffect(() => { useEffect(() => {
const fetchGroupMembers = async () => { const fetchGroupMembers = async () => {
@@ -407,21 +579,33 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
fetchGroupMembers(); fetchGroupMembers();
}, [contract.id, contract.chatroomId]); }, [contract.id, contract.chatroomId]);
const renderGroupUser = (msg: ChatRecord) => { const groupMemberMap = useMemo(() => {
if (!msg) { const map = new Map<string, GroupRenderItem>();
return { avatar: "", nickname: "" }; groupRender.forEach(member => {
} if (member?.identifier) {
map.set(member.identifier, member);
}
});
return map;
}, [groupRender]);
const member = groupRender.find( const renderGroupUser = useCallback(
user => user?.identifier === msg?.senderWechatId, (msg: ChatRecord) => {
); if (!msg) {
console.log(member, "member"); return { avatar: "", nickname: "" };
}
return { const member = msg.senderWechatId
avatar: member?.avatar || msg?.avatar, ? groupMemberMap.get(msg.senderWechatId)
nickname: member?.nickname || msg?.senderNickname, : undefined;
};
}; return {
avatar: member?.avatar || msg?.avatar,
nickname: member?.nickname || msg?.senderNickname,
};
},
[groupMemberMap],
);
useEffect(() => { useEffect(() => {
const prevMessages = prevMessagesRef.current; const prevMessages = prevMessagesRef.current;
@@ -596,29 +780,28 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
// 获取群成员头像 // 获取群成员头像
// 清理微信ID前缀 // 清理微信ID前缀
const clearWechatidInContent = (sender: any, content: string) => { const clearWechatidInContent = useCallback((sender: any, content: string) => {
try { try {
return content.replace(new RegExp(`${sender?.wechatId}:\n`, "g"), ""); return content.replace(new RegExp(`${sender?.wechatId}:\n`, "g"), "");
} catch (err) { } catch (err) {
return "-"; return "-";
} }
}; }, []);
// 右键菜单事件处理 // 右键菜单事件处理
const handleContextMenu = ( const handleContextMenu = useCallback(
e: React.MouseEvent, (e: React.MouseEvent, msg: ChatRecord, isOwn: boolean) => {
msg: ChatRecord, e.preventDefault();
isOwn: boolean, setContextMenu({
) => { visible: true,
e.preventDefault(); x: e.clientX,
setContextMenu({ y: e.clientY,
visible: true, messageData: msg,
x: e.clientX, });
y: e.clientY, setNowIsOwn(isOwn);
messageData: msg, },
}); [],
setNowIsOwn(isOwn); );
};
const handleCloseContextMenu = () => { const handleCloseContextMenu = () => {
setContextMenu({ setContextMenu({
@@ -630,16 +813,21 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
}; };
// 处理checkbox选中状态变化 // 处理checkbox选中状态变化
const handleCheckboxChange = (checked: boolean, msg: ChatRecord) => { const handleCheckboxChange = useCallback(
if (checked) { (checked: boolean, msg: ChatRecord) => {
// 添加到选中记录 setSelectedRecords(prev => {
setSelectedRecords(prev => [...prev, msg]); let next: ChatRecord[];
} else { if (checked) {
// 从选中记录中移除 next = [...prev, msg];
setSelectedRecords(prev => prev.filter(record => record.id !== msg.id)); } else {
} next = prev.filter(record => record.id !== msg.id);
updateSelectedChatRecords(selectedRecords); }
}; updateSelectedChatRecords(next);
return next;
});
},
[updateSelectedChatRecords],
);
// 检查消息是否被选中 // 检查消息是否被选中
const isMessageSelected = (msg: ChatRecord) => { const isMessageSelected = (msg: ChatRecord) => {
@@ -662,124 +850,12 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
})); }));
}; };
// 渲染单条消息 const groupedMessages = useMemo(
const renderMessage = (msg: ChatRecord) => { () => groupMessagesByTime(currentMessages),
// 添加null检查防止访问null对象的属性 [currentMessages],
if (!msg) return null; );
const isOwn = msg?.isSend; const isGroupChat = !!contract.chatroomId;
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 loadMoreMessages = () => { const loadMoreMessages = () => {
if (messagesLoading || !currentMessagesHasMore) { if (messagesLoading || !currentMessagesHasMore) {
return; return;
@@ -871,7 +947,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
<div ref={messagesContainerRef} className={styles.messagesContainer}> <div ref={messagesContainerRef} className={styles.messagesContainer}>
<div <div
className={styles.loadMore} className={styles.loadMore}
onClick={() => loadMoreMessages()} onClick={loadMoreMessages}
style={{ style={{
cursor: cursor:
currentMessagesHasMore && !messagesLoading ? "pointer" : "default", currentMessagesHasMore && !messagesLoading ? "pointer" : "default",
@@ -881,7 +957,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
{currentMessagesHasMore ? "点击加载更早的信息" : "已经没有更早的消息了"} {currentMessagesHasMore ? "点击加载更早的信息" : "已经没有更早的消息了"}
{messagesLoading ? <LoadingOutlined /> : ""} {messagesLoading ? <LoadingOutlined /> : ""}
</div> </div>
{groupMessagesByTime(currentMessages).map((group, groupIndex) => ( {groupedMessages.map((group, groupIndex) => (
<React.Fragment key={`group-${groupIndex}`}> <React.Fragment key={`group-${groupIndex}`}>
{group.messages {group.messages
.filter(v => [10000, -10001].includes(v.msgType)) .filter(v => [10000, -10001].includes(v.msgType))
@@ -923,7 +999,23 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
{group.messages {group.messages
.filter(v => ![10000, 570425393, 90000, -10001].includes(v.msgType)) .filter(v => ![10000, 570425393, 90000, -10001].includes(v.msgType))
.map(msg => { .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> </React.Fragment>
))} ))}

View File

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

View File

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