feat(MessageRecord): 优化消息渲染逻辑并增强样式

重构消息内容解析逻辑,增加对多种消息类型的支持,包括文本、图片、视频、表情包、小程序和文件消息。改进错误处理和兼容性,同时优化UI样式和移动端适配。

- 新增消息类型检测工具函数
- 增强JSON解析和错误处理
- 优化小程序消息的XML解析和显示
- 改进文件消息的图标和文件名显示
- 添加移动端样式适配
- 统一错误消息渲染逻辑
This commit is contained in:
超级老白兔
2025-09-08 12:13:38 +08:00
parent 57ceae97a1
commit bd6f1064fb
2 changed files with 402 additions and 120 deletions

View File

@@ -55,7 +55,7 @@
.messageBubble {
background: #1890ff;
color: white;
border-radius: 18px 4px 18px 18px;
border-radius: 4px 18px 18px 18px;
}
}
@@ -180,37 +180,34 @@
}
}
// 小程序消息
// 小程序消息样式
.miniProgramMessage {
background: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
.miniProgramCard {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
max-width: 280px;
gap: 10px;
padding: 10px 14px;
&:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
}
cursor: pointer;
transition: all 0.2s ease;
min-width: 260px;
min-height: 56px;
}
.miniProgramThumb {
width: 40px;
height: 40px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: #f0f0f0;
}
.miniProgramInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.miniProgramTitle {
@@ -218,23 +215,31 @@
color: #262626;
font-size: 14px;
line-height: 1.4;
margin-bottom: 4px;
margin-bottom: 3px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.miniProgramApp {
font-size: 12px;
color: #8c8c8c;
color: #666666;
display: flex;
align-items: center;
gap: 4px;
line-height: 1.2;
font-weight: 400;
&::before {
content: "📱";
font-size: 10px;
content: "";
width: 14px;
height: 14px;
background: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHZpZXdCb3g9IjAgMCAxNCAxNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTcgMEM0LjI0IDAgMiAyLjI0IDIgNVY5QzIgMTEuNzYgNC4yNCAxNCA3IDE0UzEyIDExLjc2IDEyIDlWNUMxMiAyLjI0IDkuNzYgMCA3IDBaTTEwIDlDMTAgMTAuNjUgOC42NSAxMiA3IDEyUzQgMTAuNjUgNCA5VjVDNCAzLjM1IDUuMzUgMiA3IDJTMTAgMy4zNSAxMCA1VjlaIiBmaWxsPSIjNjY2NjY2Ii8+Cjwvc3ZnPgo=")
no-repeat center;
background-size: contain;
flex-shrink: 0;
}
}
}
@@ -304,4 +309,37 @@
max-width: 150px;
max-height: 150px;
}
// 小程序消息移动端适配
.miniProgramMessage {
min-width: 400px;
.miniProgramCard {
max-width: 260px;
padding: 8px 12px;
gap: 8px;
min-height: 52px;
&:hover {
transform: none; // 移动端禁用悬浮动画
}
}
.miniProgramThumb {
width: 36px;
height: 36px;
}
.miniProgramTitle {
font-size: 13px;
line-height: 1.3;
}
.miniProgramApp {
font-size: 11px;
&::before {
width: 12px;
height: 12px;
}
}
}
}

View File

