feat(微信聊天): 实现消息撤回功能并优化右键菜单

添加消息撤回API接口及状态管理
在右键菜单中根据消息发送时间和归属显示撤回选项
优化消息记录组件与状态管理的交互逻辑
This commit is contained in:
超级老白兔
2025-09-18 17:58:33 +08:00
parent f0f64dd118
commit a53460b4cc
8 changed files with 193 additions and 284 deletions

View File

@@ -14,7 +14,7 @@ import AudioRecorder from "@/components/Upload/AudioRecorder";
import ToContract from "./components/toContract";
import ChatRecord from "./components/chatRecord";
import styles from "./MessageEnter.module.scss";
import { useWeChatStore } from "@/store/module/weChat/weChat";
const { Footer } = Layout;
const { TextArea } = Input;
@@ -27,6 +27,7 @@ const { sendCommand } = useWebSocketStore.getState();
const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const [inputValue, setInputValue] = useState("");
const [showMaterialModal, setShowMaterialModal] = useState(false);
const EnterModule = useWeChatStore(state => state.EnterModule);
const handleSend = async () => {
if (!inputValue.trim()) return;
@@ -134,76 +135,81 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
<>
{/* 聊天输入 */}
<Footer className={styles.chatFooter}>
<div className={styles.inputContainer}>
<div className={styles.inputToolbar}>
<div className={styles.leftTool}>
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.FILE)
}
maxSize={1}
type={4}
slot={
<Button
className={styles.toolbarButton}
type="text"
icon={<FolderOutlined />}
/>
}
/>
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.IMAGE)
}
maxSize={1}
type={1}
slot={
<Button
className={styles.toolbarButton}
type="text"
icon={<PictureOutlined />}
/>
}
/>
{["common"].includes(EnterModule) && (
<div className={styles.inputContainer}>
<div className={styles.inputToolbar}>
<div className={styles.leftTool}>
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.FILE)
}
maxSize={1}
type={4}
slot={
<Button
className={styles.toolbarButton}
type="text"
icon={<FolderOutlined />}
/>
}
/>
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.IMAGE)
}
maxSize={1}
type={1}
slot={
<Button
className={styles.toolbarButton}
type="text"
icon={<PictureOutlined />}
/>
}
/>
<AudioRecorder
onAudioUploaded={audioData =>
handleFileUploaded(audioData, FileType.AUDIO)
}
className={styles.toolbarButton}
/>
</div>
<div className={styles.rightTool}>
<ToContract className={styles.rightToolItem} />
<ChatRecord className={styles.rightToolItem} />
</div>
</div>
<div className={styles.inputArea}>
<div className={styles.inputWrapper}>
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="输入消息..."
className={styles.messageInput}
autoSize={{ minRows: 2, maxRows: 6 }}
/>
<div className={styles.sendButtonArea}>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
disabled={!inputValue.trim()}
className={styles.sendButton}
>
</Button>
<AudioRecorder
onAudioUploaded={audioData =>
handleFileUploaded(audioData, FileType.AUDIO)
}
className={styles.toolbarButton}
/>
</div>
<div className={styles.rightTool}>
<ToContract className={styles.rightToolItem} />
<ChatRecord className={styles.rightToolItem} />
</div>
</div>
<div className={styles.inputArea}>
<div className={styles.inputWrapper}>
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="输入消息..."
className={styles.messageInput}
autoSize={{ minRows: 2, maxRows: 6 }}
/>
<div className={styles.sendButtonArea}>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
disabled={!inputValue.trim()}
className={styles.sendButton}
>
</Button>
</div>
</div>
</div>
<div className={styles.inputHint}>
Ctrl+Enter换行Enter发送
</div>
</div>
<div className={styles.inputHint}>Ctrl+Enter换行Enter发送</div>
</div>
)}
{/* {["common"].includes(EnterModule) &&} */}
</Footer>
{/* 素材选择模态框 */}

View File

