refactor(ChatWindow): 提取消息记录组件并优化样式
将消息记录相关逻辑从ChatWindow组件中提取为独立的MessageRecord组件 新增MessageRecord.module.scss文件定义消息样式 简化ChatWindow组件逻辑,提高代码可维护性
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
// 消息容器
|
||||
.messagesContainer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: #f5f5f5;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 时间分组
|
||||
.messageTime {
|
||||
text-align: center;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
margin: 8px 0;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: attr(data-time);
|
||||
background: #f5f5f5;
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息项
|
||||
.messageItem {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&.ownMessage {
|
||||
justify-content: flex-end;
|
||||
|
||||
.messageContent {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.messageBubble {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border-radius: 18px 4px 18px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&.otherMessage {
|
||||
justify-content: flex-start;
|
||||
|
||||
.messageBubble {
|
||||
background: white;
|
||||
color: #262626;
|
||||
border-radius: 4px 18px 18px 18px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 消息内容容器
|
||||
.messageContent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
// 头像
|
||||
.messageAvatar {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// 消息气泡
|
||||
.messageBubble {
|
||||
padding: 8px 12px;
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// 发送者名称
|
||||
.messageSender {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 普通文本消息
|
||||
.messageText {
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// 表情包消息
|
||||
.emojiMessage {
|
||||
img {
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 视频消息
|
||||
.videoMessage {
|
||||
.videoContainer {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
|
||||
.playButton {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.videoPreview {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.playButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
|
||||
svg {
|
||||
margin-left: 2px; // 视觉居中调整
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文件消息样式(如果需要)
|
||||
.fileMessage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.fileName {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fileSize {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.messageContent {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.messageBubble {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.videoMessage .videoPreview {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import React from "react";
|
||||
import { Avatar } from "antd";
|
||||
import { UserOutlined } from "@ant-design/icons";
|
||||
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import { formatWechatTime } from "@/utils/common";
|
||||
import styles from "./MessageRecord.module.scss";
|
||||
|
||||
interface MessageRecordProps {
|
||||
messages: ChatRecord[];
|
||||
contract: ContractData | weChatGroup;
|
||||
currentGroupMembers?: any[];
|
||||
messagesEndRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const MessageRecord: React.FC<MessageRecordProps> = ({
|
||||
messages,
|
||||
contract,
|
||||
currentGroupMembers = [],
|
||||
messagesEndRef,
|
||||
}) => {
|
||||
// 解析消息内容,判断消息类型并返回对应的渲染内容
|
||||
const parseMessageContent = (
|
||||
content: string | null | undefined,
|
||||
msg: ChatRecord,
|
||||
) => {
|
||||
// 处理null或undefined的内容
|
||||
if (content === null || content === undefined) {
|
||||
return <div className={styles.messageText}>消息内容不可用</div>;
|
||||
}
|
||||
// 检查是否为表情包
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") &&
|
||||
content.includes("#")
|
||||
) {
|
||||
return (
|
||||
<div className={styles.emojiMessage}>
|
||||
<img
|
||||
src={content}
|
||||
alt="表情包"
|
||||
style={{ maxWidth: "120px", maxHeight: "120px" }}
|
||||
onClick={() => window.open(content, "_blank")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否为带预览图的视频消息
|
||||
try {
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
content.trim().startsWith("{") &&
|
||||
content.trim().endsWith("}")
|
||||
) {
|
||||
const videoData = JSON.parse(content);
|
||||
// 处理视频消息格式 {"previewImage":"https://...", "tencentUrl":"...", "videoUrl":"...", "isLoading":true}
|
||||
if (videoData.previewImage && videoData.tencentUrl) {
|
||||
// 提取预览图URL,去掉可能的引号
|
||||
const previewImageUrl = videoData.previewImage.replace(/[`"']/g, "");
|
||||
|
||||
return (
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer}>
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="视频预览"
|
||||
className={styles.videoPreview}
|
||||
onClick={() => {
|
||||
if (videoData.videoUrl) {
|
||||
window.open(videoData.videoUrl, "_blank");
|
||||
} else if (videoData.tencentUrl) {
|
||||
window.open(videoData.tencentUrl, "_blank");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// JSON解析失败,按普通文本处理
|
||||
}
|
||||
|
||||
// 普通文本消息
|
||||
return <div className={styles.messageText}>{content}</div>;
|
||||
};
|
||||
|
||||
// 获取群成员头像
|
||||
const groupMemberAvatar = (msg: ChatRecord) => {
|
||||
const groupMembers = currentGroupMembers.find(
|
||||
v => v?.wechatId == msg?.sender?.wechatId,
|
||||
);
|
||||
return groupMembers?.avatar;
|
||||
};
|
||||
|
||||
// 清理微信ID前缀
|
||||
const clearWechatidInContent = (sender: any, content: string) => {
|
||||
try {
|
||||
return content.replace(new RegExp(`${sender?.wechatId}:\n`, "g"), "");
|
||||
} catch (err) {
|
||||
return "-";
|
||||
}
|
||||
};
|
||||
|
||||
// 用于分组消息并添加时间戳的辅助函数
|
||||
const groupMessagesByTime = (messages: ChatRecord[]) => {
|
||||
return messages
|
||||
.filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息
|
||||
.map(msg => ({
|
||||
time: formatWechatTime(msg?.wechatTime),
|
||||
messages: [msg],
|
||||
}));
|
||||
};
|
||||
|
||||
// 渲染单条消息
|
||||
const renderMessage = (msg: ChatRecord) => {
|
||||
// 添加null检查,防止访问null对象的属性
|
||||
if (!msg) return null;
|
||||
|
||||
const isOwn = msg?.isSend;
|
||||
const isGroup = !!contract.chatroomId;
|
||||
return (
|
||||
<div
|
||||
key={msg.id || `msg-${Date.now()}`}
|
||||
className={`${styles.messageItem} ${
|
||||
isOwn ? styles.ownMessage : styles.otherMessage
|
||||
}`}
|
||||
>
|
||||
<div className={styles.messageContent}>
|
||||
{/* 如果不是群聊 */}
|
||||
{!isGroup && !isOwn && (
|
||||
<>
|
||||
<Avatar
|
||||
size={32}
|
||||
src={contract.avatar}
|
||||
icon={<UserOutlined />}
|
||||
className={styles.messageAvatar}
|
||||
/>
|
||||
|
||||
<div className={styles.messageBubble}>
|
||||
{!isOwn && (
|
||||
<div className={styles.messageSender}>
|
||||
{contract.nickname}
|
||||
</div>
|
||||
)}
|
||||
{parseMessageContent(msg?.content, msg)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* 如果是群聊 */}
|
||||
{isGroup && !isOwn && (
|
||||
<>
|
||||
<Avatar
|
||||
size={32}
|
||||
src={groupMemberAvatar(msg)}
|
||||
icon={<UserOutlined />}
|
||||
className={styles.messageAvatar}
|
||||
/>
|
||||
|
||||
<div className={styles.messageBubble}>
|
||||
{!isOwn && (
|
||||
<div className={styles.messageSender}>
|
||||
{msg?.sender?.nickname}
|
||||
</div>
|
||||
)}
|
||||
{parseMessageContent(
|
||||
clearWechatidInContent(msg?.sender, msg?.content),
|
||||
msg,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOwn && (
|
||||
<div className={styles.messageBubble}>
|
||||
{parseMessageContent(msg?.content, msg)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.messagesContainer}>
|
||||
{groupMessagesByTime(messages).map((group, groupIndex) => (
|
||||
<React.Fragment key={`group-${groupIndex}`}>
|
||||
<div className={styles.messageTime}>{group.time}</div>
|
||||
{group.messages.map(renderMessage)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageRecord;
|
||||
@@ -5,23 +5,14 @@ import {
|
||||
VideoCameraOutlined,
|
||||
MoreOutlined,
|
||||
UserOutlined,
|
||||
DownloadOutlined,
|
||||
FileOutlined,
|
||||
FilePdfOutlined,
|
||||
FileWordOutlined,
|
||||
FileExcelOutlined,
|
||||
FilePptOutlined,
|
||||
PlayCircleFilled,
|
||||
TeamOutlined,
|
||||
FolderOutlined,
|
||||
EnvironmentOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import styles from "./ChatWindow.module.scss";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
import { formatWechatTime } from "@/utils/common";
|
||||
|
||||
import ProfileCard from "./components/ProfileCard";
|
||||
import MessageEnter from "./components/MessageEnter";
|
||||
import MessageRecord from "./components/MessageRecord";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
@@ -41,586 +32,15 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const currentGroupMembers = useWeChatStore(
|
||||
state => state.currentGroupMembers,
|
||||
);
|
||||
const prevMessagesRef = useRef(currentMessages);
|
||||
|
||||
useEffect(() => {
|
||||
const prevMessages = prevMessagesRef.current;
|
||||
|
||||
const hasVideoStateChange = currentMessages.some((msg, index) => {
|
||||
// 首先检查消息对象本身是否为null或undefined
|
||||
if (!msg || !msg.content) return false;
|
||||
|
||||
const prevMsg = prevMessages[index];
|
||||
if (!prevMsg || !prevMsg.content || prevMsg.id !== msg.id) return false;
|
||||
|
||||
try {
|
||||
const currentContent =
|
||||
typeof msg.content === "string"
|
||||
? JSON.parse(msg.content)
|
||||
: msg.content;
|
||||
const prevContent =
|
||||
typeof prevMsg.content === "string"
|
||||
? JSON.parse(prevMsg.content)
|
||||
: prevMsg.content;
|
||||
|
||||
// 检查视频状态是否发生变化(开始加载、完成加载、获得URL)
|
||||
const currentHasVideo =
|
||||
currentContent.previewImage && currentContent.tencentUrl;
|
||||
const prevHasVideo = prevContent.previewImage && prevContent.tencentUrl;
|
||||
|
||||
if (currentHasVideo && prevHasVideo) {
|
||||
// 检查加载状态变化或视频URL变化
|
||||
return (
|
||||
currentContent.isLoading !== prevContent.isLoading ||
|
||||
currentContent.videoUrl !== prevContent.videoUrl
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 只有在没有视频状态变化时才自动滚动到底部
|
||||
if (!hasVideoStateChange) {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// 更新上一次的消息状态
|
||||
prevMessagesRef.current = currentMessages;
|
||||
scrollToBottom();
|
||||
}, [currentMessages]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
// 处理视频播放请求,发送socket请求获取真实视频地址
|
||||
const handleVideoPlayRequest = (tencentUrl: string, messageId: number) => {
|
||||
console.log("发送视频下载请求:", { messageId, tencentUrl });
|
||||
|
||||
// 先设置加载状态
|
||||
useWeChatStore.getState().setVideoLoading(messageId, true);
|
||||
|
||||
// 构建socket请求数据
|
||||
useWebSocketStore.getState().sendCommand("CmdDownloadVideo", {
|
||||
chatroomMessageId: contract.chatroomId ? messageId : 0,
|
||||
friendMessageId: contract.chatroomId ? 0 : messageId,
|
||||
seq: `${+new Date()}`, // 使用时间戳作为请求序列号
|
||||
tencentUrl: tencentUrl,
|
||||
wechatAccountId: contract.wechatAccountId,
|
||||
});
|
||||
};
|
||||
|
||||
// 解析消息内容,判断消息类型并返回对应的渲染内容
|
||||
const parseMessageContent = (
|
||||
content: string | null | undefined,
|
||||
msg: ChatRecord,
|
||||
) => {
|
||||
// 处理null或undefined的内容
|
||||
if (content === null || content === undefined) {
|
||||
return <div className={styles.messageText}>消息内容不可用</div>;
|
||||
}
|
||||
// 检查是否为表情包
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") &&
|
||||
content.includes("#")
|
||||
) {
|
||||
return (
|
||||
<div className={styles.emojiMessage}>
|
||||
<img
|
||||
src={content}
|
||||
alt="表情包"
|
||||
style={{ maxWidth: "120px", maxHeight: "120px" }}
|
||||
onClick={() => window.open(content, "_blank")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否为带预览图的视频消息
|
||||
try {
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
content.trim().startsWith("{") &&
|
||||
content.trim().endsWith("}")
|
||||
) {
|
||||
const videoData = JSON.parse(content);
|
||||
// 处理视频消息格式 {"previewImage":"https://...", "tencentUrl":"...", "videoUrl":"...", "isLoading":true}
|
||||
if (videoData.previewImage && videoData.tencentUrl) {
|
||||
// 提取预览图URL,去掉可能的引号
|
||||
const previewImageUrl = videoData.previewImage.replace(/[`"']/g, "");
|
||||
|
||||
// 创建点击处理函数
|
||||
const handlePlayClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// 如果没有视频URL且不在加载中,则发起下载请求
|
||||
if (!videoData.videoUrl && !videoData.isLoading) {
|
||||
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 如果已有视频URL,显示视频播放器
|
||||
if (videoData.videoUrl) {
|
||||
return (
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer}>
|
||||
<video
|
||||
controls
|
||||
src={videoData.videoUrl}
|
||||
style={{ maxWidth: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
<a
|
||||
href={videoData.videoUrl}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示预览图,根据加载状态显示不同的图标
|
||||
return (
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer} onClick={handlePlayClick}>
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="视频预览"
|
||||
className={styles.videoThumbnail}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
borderRadius: "8px",
|
||||
opacity: videoData.isLoading ? "0.7" : "1",
|
||||
}}
|
||||
/>
|
||||
<div className={styles.videoPlayIcon}>
|
||||
{videoData.isLoading ? (
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
) : (
|
||||
<PlayCircleFilled
|
||||
style={{ fontSize: "48px", color: "#fff" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 保留原有的视频处理逻辑
|
||||
else if (
|
||||
videoData.type === "video" &&
|
||||
videoData.url &&
|
||||
videoData.thumb
|
||||
) {
|
||||
return (
|
||||
<div className={styles.videoMessage}>
|
||||
<div
|
||||
className={styles.videoContainer}
|
||||
onClick={() => window.open(videoData.url, "_blank")}
|
||||
>
|
||||
<img
|
||||
src={videoData.thumb}
|
||||
alt="视频预览"
|
||||
className={styles.videoThumbnail}
|
||||
/>
|
||||
<div className={styles.videoPlayIcon}>
|
||||
<VideoCameraOutlined
|
||||
style={{ fontSize: "32px", color: "#fff" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={videoData.url}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析JSON失败,不是视频消息
|
||||
console.log("解析视频消息失败:", e);
|
||||
}
|
||||
|
||||
// 检查是否为图片链接
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
(content.match(/\.(jpg|jpeg|png|gif)$/i) ||
|
||||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
|
||||
content.includes(".jpg")))
|
||||
) {
|
||||
return (
|
||||
<div className={styles.imageMessage}>
|
||||
<img
|
||||
src={content}
|
||||
alt="图片消息"
|
||||
onClick={() => window.open(content, "_blank")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否为视频链接
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
(content.match(/\.(mp4|avi|mov|wmv|flv)$/i) ||
|
||||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
|
||||
content.includes(".mp4")))
|
||||
) {
|
||||
return (
|
||||
<div className={styles.videoMessage}>
|
||||
<video
|
||||
controls
|
||||
src={content}
|
||||
style={{ maxWidth: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
<a
|
||||
href={content}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否为音频链接
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
(content.match(/\.(mp3|wav|ogg|m4a)$/i) ||
|
||||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
|
||||
content.includes(".mp3")))
|
||||
) {
|
||||
return (
|
||||
<div className={styles.audioMessage}>
|
||||
<audio controls src={content} style={{ maxWidth: "100%" }} />
|
||||
<a
|
||||
href={content}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否为Office文件链接
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
content.match(/\.(doc|docx|xls|xlsx|ppt|pptx|pdf)$/i)
|
||||
) {
|
||||
const fileName = content.split("/").pop() || "文件";
|
||||
const fileExt = fileName.split(".").pop()?.toLowerCase();
|
||||
|
||||
// 根据文件类型选择不同的图标
|
||||
let fileIcon = (
|
||||
<FileOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#1890ff" }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (fileExt === "pdf") {
|
||||
fileIcon = (
|
||||
<FilePdfOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#ff4d4f" }}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "doc" || fileExt === "docx") {
|
||||
fileIcon = (
|
||||
<FileWordOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#2f54eb" }}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "xls" || fileExt === "xlsx") {
|
||||
fileIcon = (
|
||||
<FileExcelOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#52c41a" }}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "ppt" || fileExt === "pptx") {
|
||||
fileIcon = (
|
||||
<FilePptOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#fa8c16" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.fileMessage}>
|
||||
{fileIcon}
|
||||
<div className={styles.fileInfo}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{fileName}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={content}
|
||||
download={fileExt !== "pdf" ? fileName : undefined}
|
||||
target={fileExt === "pdf" ? "_blank" : undefined}
|
||||
className={styles.downloadButton}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ display: "flex" }}
|
||||
rel="noreferrer"
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否为文件消息(JSON格式)
|
||||
try {
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
content.trim().startsWith("{") &&
|
||||
content.trim().endsWith("}")
|
||||
) {
|
||||
const fileData = JSON.parse(content);
|
||||
if (fileData.type === "file" && fileData.title) {
|
||||
// 检查是否为Office文件
|
||||
const fileExt = fileData.title.split(".").pop()?.toLowerCase();
|
||||
let fileIcon = (
|
||||
<FolderOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#1890ff" }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (fileExt === "pdf") {
|
||||
fileIcon = (
|
||||
<FilePdfOutlined
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
marginRight: "8px",
|
||||
color: "#ff4d4f",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "doc" || fileExt === "docx") {
|
||||
fileIcon = (
|
||||
<FileWordOutlined
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
marginRight: "8px",
|
||||
color: "#2f54eb",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "xls" || fileExt === "xlsx") {
|
||||
fileIcon = (
|
||||
<FileExcelOutlined
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
marginRight: "8px",
|
||||
color: "#52c41a",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "ppt" || fileExt === "pptx") {
|
||||
fileIcon = (
|
||||
<FilePptOutlined
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
marginRight: "8px",
|
||||
color: "#fa8c16",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.fileMessage}>
|
||||
{fileIcon}
|
||||
<div className={styles.fileInfo}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{fileData.title}
|
||||
</div>
|
||||
{fileData.totalLen && (
|
||||
<div style={{ fontSize: "12px", color: "#8c8c8c" }}>
|
||||
{Math.round(fileData.totalLen / 1024)} KB
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={fileData.url || "#"}
|
||||
download={fileExt !== "pdf" ? fileData.title : undefined}
|
||||
target={fileExt === "pdf" ? "_blank" : undefined}
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (!fileData.url) {
|
||||
console.log("文件URL不存在");
|
||||
}
|
||||
}}
|
||||
rel="noreferrer"
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析JSON失败,不是文件消息
|
||||
}
|
||||
|
||||
// 检查是否为位置信息
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
(content.includes("<location") || content.includes("<msg><location"))
|
||||
) {
|
||||
// 提取位置信息
|
||||
const labelMatch = content.match(/label="([^"]*)"/i);
|
||||
const poiNameMatch = content.match(/poiname="([^"]*)"/i);
|
||||
const xMatch = content.match(/x="([^"]*)"/i);
|
||||
const yMatch = content.match(/y="([^"]*)"/i);
|
||||
|
||||
const label = labelMatch
|
||||
? labelMatch[1]
|
||||
: poiNameMatch
|
||||
? poiNameMatch[1]
|
||||
: "位置信息";
|
||||
const coordinates = xMatch && yMatch ? `${yMatch[1]}, ${xMatch[1]}` : "";
|
||||
|
||||
return (
|
||||
<div className={styles.locationMessage}>
|
||||
<EnvironmentOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#ff4d4f" }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontWeight: "bold" }}>{label}</div>
|
||||
{coordinates && (
|
||||
<div style={{ fontSize: "12px", color: "#8c8c8c" }}>
|
||||
{coordinates}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 默认为文本消息
|
||||
return <div className={styles.messageText}>{content}</div>;
|
||||
};
|
||||
|
||||
// 用于分组消息并添加时间戳的辅助函数
|
||||
const groupMessagesByTime = (messages: ChatRecord[]) => {
|
||||
return messages
|
||||
.filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息
|
||||
.map(msg => ({
|
||||
time: formatWechatTime(msg?.wechatTime),
|
||||
messages: [msg],
|
||||
}));
|
||||
};
|
||||
const groupMemberAvatar = (msg: ChatRecord) => {
|
||||
const groupMembers = currentGroupMembers.find(
|
||||
v => v.wechatId == msg.sender.wechatId,
|
||||
);
|
||||
return groupMembers.avatar;
|
||||
};
|
||||
const clearWechatidInContent = (sender, content: string) => {
|
||||
return content.replace(new RegExp(`${sender.wechatId}:\n`, "g"), "");
|
||||
};
|
||||
const renderMessage = (msg: ChatRecord) => {
|
||||
console.log(msg);
|
||||
// 添加null检查,防止访问null对象的属性
|
||||
if (!msg) return null;
|
||||
|
||||
const isOwn = msg?.isSend;
|
||||
const isGroup = !!contract.chatroomId;
|
||||
return (
|
||||
<div
|
||||
key={msg.id || `msg-${Date.now()}`}
|
||||
className={`${styles.messageItem} ${
|
||||
isOwn ? styles.ownMessage : styles.otherMessage
|
||||
}`}
|
||||
>
|
||||
<div className={styles.messageContent}>
|
||||
{/* 如果不是群聊 */}
|
||||
{!isGroup && !isOwn && (
|
||||
<>
|
||||
<Avatar
|
||||
size={32}
|
||||
src={contract.avatar}
|
||||
icon={<UserOutlined />}
|
||||
className={styles.messageAvatar}
|
||||
/>
|
||||
|
||||
<div className={styles.messageBubble}>
|
||||
{!isOwn && (
|
||||
<div className={styles.messageSender}>
|
||||
{contract.nickname}
|
||||
</div>
|
||||
)}
|
||||
{parseMessageContent(msg?.content, msg)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* 如果是群聊 */}
|
||||
{isGroup && !isOwn && (
|
||||
<>
|
||||
<Avatar
|
||||
size={32}
|
||||
src={groupMemberAvatar(msg)}
|
||||
icon={<UserOutlined />}
|
||||
className={styles.messageAvatar}
|
||||
/>
|
||||
|
||||
<div className={styles.messageBubble}>
|
||||
{!isOwn && (
|
||||
<div className={styles.messageSender}>
|
||||
{msg?.sender?.nickname}
|
||||
</div>
|
||||
)}
|
||||
{parseMessageContent(
|
||||
clearWechatidInContent(msg?.sender, msg?.content),
|
||||
msg,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOwn && (
|
||||
<div className={styles.messageBubble}>
|
||||
{parseMessageContent(msg?.content, msg)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const chatMenu = (
|
||||
<Menu>
|
||||
<Menu.Item key="profile" icon={<UserOutlined />}>
|
||||
@@ -689,15 +109,12 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
|
||||
{/* 聊天内容 */}
|
||||
<Content className={styles.chatContent}>
|
||||
<div className={styles.messagesContainer}>
|
||||
{groupMessagesByTime(currentMessages).map((group, groupIndex) => (
|
||||
<React.Fragment key={`group-${groupIndex}`}>
|
||||
<div className={styles.messageTime}>{group.time}</div>
|
||||
{group.messages.map(renderMessage)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<MessageRecord
|
||||
messages={currentMessages}
|
||||
contract={contract}
|
||||
currentGroupMembers={currentGroupMembers}
|
||||
messagesEndRef={messagesEndRef}
|
||||
/>
|
||||
</Content>
|
||||
|
||||
{/* 消息输入组件 */}
|
||||
|
||||
Reference in New Issue
Block a user