From dba6ae164cad54a2821ee8fbd31c08005f5153e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E7=BA=A7=E8=80=81=E7=99=BD=E5=85=94?= Date: Mon, 8 Sep 2025 15:53:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(MessageRecord):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=B6=88=E6=81=AF=E9=A2=84=E8=A7=88=E5=92=8C?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增视频消息的预览图显示、加载动画和下载按钮功能。优化视频消息的交互体验,包括点击预览图触发下载请求,显示加载状态,以及视频播放时的下载选项。同时添加了相关样式和动画效果。 --- .../MessageRecord/MessageRecord.module.scss | 59 ++ .../components/MessageRecord/index.tsx | 224 +++++- .../pc/ckbox/components/ChatWindow/demo.tsx | 669 ++++++++++++++++++ 3 files changed, 913 insertions(+), 39 deletions(-) create mode 100644 Cunkebao/src/pages/pc/ckbox/components/ChatWindow/demo.tsx diff --git a/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/MessageRecord.module.scss b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/MessageRecord.module.scss index 1b922238..6a4ede88 100644 --- a/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/MessageRecord.module.scss +++ b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/MessageRecord.module.scss @@ -146,6 +146,56 @@ display: block; } + .videoThumbnail { + max-width: 200px; + max-height: 200px; + display: block; + cursor: pointer; + transition: opacity 0.2s; + } + + .videoPlayIcon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + + .loadingSpinner { + width: 48px; + height: 48px; + border: 4px solid rgba(255, 255, 255, 0.3); + border-top: 4px solid #fff; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + .downloadButton { + position: absolute; + top: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.6); + color: white; + border: none; + border-radius: 4px; + padding: 6px; + cursor: pointer; + transition: background 0.2s; + text-decoration: none; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: rgba(0, 0, 0, 0.8); + color: white; + } + } + .playButton { position: absolute; top: 50%; @@ -166,6 +216,15 @@ } } +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + // 图片消息 .imageMessage { img { diff --git a/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/index.tsx b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/index.tsx index 7a9c5046..4d12191e 100644 --- a/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/index.tsx +++ b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/index.tsx @@ -1,10 +1,16 @@ import React, { useEffect, useRef } from "react"; import { Avatar, Divider } from "antd"; -import { UserOutlined, LoadingOutlined } from "@ant-design/icons"; +import { + UserOutlined, + LoadingOutlined, + DownloadOutlined, + PlayCircleFilled, +} from "@ant-design/icons"; import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; import { formatWechatTime } from "@/utils/common"; import styles from "./MessageRecord.module.scss"; import { useWeChatStore } from "@/store/module/weChat/weChat"; +import { useWebSocketStore } from "@/store/module/websocket/websocket"; interface MessageRecordProps { contract: ContractData | weChatGroup; @@ -18,6 +24,19 @@ const MessageRecord: React.FC = ({ contract }) => { const currentGroupMembers = useWeChatStore( state => state.currentGroupMembers, ); + const prevMessagesRef = useRef(currentMessages); + + // 检测是否为直接视频链接的函数 + const isDirectVideoLink = (content: string): boolean => { + const trimmedContent = content.trim(); + return ( + trimmedContent.startsWith("http") && + (trimmedContent.includes(".mp4") || + trimmedContent.includes(".mov") || + trimmedContent.includes(".avi") || + trimmedContent.includes("video")) + ); + }; // 判断是否为表情包URL的工具函数 const isEmojiUrl = (content: string): boolean => { @@ -31,18 +50,78 @@ const MessageRecord: React.FC = ({ contract }) => { }; useEffect(() => { - if (isLoadingData) { + 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 && isLoadingData) { scrollToBottom(); } + + // 更新上一次的消息状态 + prevMessagesRef.current = currentMessages; }, [currentMessages, isLoadingData]); 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, + }); + }; + // 解析消息内容,根据msgType判断消息类型并返回对应的渲染内容 const parseMessageContent = ( content: string | null | undefined, + msg: ChatRecord, msgType?: number, ) => { // 处理null或undefined的内容 @@ -98,55 +177,121 @@ const MessageRecord: React.FC = ({ contract }) => { return renderErrorMessage("[视频消息 - 无效内容]"); } + // 如果content是直接的视频链接(已预览过或下载好的视频) + if (isDirectVideoLink(content)) { + return ( +
+ +
+ ); + } + try { - // 更严格的JSON格式验证 - const trimmedContent = content.trim(); - if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) { - const videoData = JSON.parse(trimmedContent); + // 尝试解析JSON格式的视频数据 + if (content.startsWith("{") && content.endsWith("}")) { + const videoData = JSON.parse(content); // 验证必要的视频数据字段 if ( videoData && typeof videoData === "object" && videoData.previewImage && - (videoData.tencentUrl || videoData.videoUrl) + videoData.tencentUrl ) { const previewImageUrl = String(videoData.previewImage).replace( /[`"']/g, "", ); + // 创建点击处理函数 + const handlePlayClick = ( + e: React.MouseEvent, + msg: ChatRecord, + ) => { + e.stopPropagation(); + // 如果没有视频URL且不在加载中,则发起下载请求 + if (!videoData.videoUrl && !videoData.isLoading) { + handleVideoPlayRequest(videoData.tencentUrl, msg.id); + } + }; + + // 如果已有视频URL,显示视频播放器 + if (videoData.videoUrl) { + return ( +
+ +
+ ); + } + + // 显示预览图,根据加载状态显示不同的图标 return ( -
-
- 视频预览 { - const videoUrl = - videoData.videoUrl || videoData.tencentUrl; - if (videoUrl) { - window.open(videoUrl, "_blank"); - } - }} - onError={e => { - const target = e.target as HTMLImageElement; - const parent = target.parentElement?.parentElement; - if (parent) { - parent.innerHTML = `
[视频预览加载失败]
`; - } - }} - /> -
- - - +
+
+
handlePlayClick(e, msg)} + > + 视频预览 { + const target = e.target as HTMLImageElement; + const parent = target.parentElement?.parentElement; + if (parent) { + parent.innerHTML = `
[视频预览加载失败]
`; + } + }} + /> +
+ {videoData.isLoading ? ( +
+ ) : ( + + )} +
@@ -712,7 +857,7 @@ const MessageRecord: React.FC = ({ contract }) => { {contract.nickname}
)} - <>{parseMessageContent(msg?.content, msg?.msgType)} + <>{parseMessageContent(msg?.content, msg, msg?.msgType)}
)} @@ -735,6 +880,7 @@ const MessageRecord: React.FC = ({ contract }) => { <> {parseMessageContent( clearWechatidInContent(msg?.sender, msg?.content), + msg, msg?.msgType, )} @@ -742,7 +888,7 @@ const MessageRecord: React.FC = ({ contract }) => { )} - {isOwn && <>{parseMessageContent(msg?.content, msg?.msgType)}} + {isOwn && <>{parseMessageContent(msg?.content, msg, msg?.msgType)}}
); diff --git a/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/demo.tsx b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/demo.tsx new file mode 100644 index 00000000..e1bfb7ca --- /dev/null +++ b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/demo.tsx @@ -0,0 +1,669 @@ +import React, { useEffect, useRef } from "react"; +import { Layout, Button, Avatar, Space, Dropdown, Menu, Tooltip } from "antd"; +import { + PhoneOutlined, + 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 { useWeChatStore } from "@/store/module/weChat/weChat"; +const { Header, Content } = Layout; + +interface ChatWindowProps { + contract: ContractData | weChatGroup; + showProfile?: boolean; + onToggleProfile?: () => void; +} + +const ChatWindow: React.FC = ({ + contract, + showProfile = true, + onToggleProfile, +}) => { + const messagesEndRef = useRef(null); + const currentMessages = useWeChatStore(state => state.currentMessages); + 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; + }, [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
消息内容不可用
; + } + // 检查是否为表情包 + if ( + typeof content === "string" && + content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") && + content.includes("#") + ) { + return ( +
+ 表情包 window.open(content, "_blank")} + /> +
+ ); + } + + // 检查是否为带预览图的视频消息 + 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 ( + + ); + } + + // 显示预览图,根据加载状态显示不同的图标 + return ( +
+
+ 视频预览 +
+ {videoData.isLoading ? ( +
+ ) : ( + + )} +
+
+
+ ); + } + // 保留原有的视频处理逻辑 + else if ( + videoData.type === "video" && + videoData.url && + videoData.thumb + ) { + return ( +
+
window.open(videoData.url, "_blank")} + > + 视频预览 +
+ +
+
+ e.stopPropagation()} + > + + +
+ ); + } + } + } 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 ( +
+ 图片消息 window.open(content, "_blank")} + /> +
+ ); + } + + // 检查是否为视频链接 + if ( + typeof content === "string" && + (content.match(/\.(mp4|avi|mov|wmv|flv)$/i) || + (content.includes("oss-cn-shenzhen.aliyuncs.com") && + content.includes(".mp4"))) + ) { + return ( + + ); + } + + // 检查是否为音频链接 + if ( + typeof content === "string" && + (content.match(/\.(mp3|wav|ogg|m4a)$/i) || + (content.includes("oss-cn-shenzhen.aliyuncs.com") && + content.includes(".mp3"))) + ) { + return ( + + ); + } + + // 检查是否为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 = ( + + ); + + if (fileExt === "pdf") { + fileIcon = ( + + ); + } else if (fileExt === "doc" || fileExt === "docx") { + fileIcon = ( + + ); + } else if (fileExt === "xls" || fileExt === "xlsx") { + fileIcon = ( + + ); + } else if (fileExt === "ppt" || fileExt === "pptx") { + fileIcon = ( + + ); + } + + return ( + + ); + } + + // 检查是否为文件消息(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 = ( + + ); + + if (fileExt === "pdf") { + fileIcon = ( + + ); + } else if (fileExt === "doc" || fileExt === "docx") { + fileIcon = ( + + ); + } else if (fileExt === "xls" || fileExt === "xlsx") { + fileIcon = ( + + ); + } else if (fileExt === "ppt" || fileExt === "pptx") { + fileIcon = ( + + ); + } + + return ( +
+ {fileIcon} +
+
+ {fileData.title} +
+ {fileData.totalLen && ( +
+ {Math.round(fileData.totalLen / 1024)} KB +
+ )} +
+ { + e.stopPropagation(); + if (!fileData.url) { + console.log("文件URL不存在"); + } + }} + rel="noreferrer" + > + + +
+ ); + } + } + } catch (e) { + // 解析JSON失败,不是文件消息 + } + + // 检查是否为位置信息 + if ( + typeof content === "string" && + (content.includes(" + +
+
{label}
+ {coordinates && ( +
+ {coordinates} +
+ )} +
+ + ); + } + + // 默认为文本消息 + return
{content}
; + }; + + // 用于分组消息并添加时间戳的辅助函数 + 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; + return ( +
+
+ {!isOwn && ( + } + className={styles.messageAvatar} + /> + )} +
+ {!isOwn && ( +
{msg?.senderName}
+ )} + {parseMessageContent(msg?.content, msg)} +
+
+
+ ); + }; + + const chatMenu = ( + + }> + 查看资料 + + }> + 语音通话 + + }> + 视频通话 + + + 置顶聊天 + 消息免打扰 + + + 清空聊天记录 + + + ); + + return ( + + {/* 聊天主体区域 */} + + {/* 聊天头部 */} +
+
+ : + } + /> +
+
+ {contract.nickname || contract.name} +
+
+
+ + +
+ + {/* 聊天内容 */} + +
+ {groupMessagesByTime(currentMessages).map((group, groupIndex) => ( + +
{group.time}
+ {group.messages.map(renderMessage)} +
+ ))} +
+
+ + + {/* 消息输入组件 */} + + + + {/* 右侧个人资料卡片 */} + + + ); +}; + +export default ChatWindow;