@@ -0,0 +1,15 @@
// 朋友圈相关的API接口
import { useWebSocketStore } from "@/store/module/websocket/websocket";
// 朋友圈请求参数接口
export interface FetchMomentParams {
friendMessageId: number;
chatroomMessageId: number;
seq: number;
}
// 获取朋友圈数据
export const fetchReCallApi = async (params: FetchMomentParams) => {
const { sendCommand } = useWebSocketStore.getState();
sendCommand("CmdRecallMessage", params);
};

View File

@@ -7,6 +7,7 @@ import {
ExportOutlined,
LinkOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { ChatRecord } from "@/pages/pc/ckbox/data";
import styles from "./ClickMenu.module.scss";
@@ -17,6 +18,7 @@ interface ClickMenuProps {
messageData: ChatRecord | null;
onClose: () => void;
onCommad: (action: string) => void;
isOwn: boolean;
}
const ClickMenu: React.FC<ClickMenuProps> = ({
@@ -26,6 +28,7 @@ const ClickMenu: React.FC<ClickMenuProps> = ({
messageData,
onClose,
onCommad,
isOwn,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x, y });
@@ -96,7 +99,18 @@ const ClickMenu: React.FC<ClickMenuProps> = ({
}
onClose();
};
// 检查是否显示撤回功能
const isShowRecall = (): boolean => {
// 早期返回:非自己发送的消息不能撤回
if (!isOwn) return false;
// 使用 dayjs 计算时间差1.8分钟 = 108秒
const timeDiffInSeconds = dayjs().diff(
dayjs(messageData.wechatTime),
"second",
);
return timeDiffInSeconds <= 108;
};
const menuItems = [
{
key: "transmit",
@@ -118,11 +132,15 @@ const ClickMenu: React.FC<ClickMenuProps> = ({
icon: <LinkOutlined />,
label: "引用",
},
{
key: "recall",
icon: <RollbackOutlined />,
label: "撤回",
},
...(isShowRecall()
? [
{
key: "recall",
icon: <RollbackOutlined />,
label: "撤回",
},
]
: []),
];
return (
@@ -144,6 +162,7 @@ const ClickMenu: React.FC<ClickMenuProps> = ({
handleCopy();
} else {
onCommad(value.key);
onClose();
}
},
}))}

View File

@@ -11,6 +11,7 @@ import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
import styles from "./MessageRecord.module.scss";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
import { fetchReCallApi } from "./api";
interface MessageRecordProps {
contract: ContractData | weChatGroup;
}
@@ -23,11 +24,12 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
y: 0,
messageData: null as ChatRecord | null,
});
const [nowIsOwn, setNowIsOwn] = useState(false);
// 选中的聊天记录状态
const [selectedRecords, setSelectedRecords] = useState<ChatRecord[]>([]);
const currentMessages = useWeChatStore(state => state.currentMessages);
const loadChatMessages = useWeChatStore(state => state.loadChatMessages);
const messagesLoading = useWeChatStore(state => state.messagesLoading);
const isLoadingData = useWeChatStore(state => state.isLoadingData);
@@ -39,8 +41,9 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
const updateShowCheckbox = useWeChatStore(state => state.updateShowCheckbox);
const updateEnterModule = useWeChatStore(state => state.updateEnterModule);
const currentKf = useCkChatStore(state =>
state.kfUserList.find(kf => kf.id === state.kfSelected),
state.kfUserList.find(kf => kf.id === contract.wechatAccountId),
);
// 判断是否为表情包URL的工具函数
const isEmojiUrl = (content: string): boolean => {
return (
@@ -471,7 +474,11 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
};
// 右键菜单事件处理
const handleContextMenu = (e: React.MouseEvent, msg: ChatRecord) => {
const handleContextMenu = (
e: React.MouseEvent,
msg: ChatRecord,
isOwn: boolean,
) => {
e.preventDefault();
setContextMenu({
visible: true,
@@ -479,6 +486,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
y: e.clientY,
messageData: msg,
});
setNowIsOwn(isOwn);
};
const handleCloseContextMenu = () => {
@@ -490,26 +498,6 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
});
};
const handleCopyMessage = (content: string) => {
// 复制消息内容的处理逻辑
console.log("复制消息:", content);
};
const handleDeleteMessage = (messageId: string) => {
// 删除消息的处理逻辑
console.log("删除消息:", messageId);
};
const handleForwardMessage = (messageData: ChatRecord) => {
// 转发消息的处理逻辑
console.log("转发消息:", messageData);
};
const handleRetryMessage = (messageData: ChatRecord) => {
// 重试发送消息的处理逻辑
console.log("重试发送:", messageData);
};
// 处理checkbox选中状态变化
const handleCheckboxChange = (checked: boolean, msg: ChatRecord) => {
if (checked) {
@@ -549,7 +537,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
className={`${styles.messageItem} ${
isOwn ? styles.ownMessage : styles.otherMessage
}`}
onContextMenu={e => isOwn && handleContextMenu(e, msg)}
onContextMenu={e => handleContextMenu(e, msg, isOwn)}
>
<div className={styles.messageContent}>
{/* 如果不是群聊 */}
@@ -667,6 +655,30 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
loadChatMessages(false, timestamp);
};
const handleDeleteMessage = (messageId: string) => {
// 删除消息的处理逻辑
console.log("删除消息:", messageId);
};
const handleForwardMessage = (messageData: ChatRecord) => {
// 转发消息的处理逻辑
console.log("转发消息:", messageData);
};
const handleRetryMessage = (messageData: ChatRecord) => {
// 重试发送消息的处理逻辑
console.log("重试发送:", messageData);
};
const handRecall = messageData => {
// 撤回消息的处理逻辑
fetchReCallApi({
friendMessageId: messageData?.wechatFriendId ? messageData.id : 0,
chatroomMessageId: messageData?.wechatFriendId ? 0 : messageData.id,
seq: +new Date(),
});
};
const handCommad = (action: string) => {
switch (action) {
case "transmit":
@@ -682,6 +694,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
break;
case "recall":
// 撤回逻辑
handRecall(contextMenu.messageData);
break;
default:
break;
@@ -720,6 +733,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
x={contextMenu.x}
y={contextMenu.y}
messageData={contextMenu.messageData}
isOwn={nowIsOwn}
onClose={handleCloseContextMenu}
onCommad={handCommad}
/>

View File

@@ -1,172 +0,0 @@
=======================发送请求操作============================
//我的朋友圈
{
"cmdType": "CmdFetchMoment",
"wechatAccountId": 300745,
"wechatFriendId": 0,
"createTimeSec": 1758010384,
"prevSnsId": 0,
"count": 10,
"isTimeline": false,
"seq": 3
}
//朋友圈广场
{
"cmdType": "CmdFetchMoment",
"wechatAccountId": 300745,
"wechatFriendId": 0,
"createTimeSec": 1758010499,
"prevSnsId": 0,
"count": 10,
"isTimeline": true,
"seq": 8
}
//取消点赞
{
"cmdType": "CmdMomentCancelInteract",
"optType": 1,
"wechatAccountId": 300745,
"wechatFriendId": 0,
"CommentId2": "",
"CommentTime": 0,
"snsId": "-3699481452547067269",
"seq": 4
}
//点赞
{
"cmdType": "CmdMomentInteract",
"momentInteractType": 1,
"wechatAccountId": 300745,
"wechatFriendId": 0,
"snsId": "-3699481452547067269",
"seq": 7
}
//指定好友朋朋友圈
{
"cmdType": "CmdFetchMoment",
"wechatAccountId": 300745,
"wechatFriendId": 21168549,
"createTimeSec": 1758011261,
"prevSnsId": 0,
"count": 10,
"isTimeline": false,
"seq": 24
}
//发布评论
{
"cmdType":"CmdMomentInteract",
"wechatAccountId":300745,
"wechatFriendId":21168549,
"snsId":"-3705790026851937712",
"sendWord":"测试评论",
"momentInteractType":2,
"seq":27
}
//撤销评论
{
"cmdType": "CmdMomentCancelInteract",
"optType": 2,
"wechatAccountId": 300745,
"wechatFriendId": 21168549,
"CommentId2": "",
"CommentTime": 1758011328.377,
"snsId": "-3705790026851937712",
"seq": 30
}
=======================数据接收格式============================
//我的朋友圈接收的数据
{
"wechatFriendId": 0,
"wechatAccountId": 300745,
"result": [{
"commentList": [],
"createTime": 1758011600,
"likeList": [],
"momentEntity": {
"content": "回家第一时间宠幸露丝大小姐",
"createTime": 1758011600,
"lat": 0,
"lng": 0,
"location": "",
"objectType": 1,
"picSize": 0,
"resUrls": [
"https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/weremote/chat-logs/5E2C38F5A275450D935F3ECEC076124E/aa6d4c2f7b1fe24d04d34f4f409883e6/sns/wxid_480es52qsj2812/-3699473895763463557/-3699473895763463557-14747270178949771902.jpg", "https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/weremote/chat-logs/5E2C38F5A275450D935F3ECEC076124E/aa6d4c2f7b1fe24d04d34f4f409883e6/sns/wxid_480es52qsj2812/-3699473895763463557/-3699473895763463557-14747270179007509101.jpg", "https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/weremote/chat-logs/5E2C38F5A275450D935F3ECEC076124E/aa6d4c2f7b1fe24d04d34f4f409883e6/sns/wxid_480es52qsj2812/-3699473895763463557/-3699473895763463557-14747270179101684338.jpg", "https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/weremote/chat-logs/5E2C38F5A275450D935F3ECEC076124E/aa6d4c2f7b1fe24d04d34f4f409883e6/sns/wxid_480es52qsj2812/-3699473895763463557/-3699473895763463557-14747270179158569581.jpg"
],
"snsId": "-3699473895763463557",
"urls": ["/sns/c/4/snst_14747270178949771902", "/sns/9/1/snst_14747270179007509101", "/sns/0/c/snst_14747270179101684338", "/sns/3/c/snst_14747270179158569581"],
"userName": "wxid_480es52qsj2812"
},
"snsId": "-3699473895763463557",
"type": 1
}],
"seq": 1097555,
"cmdType": "CmdFetchMomentResult"
}
//朋友圈广场接收的数据
{
"wechatFriendId": 0,
"wechatAccountId": 300745,
"result": [{
"commentList": [],
"createTime": 1758018895,
"likeList": [],
"momentEntity": {
"content": "还下雨不下雨,",
"createTime": 1758018895,
"lat": 0,
"lng": 0,
"location": "",
"objectType": 15,
"picSize": 0,
"resUrls": [],
"snsId": "-3699412700395531708",
"urls": ["https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/weremote/chat-logs/5E2C38F5A275450D935F3ECEC076124E/aa6d4c2f7b1fe24d04d34f4f409883e6/sns/wxid_480es52qsj2812/-3699412700395531708/-3699412700395531708.jpg"],
"userName": "Danrtsey_DX"
},
"snsId": "-3699412700395531708",
"type": 15
}],
"seq": 1098203,
"cmdType": "CmdFetchMomentResult"
}
//指定好友的朋友圈接收的数据
{
"wechatFriendId": 21168549,
"wechatAccountId": 300745,
"result": [{
"commentList": [],
"createTime": 1757258659,
"likeList": [{
"createTime": 1757315556,
"nickName": "老坑爹- 解放双手,释放时间",
"wechatId": "wxid_480es52qsj2812"
}],
"momentEntity": {
"content": "没看到血月[旺柴]",
"createTime": 1757258659,
"lat": 0,
"lng": 0,
"location": "",
"objectType": 1,
"picSize": 0,
"resUrls": ["https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/weremote/chat-logs/5E2C38F5A275450D935F3ECEC076124E/aa6d4c2f7b1fe24d04d34f4f409883e6/sns/wxid_480es52qsj2812/-3705790026851937712/-3705790026851937712-14740954047910318662.jpg"],
"snsId": "-3705790026851937712",
"urls": ["/sns/3/3/snst_14740954047910318662"],
"userName": "wxid_dlhi90odctcl22"
},
"snsId": "-3705790026851937712",
"type": 1
}],
"seq": 1098348,
"cmdType": "CmdFetchMomentResult"
}

View File

@@ -17,6 +17,12 @@ export interface WeChatState {
// 当前聊天用户的消息列表(只存储当前聊天用户的消息)
currentMessages: ChatRecord[];
// 添加消息
addMessage: (message: ChatRecord) => void;
// 替换消息
updateMessage: (messageId: number, updates: Partial<ChatRecord>) => void;
// 撤回消息
recallMessage: (messageId: number) => void;
// 消息加载状态
messagesLoading: boolean;
isLoadingData: boolean;
@@ -48,6 +54,5 @@ export interface WeChatState {
// 视频消息处理方法
setVideoLoading: (messageId: number, isLoading: boolean) => void;
setVideoUrl: (messageId: number, videoUrl: string) => void;
addMessage: (message: ChatRecord) => void;
receivedMsg: (message: ChatRecord) => void;
}

View File

@@ -25,6 +25,29 @@ export const useWeChatStore = create<WeChatState>()(
// 初始状态
currentContract: null,
currentMessages: [],
//添加消息
addMessage: message => {
set(state => ({
currentMessages: [...state.currentMessages, message],
}));
},
//替换消息
updateMessage: (messageId, updates) => {
set(state => ({
currentMessages: state.currentMessages.map(msg =>
msg.id === messageId ? { ...msg, ...updates } : msg,
),
}));
},
//撤回消息
recallMessage: (messageId: number) => {
set(state => ({
currentMessages: state.currentMessages.filter(
msg => msg.id !== messageId,
),
}));
},
messagesLoading: false,
isLoadingData: false,
currentGroupMembers: [],
@@ -32,7 +55,7 @@ export const useWeChatStore = create<WeChatState>()(
updateShowCheckbox: (show: boolean) => {
set({ showCheckbox: show });
},
EnterModule: "common",
EnterModule: "common", //common | multipleForwarding
updateEnterModule: (module: string) => {
set({ EnterModule: module });
},
@@ -171,12 +194,6 @@ export const useWeChatStore = create<WeChatState>()(
set({ messagesLoading: Boolean(loading) });
},
addMessage: message => {
set(state => ({
currentMessages: [...state.currentMessages, message],
}));
},
receivedMsg: async message => {
const currentContract = useWeChatStore.getState().currentContract;
//判断群还是好友
@@ -215,14 +232,6 @@ export const useWeChatStore = create<WeChatState>()(
}
},
updateMessage: (messageId, updates) => {
set(state => ({
currentMessages: state.currentMessages.map(msg =>
msg.id === messageId ? { ...msg, ...updates } : msg,
),
}));
},
// 便捷选择器
getCurrentContact: () => get().currentContract,
getCurrentMessages: () => get().currentMessages,

View File

@@ -8,6 +8,7 @@ import { db } from "@/utils/db";
// 消息处理器类型定义
type MessageHandler = (message: WebSocketMessage) => void;
const addMessage = useWeChatStore.getState().addMessage;
const recallMessage = useWeChatStore.getState().recallMessage;
const receivedMsg = useWeChatStore.getState().receivedMsg;
const updateMomentCommonLoading =
useWeChatStore.getState().updateMomentCommonLoading;
@@ -92,6 +93,18 @@ const messageHandlers: Record<string, MessageHandler> = {
}
},
//撤回消息
CmdMessageRecalled: message => {
const MessageId = message.friendMessageId || message.chatroomMessageId;
recallMessage(MessageId);
// {
// "friendMessageId": 745007874,
// "chatroomMessageId": 0,
// "seq": 2076470,
// "cmdType": "CmdMessageRecalled"
// }
},
// 可以继续添加更多处理器...
};