@@ -20,6 +20,17 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
state => state.currentGroupMembers,
);
// 判断是否为表情包URL的工具函数
const isEmojiUrl = (content: string): boolean => {
return (
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") ||
/\.(gif|webp|png|jpg|jpeg)$/i.test(content) ||
content.includes("emoji") ||
content.includes("sticker") ||
content.includes("expression")
);
};
useEffect(() => {
if (isLoadingData) {
scrollToBottom();
@@ -40,48 +51,72 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
return <div className={styles.messageText}></div>;
}
// 统一的错误消息渲染函数
const renderErrorMessage = (fallbackText: string) => (
<div className={styles.messageText}>{fallbackText}</div>
);
// 根据msgType进行消息类型判断
switch (msgType) {
case 1: // 文本消息
return <div className={styles.messageText}>{content}</div>;
return (
<div className={styles.messageBubble}>
<div className={styles.messageText}>{content}</div>
</div>
);
case 3: // 图片消息
// 验证是否为有效的图片URL
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[图片消息 - 无效链接]");
}
return (
<div className={styles.imageMessage}>
<img
src={content}
alt="图片消息"
style={{
maxWidth: "200px",
maxHeight: "200px",
borderRadius: "8px",
}}
onClick={() => window.open(content, "_blank")}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
target.parentElement!.innerHTML = `<div class="${styles.messageText}">图片加载失败</div>`;
}}
/>
<div className={styles.messageBubble}>
<div className={styles.imageMessage}>
<img
src={content}
alt="图片消息"
style={{
maxWidth: "200px",
maxHeight: "200px",
borderRadius: "8px",
}}
onClick={() => window.open(content, "_blank")}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[图片加载失败]</div>`;
}
}}
/>
</div>
</div>
);
case 43: // 视频消息
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[视频消息 - 无效内容]");
}
try {
if (
typeof content === "string" &&
content.trim().startsWith("{") &&
content.trim().endsWith("}")
) {
const videoData = JSON.parse(content);
// 更严格的JSON格式验证
const trimmedContent = content.trim();
if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) {
const videoData = JSON.parse(trimmedContent);
// 验证必要的视频数据字段
if (
videoData &&
typeof videoData === "object" &&
videoData.previewImage &&
(videoData.tencentUrl || videoData.videoUrl)
) {
const previewImageUrl = videoData.previewImage.replace(
const previewImageUrl = String(videoData.previewImage).replace(
/[`"']/g,
"",
);
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
@@ -90,10 +125,17 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
alt="视频预览"
className={styles.videoPreview}
onClick={() => {
if (videoData.videoUrl) {
window.open(videoData.videoUrl, "_blank");
} else if (videoData.tencentUrl) {
window.open(videoData.tencentUrl, "_blank");
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 = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
@@ -112,21 +154,19 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
);
}
}
// 如果JSON解析失败或格式不正确显示视频消息提示
return <div className={styles.messageText}>[]</div>;
return renderErrorMessage("[视频消息]");
} catch (e) {
return <div className={styles.messageText}>[]</div>;
console.warn("视频消息解析失败:", e);
return renderErrorMessage("[视频消息 - 解析失败]");
}
case 47: // 动图表情包gif、其他表情包
// 检查是否为特定URL的表情包
if (
typeof content === "string" &&
(content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") ||
content.includes(".gif") ||
content.includes("emoji") ||
content.includes("sticker"))
) {
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[表情包 - 无效链接]");
}
// 使用工具函数判断表情包URL
if (isEmojiUrl(content)) {
return (
<div className={styles.emojiMessage}>
<img
@@ -136,25 +176,91 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
onClick={() => window.open(content, "_blank")}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
target.parentElement!.innerHTML = `<div class="${styles.messageText}">[表情包]</div>`;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[表情包加载失败]</div>`;
}
}}
/>
</div>
);
}
return <div className={styles.messageText}>[]</div>;
return renderErrorMessage("[表情包]");
case 49: // 小程序/其他:图文、文件
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[小程序/文件消息 - 无效内容]");
}
try {
const trimmedContent = content.trim();
// 尝试解析JSON格式的小程序消息
if (
typeof content === "string" &&
content.trim().startsWith("{") &&
content.trim().endsWith("}")
) {
const miniProgramData = JSON.parse(content);
if (miniProgramData.title || miniProgramData.appName) {
if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) {
const miniProgramData = JSON.parse(trimmedContent);
// 处理包含contentXml的小程序消息格式
if (
miniProgramData.contentXml &&
miniProgramData.type === "miniprogram"
) {
const xmlContent = miniProgramData.contentXml;
// 从XML中提取title
const titleMatch = xmlContent.match(/<title>([^<]*)<\/title>/);
const title = titleMatch ? titleMatch[1] : "小程序消息";
// 从XML中提取thumburl或使用previewImage
const thumbUrlMatch = xmlContent.match(
/<thumburl>\s*([^<]*?)\s*<\/thumburl>/,
);
let thumbUrl = thumbUrlMatch ? thumbUrlMatch[1].trim() : "";
// 如果thumburl为空或无效使用previewImage
if (!thumbUrl || thumbUrl === "`" || thumbUrl.includes("`")) {
thumbUrl = miniProgramData.previewImage || "";
}
// 清理URL中的特殊字符
thumbUrl = thumbUrl.replace(/[`"']/g, "").replace(/&amp;/g, "&");
// 从XML中提取appname或使用默认值
const appNameMatch =
xmlContent.match(/<appname\s*\/?>([^<]*)<\/appname>/) ||
xmlContent.match(
/<sourcedisplayname>([^<]*)<\/sourcedisplayname>/,
);
const appName = appNameMatch ? appNameMatch[1] : "小程序";
return (
<div className={styles.miniProgramMessage}>
<div className={styles.miniProgramCard}>
{thumbUrl && (
<img
src={thumbUrl}
alt="小程序缩略图"
className={styles.miniProgramThumb}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
</div>
</div>
<div className={styles.miniProgramApp}>{appName}</div>
</div>
);
}
// 验证传统JSON格式的小程序数据结构
if (
miniProgramData &&
typeof miniProgramData === "object" &&
(miniProgramData.title || miniProgramData.appName)
) {
return (
<div className={styles.miniProgramMessage}>
<div className={styles.miniProgramCard}>
@@ -163,6 +269,10 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
src={miniProgramData.thumb}
alt="小程序缩略图"
className={styles.miniProgramThumb}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
<div className={styles.miniProgramInfo}>
@@ -180,20 +290,67 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
);
}
}
// 文件消息处理
if (
typeof content === "string" &&
(content.includes("http") || content.includes("file"))
) {
// 增强的文件消息处理
const isFileUrl =
content.startsWith("http") ||
content.startsWith("https") ||
content.startsWith("file://") ||
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(content);
if (isFileUrl) {
// 尝试从URL中提取文件名
const fileName = content.split("/").pop()?.split("?")[0] || "文件";
const fileExtension = fileName.split(".").pop()?.toLowerCase();
// 根据文件类型选择图标
let fileIcon = "📄";
if (fileExtension) {
const iconMap: { [key: string]: string } = {
pdf: "📕",
doc: "📘",
docx: "📘",
xls: "📗",
xlsx: "📗",
ppt: "📙",
pptx: "📙",
txt: "📝",
zip: "🗜️",
rar: "🗜️",
"7z": "🗜️",
jpg: "🖼️",
jpeg: "🖼️",
png: "🖼️",
gif: "🖼️",
mp4: "🎬",
avi: "🎬",
mov: "🎬",
mp3: "🎵",
wav: "🎵",
flac: "🎵",
};
fileIcon = iconMap[fileExtension] || "📄";
}
return (
<div className={styles.fileMessage}>
<div className={styles.fileCard}>
<div className={styles.fileIcon}>📄</div>
<div className={styles.fileIcon}>{fileIcon}</div>
<div className={styles.fileInfo}>
<div className={styles.fileName}></div>
<div className={styles.fileName}>
{fileName.length > 20
? fileName.substring(0, 20) + "..."
: fileName}
</div>
<div
className={styles.fileAction}
onClick={() => window.open(content, "_blank")}
onClick={() => {
try {
window.open(content, "_blank");
} catch (e) {
console.error("文件打开失败:", e);
}
}}
>
</div>
@@ -202,41 +359,62 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
</div>
);
}
return <div className={styles.messageText}>[/]</div>;
return renderErrorMessage("[小程序/文件消息]");
} catch (e) {
return <div className={styles.messageText}>[/]</div>;
console.warn("小程序/文件消息解析失败:", e);
return renderErrorMessage("[小程序/文件消息 - 解析失败]");
}
default:
// 兼容旧版本如果没有msgType或msgType不在已知范围内使用原有逻辑
// 检查是否为表情包(兼容旧逻辑)
if (
typeof content === "string" &&
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") &&
content.includes("#")
) {
default: {
// 兼容旧版本和未知消息类型的处理逻辑
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage(
`[未知消息类型${msgType ? ` - ${msgType}` : ""}]`,
);
}
// 智能识别消息类型(兼容旧版本数据)
const contentStr = content.trim();
// 1. 检查是否为表情包(兼容旧逻辑)
const isLegacyEmoji =
contentStr.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") ||
/\.(gif|webp|png|jpg|jpeg)$/i.test(contentStr) ||
contentStr.includes("emoji") ||
contentStr.includes("sticker");
if (isLegacyEmoji) {
return (
<div className={styles.emojiMessage}>
<img
src={content}
src={contentStr}
alt="表情包"
style={{ maxWidth: "120px", maxHeight: "120px" }}
onClick={() => window.open(content, "_blank")}
onClick={() => window.open(contentStr, "_blank")}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[表情包加载失败]</div>`;
}
}}
/>
</div>
);
}
// 检查是否为带预览图的视频消息(兼容旧逻辑)
try {
if (
typeof content === "string" &&
content.trim().startsWith("{") &&
content.trim().endsWith("}")
) {
const videoData = JSON.parse(content);
if (videoData.previewImage && videoData.tencentUrl) {
const previewImageUrl = videoData.previewImage.replace(
// 2. 检查是否为视频消息(兼容旧逻辑)
if (contentStr.startsWith("{") && contentStr.endsWith("}")) {
try {
const videoData = JSON.parse(contentStr);
if (
videoData &&
typeof videoData === "object" &&
videoData.previewImage &&
(videoData.tencentUrl || videoData.videoUrl)
) {
const previewImageUrl = String(videoData.previewImage).replace(
/[`"']/g,
"",
);
@@ -248,10 +426,17 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
alt="视频预览"
className={styles.videoPreview}
onClick={() => {
if (videoData.videoUrl) {
window.open(videoData.videoUrl, "_blank");
} else if (videoData.tencentUrl) {
window.open(videoData.tencentUrl, "_blank");
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 = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
@@ -269,13 +454,74 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
</div>
);
}
} catch (e) {
console.warn("兼容模式JSON解析失败:", e);
}
} catch (e) {
// JSON解析失败按普通文本处理
}
// 普通文本消息
// 3. 检查是否为图片链接
const isImageUrl =
contentStr.startsWith("http") &&
/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(contentStr);
if (isImageUrl) {
return (
<div className={styles.imageMessage}>
<img
src={contentStr}
alt="图片消息"
style={{
maxWidth: "200px",
maxHeight: "200px",
borderRadius: "8px",
}}
onClick={() => window.open(contentStr, "_blank")}
onError={e => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[图片加载失败]</div>`;
}
}}
/>
</div>
);
}
// 4. 检查是否为文件链接
const isFileLink =
contentStr.startsWith("http") &&
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(
contentStr,
);
if (isFileLink) {
const fileName = contentStr.split("/").pop()?.split("?")[0] || "文件";
return (
<div className={styles.fileMessage}>
<div className={styles.fileCard}>
<div className={styles.fileIcon}>📄</div>
<div className={styles.fileInfo}>
<div className={styles.fileName}>
{fileName.length > 20
? fileName.substring(0, 20) + "..."
: fileName}
</div>
<div
className={styles.fileAction}
onClick={() => window.open(contentStr, "_blank")}
>
</div>
</div>
</div>
</div>
);
}
// 5. 默认按文本消息处理
return <div className={styles.messageText}>{content}</div>;
}
}
};
@@ -331,13 +577,13 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
className={styles.messageAvatar}
/>
<div className={styles.messageBubble}>
<div>
{!isOwn && (
<div className={styles.messageSender}>
{contract.nickname}
</div>
)}
{parseMessageContent(msg?.content, msg?.msgType)}
<>{parseMessageContent(msg?.content, msg?.msgType)}</>
</div>
</>
)}
@@ -351,25 +597,23 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
className={styles.messageAvatar}
/>
<div className={styles.messageBubble}>
<div>
{!isOwn && (
<div className={styles.messageSender}>
{msg?.sender?.nickname}
</div>
)}
{parseMessageContent(
clearWechatidInContent(msg?.sender, msg?.content),
msg?.msgType,
)}
<>
{parseMessageContent(
clearWechatidInContent(msg?.sender, msg?.content),
msg?.msgType,
)}
</>
</div>
</>
)}
{isOwn && (
<div className={styles.messageBubble}>
{parseMessageContent(msg?.content, msg?.msgType)}
</div>
)}
{isOwn && <>{parseMessageContent(msg?.content, msg?.msgType)}</>}
</div>
</div>
);