feat(视频消息): 添加视频消息组件并优化消息记录处理

将视频消息处理逻辑从MessageRecord组件中抽离为独立组件VideoMessage
新增视频消息样式文件VideoMessage.module.scss
优化视频消息的加载状态处理和错误显示
This commit is contained in:
超级老白兔
2025-09-10 10:02:43 +08:00
parent ae4fcbad67
commit f60bf294c1
5 changed files with 346 additions and 172 deletions

View File

@@ -33,7 +33,7 @@
"name": "vendor"
},
"index.html": {
"file": "assets/index-CTEriEiT.js",
"file": "assets/index-PSLRJs-x.js",
"name": "index",
"src": "index.html",
"isEntry": true,
@@ -44,7 +44,7 @@
"_charts-ghR_XExL.js"
],
"css": [
"assets/index-ZHlr-6NP.css"
"assets/index-2A02LaoT.css"
]
}
}

View File

@@ -11,13 +11,13 @@
</style>
<!-- 引入 uni-app web-view SDK必须 -->
<script type="text/javascript" src="/websdk.js"></script>
<script type="module" crossorigin src="/assets/index-CTEriEiT.js"></script>
<script type="module" crossorigin src="/assets/index-PSLRJs-x.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-BPPoWDlG.js">
<link rel="modulepreload" crossorigin href="/assets/utils-DiZV3oaL.js">
<link rel="modulepreload" crossorigin href="/assets/ui-J9wtlgqT.js">
<link rel="modulepreload" crossorigin href="/assets/charts-ghR_XExL.js">
<link rel="stylesheet" crossorigin href="/assets/ui-D0C0OGrH.css">
<link rel="stylesheet" crossorigin href="/assets/index-ZHlr-6NP.css">
<link rel="stylesheet" crossorigin href="/assets/index-2A02LaoT.css">
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,153 @@
// 通用消息文本样式
.messageText {
color: #666;
font-style: italic;
padding: 8px 12px;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
// 消息气泡样式
.messageBubble {
display: inline-block;
max-width: 70%;
padding: 8px 12px;
border-radius: 12px;
word-wrap: break-word;
position: relative;
}
// 视频消息样式
.videoMessage {
position: relative;
display: inline-block;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: #000;
.videoContainer {
position: relative;
display: inline-block;
cursor: pointer;
video {
display: block;
max-width: 300px;
max-height: 400px;
border-radius: 8px;
}
}
.videoThumbnail {
display: block;
max-width: 300px;
max-height: 400px;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.3s ease;
}
.videoPlayIcon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 2;
.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;
width: 32px;
height: 32px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
color: white;
border: none;
cursor: pointer;
transition: background 0.2s;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
z-index: 3;
&:hover {
background: rgba(0, 0, 0, 0.8);
color: white;
}
}
.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; // 视觉居中调整
}
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// 响应式设计
@media (max-width: 768px) {
.messageBubble {
padding: 6px 10px;
}
.videoMessage .videoThumbnail,
.videoMessage .videoContainer video {
max-width: 200px;
max-height: 250px;
}
.videoMessage .videoPlayIcon {
.loadingSpinner {
width: 36px;
height: 36px;
border-width: 3px;
}
}
.videoMessage .downloadButton {
width: 28px;
height: 28px;
top: 6px;
right: 6px;
svg {
font-size: 14px !important;
}
}
}

View File

