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