feat(MessageRecord): 添加右键菜单功能支持消息操作

实现消息记录的右键菜单功能,包括复制、转发、删除和重发等操作。添加ClickMenu组件处理菜单显示和位置调整,并在MessageRecord中集成右键事件处理逻辑。支持暗色主题适配和边界检测防止菜单溢出屏幕。
This commit is contained in:
超级老白兔
2025-09-18 10:59:33 +08:00
parent 065b516bd7
commit dc93c7f9f4
3 changed files with 363 additions and 1 deletions

View File

@@ -0,0 +1,103 @@
.contextMenu {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid #e8e8e8;
overflow: hidden;
min-width: 120px;
animation: fadeIn 0.15s ease-out;
.menu {
border: none;
box-shadow: none;
background: transparent;
:global(.ant-menu-item) {
padding: 8px 16px;
margin: 0;
height: auto;
line-height: 1.5;
border-radius: 0;
transition: all 0.2s ease;
&:hover {
background-color: #f5f5f5;
}
&:global(.ant-menu-item-danger) {
color: #ff4d4f;
&:hover {
background-color: #fff2f0;
color: #ff4d4f;
}
}
&:global(.ant-menu-item-disabled) {
color: #bfbfbf;
cursor: not-allowed;
&:hover {
background-color: transparent;
}
}
:global(.ant-menu-item-icon) {
margin-right: 8px;
font-size: 14px;
}
}
:global(.ant-menu-item-divider) {
margin: 4px 0;
background-color: #f0f0f0;
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
// 暗色主题支持
@media (prefers-color-scheme: dark) {
.contextMenu {
background: #2f2f2f;
border-color: #434343;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
.menu {
:global(.ant-menu-item) {
color: #ffffff;
&:hover {
background-color: #404040;
}
&:global(.ant-menu-item-danger) {
color: #ff7875;
&:hover {
background-color: #2a1215;
color: #ff7875;
}
}
&:global(.ant-menu-item-disabled) {
color: #595959;
}
}
:global(.ant-menu-item-divider) {
background-color: #434343;
}
}
}
}

View File

@@ -0,0 +1,196 @@
import React, { useState, useEffect, useRef } from "react";
import { Menu, message } from "antd";
import {
CopyOutlined,
DeleteOutlined,
ShareAltOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import { ChatRecord } from "@/pages/pc/ckbox/data";
import styles from "./ClickMenu.module.scss";
interface ClickMenuProps {
visible: boolean;
x: number;
y: number;
messageData: ChatRecord | null;
onClose: () => void;
onCopy?: (content: string) => void;
onDelete?: (messageId: string) => void;
onForward?: (messageData: ChatRecord) => void;
onRetry?: (messageData: ChatRecord) => void;
}
const ClickMenu: React.FC<ClickMenuProps> = ({
visible,
x,
y,
messageData,
onClose,
onCopy,
onDelete,
onForward,
onRetry,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x, y });
useEffect(() => {
if (visible && menuRef.current) {
const menuRect = menuRef.current.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let adjustedX = x;
let adjustedY = y;
// 防止菜单超出屏幕右边界
if (x + menuRect.width > windowWidth) {
adjustedX = windowWidth - menuRect.width - 10;
}
// 防止菜单超出屏幕下边界
if (y + menuRect.height > windowHeight) {
adjustedY = windowHeight - menuRect.height - 10;
}
setPosition({ x: adjustedX, y: adjustedY });
}
}, [visible, x, y]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
if (visible) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscKey);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscKey);
};
}, [visible, onClose]);
if (!visible || !messageData) {
return null;
}
const handleCopy = () => {
if (messageData.content && onCopy) {
// 提取纯文本内容去除HTML标签
const textContent = messageData.content.replace(/<[^>]*>/g, "");
onCopy(textContent);
navigator.clipboard
.writeText(textContent)
.then(() => {
message.success("已复制到剪贴板");
})
.catch(() => {
message.error("复制失败");
});
}
onClose();
};
const handleDelete = () => {
if (messageData.id && onDelete) {
onDelete(messageData.id.toString());
message.success("消息已删除");
}
onClose();
};
const handleForward = () => {
if (onForward) {
onForward(messageData);
message.info("转发功能待实现");
}
onClose();
};
const handleRetry = () => {
if (onRetry) {
onRetry(messageData);
message.info("重新发送");
}
onClose();
};
const menuItems = [
{
key: "copy",
icon: <CopyOutlined />,
label: "转发",
onClick: handleCopy,
disabled: !messageData.content || messageData.msgType === 3, // 图片消息不能复制文本
},
{
key: "forward",
icon: <ShareAltOutlined />,
label: "复制",
onClick: handleForward,
},
{
key: "copy",
icon: <CopyOutlined />,
label: "多条转发",
onClick: handleCopy,
disabled: !messageData.content || messageData.msgType === 3, // 图片消息不能复制文本
},
{
key: "delete",
icon: <DeleteOutlined />,
label: "引用",
onClick: handleDelete,
},
{
key: "delete",
icon: <DeleteOutlined />,
label: "撤回",
onClick: handleDelete,
},
];
// 如果是自己发送的消息且发送失败,显示重试选项
if (messageData.isSend && messageData.status === "failed") {
menuItems.unshift({
key: "retry",
icon: <ReloadOutlined />,
label: "重新发送",
onClick: handleRetry,
});
}
return (
<div
ref={menuRef}
className={styles.contextMenu}
style={{
position: "fixed",
left: position.x,
top: position.y,
zIndex: 9999,
}}
>
<Menu
items={menuItems}
mode="vertical"
selectable={false}
className={styles.menu}
/>
</div>
);
};
export default ClickMenu;

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useRef } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Avatar, Divider } from "antd";
import { UserOutlined, LoadingOutlined } from "@ant-design/icons";
import AudioMessage from "./components/AudioMessage/AudioMessage";
import SmallProgramMessage from "./components/SmallProgramMessage";
import VideoMessage from "./components/VideoMessage";
import ClickMenu from "./components/ClickMeau";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { formatWechatTime } from "@/utils/common";
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
@@ -16,6 +17,14 @@ interface MessageRecordProps {
const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
// 右键菜单状态
const [contextMenu, setContextMenu] = useState({
visible: false,
x: 0,
y: 0,
messageData: null as ChatRecord | null,
});
const currentMessages = useWeChatStore(state => state.currentMessages);
const loadChatMessages = useWeChatStore(state => state.loadChatMessages);
const messagesLoading = useWeChatStore(state => state.messagesLoading);
@@ -454,6 +463,46 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
}
};
// 右键菜单事件处理
const handleContextMenu = (e: React.MouseEvent, msg: ChatRecord) => {
e.preventDefault();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
messageData: msg,
});
};
const handleCloseContextMenu = () => {
setContextMenu({
visible: false,
x: 0,
y: 0,
messageData: null,
});
};
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);
};
// 用于分组消息并添加时间戳的辅助函数
const groupMessagesByTime = (messages: ChatRecord[]) => {
return messages
@@ -477,6 +526,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
className={`${styles.messageItem} ${
isOwn ? styles.ownMessage : styles.otherMessage
}`}
onContextMenu={e => handleContextMenu(e, msg)}
>
<div className={styles.messageContent}>
{/* 如果不是群聊 */}
@@ -587,6 +637,19 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
</React.Fragment>
))}
<div ref={messagesEndRef} />
{/* 右键菜单组件 */}
<ClickMenu
visible={contextMenu.visible}
x={contextMenu.x}
y={contextMenu.y}
messageData={contextMenu.messageData}
onClose={handleCloseContextMenu}
onCopy={handleCopyMessage}
onDelete={handleDeleteMessage}
onForward={handleForwardMessage}
onRetry={handleRetryMessage}
/>
</div>
);
};