@@ -0,0 +1,182 @@
import React from "react";
import { DownloadOutlined, PlayCircleFilled } from "@ant-design/icons";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import styles from "./VideoMessage.module.scss";
interface VideoMessageProps {
content: string;
msg: ChatRecord;
contract: ContractData | weChatGroup;
}
const VideoMessage: React.FC<VideoMessageProps> = ({
content,
msg,
contract,
}) => {
// 检测是否为直接视频链接的函数
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"))
);
};
// 处理视频播放请求发送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 renderErrorMessage = (message: string) => (
<div className={styles.messageText}>{message}</div>
);
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[视频消息 - 无效内容]");
}
// 如果content是直接的视频链接已预览过或下载好的视频
if (isDirectVideoLink(content)) {
return (
<div className={styles.messageBubble}>
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<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>
</div>
</div>
);
}
try {
// 尝试解析JSON格式的视频数据
if (content.startsWith("{") && content.endsWith("}")) {
const videoData = JSON.parse(content);
// 验证必要的视频数据字段
if (
videoData &&
typeof videoData === "object" &&
videoData.previewImage &&
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 (
<div className={styles.messageBubble}>
<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>
</div>
);
}
// 显示预览图,根据加载状态显示不同的图标
return (
<div className={styles.messageBubble}>
<div className={styles.videoMessage}>
<div
className={styles.videoContainer}
onClick={e => handlePlayClick(e, msg)}
>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoThumbnail}
style={{
maxWidth: "100%",
borderRadius: "8px",
opacity: videoData.isLoading ? "0.7" : "1",
}}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement?.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
<div className={styles.videoPlayIcon}>
{videoData.isLoading ? (
<div className={styles.loadingSpinner}></div>
) : (
<PlayCircleFilled
style={{ fontSize: "48px", color: "#fff" }}
/>
)}
</div>
</div>
</div>
</div>
);
}
}
return renderErrorMessage("[视频消息]");
} catch (e) {
console.warn("视频消息解析失败:", e);
return renderErrorMessage("[视频消息 - 解析失败]");
}
};
export default VideoMessage;

View File

@@ -1,19 +1,14 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef } from "react";
import { Avatar, Divider } from "antd";
import {
UserOutlined,
LoadingOutlined,
DownloadOutlined,
PlayCircleFilled,
} from "@ant-design/icons";
import { UserOutlined, LoadingOutlined } from "@ant-design/icons";
import AudioMessage from "./components/AudioMessage/AudioMessage";
import SmallProgramMessage from "./components/SmallProgramMessage";
import VideoMessage from "./components/VideoMessage";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { formatWechatTime, parseWeappMsgStr } from "@/utils/common";
import { formatWechatTime } from "@/utils/common";
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
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;
@@ -30,18 +25,6 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
);
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 => {
return (
@@ -156,23 +139,6 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
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,
@@ -228,136 +194,9 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
);
case 43: // 视频消息
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[视频消息 - 无效内容]");
}
// 如果content是直接的视频链接已预览过或下载好的视频
if (isDirectVideoLink(content)) {
return (
<div className={styles.messageBubble}>
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<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>
</div>
</div>
);
}
try {
// 尝试解析JSON格式的视频数据
if (content.startsWith("{") && content.endsWith("}")) {
const videoData = JSON.parse(content);
// 验证必要的视频数据字段
if (
videoData &&
typeof videoData === "object" &&
videoData.previewImage &&
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 (
<div className={styles.messageBubble}>
<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>
</div>
);
}
// 显示预览图,根据加载状态显示不同的图标
return (
<div className={styles.messageBubble}>
<div className={styles.videoMessage}>
<div
className={styles.videoContainer}
onClick={e => handlePlayClick(e, msg)}
>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoThumbnail}
style={{
maxWidth: "100%",
borderRadius: "8px",
opacity: videoData.isLoading ? "0.7" : "1",
}}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement?.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
<div className={styles.videoPlayIcon}>
{videoData.isLoading ? (
<div className={styles.loadingSpinner}></div>
) : (
<PlayCircleFilled
style={{ fontSize: "48px", color: "#fff" }}
/>
)}
</div>
</div>
</div>
</div>
);
}
}
return renderErrorMessage("[视频消息]");
} catch (e) {
console.warn("视频消息解析失败:", e);
return renderErrorMessage("[视频消息 - 解析失败]");
}
return (
<VideoMessage content={content || ""} msg={msg} contract={contract} />
);
case 47: // 动图表情包gif、其他表情包
if (typeof content !== "string" || !content.trim()) {