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