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 { .messageBubble {
background: #1890ff; background: #1890ff;
color: white; color: white;
border-radius: 18px 4px 18px 18px; border-radius: 4px 18px 18px 18px;
} }
} }
@@ -180,37 +180,34 @@
} }
} }
// 小程序消息 // 小程序消息样式
.miniProgramMessage { .miniProgramMessage {
background: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
.miniProgramCard { .miniProgramCard {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
padding: 12px; padding: 10px 14px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
max-width: 280px;
&:hover { cursor: pointer;
border-color: #1890ff; transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1); min-width: 260px;
} min-height: 56px;
} }
.miniProgramThumb { .miniProgramThumb {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 6px; background: #f0f0f0;
object-fit: cover;
flex-shrink: 0;
} }
.miniProgramInfo { .miniProgramInfo {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
} }
.miniProgramTitle { .miniProgramTitle {
@@ -218,23 +215,31 @@
color: #262626; color: #262626;
font-size: 14px; font-size: 14px;
line-height: 1.4; line-height: 1.4;
margin-bottom: 4px; margin-bottom: 3px;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 1;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
} }
.miniProgramApp { .miniProgramApp {
font-size: 12px; font-size: 12px;
color: #8c8c8c; color: #666666;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
line-height: 1.2;
font-weight: 400;
&::before { &::before {
content: "📱"; content: "";
font-size: 10px; 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-width: 150px;
max-height: 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, 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(() => { useEffect(() => {
if (isLoadingData) { if (isLoadingData) {
scrollToBottom(); scrollToBottom();
@@ -40,48 +51,72 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
return <div className={styles.messageText}></div>; return <div className={styles.messageText}></div>;
} }
// 统一的错误消息渲染函数
const renderErrorMessage = (fallbackText: string) => (
<div className={styles.messageText}>{fallbackText}</div>
);
// 根据msgType进行消息类型判断 // 根据msgType进行消息类型判断
switch (msgType) { switch (msgType) {
case 1: // 文本消息 case 1: // 文本消息
return <div className={styles.messageText}>{content}</div>; return (
<div className={styles.messageBubble}>
<div className={styles.messageText}>{content}</div>
</div>
);
case 3: // 图片消息 case 3: // 图片消息
// 验证是否为有效的图片URL
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[图片消息 - 无效链接]");
}
return ( return (
<div className={styles.imageMessage}> <div className={styles.messageBubble}>
<img <div className={styles.imageMessage}>
src={content} <img
alt="图片消息" src={content}
style={{ alt="图片消息"
maxWidth: "200px", style={{
maxHeight: "200px", maxWidth: "200px",
borderRadius: "8px", maxHeight: "200px",
}} borderRadius: "8px",
onClick={() => window.open(content, "_blank")} }}
onError={e => { onClick={() => window.open(content, "_blank")}
const target = e.target as HTMLImageElement; onError={e => {
target.style.display = "none"; const target = e.target as HTMLImageElement;
target.parentElement!.innerHTML = `<div class="${styles.messageText}">图片加载失败</div>`; const parent = target.parentElement;
}} if (parent) {
/> parent.innerHTML = `<div class="${styles.messageText}">[图片加载失败]</div>`;
}
}}
/>
</div>
</div> </div>
); );
case 43: // 视频消息 case 43: // 视频消息
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[视频消息 - 无效内容]");
}
try { try {
if ( // 更严格的JSON格式验证
typeof content === "string" && const trimmedContent = content.trim();
content.trim().startsWith("{") && if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) {
content.trim().endsWith("}") const videoData = JSON.parse(trimmedContent);
) {
const videoData = JSON.parse(content); // 验证必要的视频数据字段
if ( if (
videoData &&
typeof videoData === "object" &&
videoData.previewImage && videoData.previewImage &&
(videoData.tencentUrl || videoData.videoUrl) (videoData.tencentUrl || videoData.videoUrl)
) { ) {
const previewImageUrl = videoData.previewImage.replace( const previewImageUrl = String(videoData.previewImage).replace(
/[`"']/g, /[`"']/g,
"", "",
); );
return ( return (
<div className={styles.videoMessage}> <div className={styles.videoMessage}>
<div className={styles.videoContainer}> <div className={styles.videoContainer}>
@@ -90,10 +125,17 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
alt="视频预览" alt="视频预览"
className={styles.videoPreview} className={styles.videoPreview}
onClick={() => { onClick={() => {
if (videoData.videoUrl) { const videoUrl =
window.open(videoData.videoUrl, "_blank"); videoData.videoUrl || videoData.tencentUrl;
} else if (videoData.tencentUrl) { if (videoUrl) {
window.open(videoData.tencentUrl, "_blank"); 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 renderErrorMessage("[视频消息]");
return <div className={styles.messageText}>[]</div>;
} catch (e) { } catch (e) {
return <div className={styles.messageText}>[]</div>; console.warn("视频消息解析失败:", e);
return renderErrorMessage("[视频消息 - 解析失败]");
} }
case 47: // 动图表情包gif、其他表情包 case 47: // 动图表情包gif、其他表情包
// 检查是否为特定URL的表情包 if (typeof content !== "string" || !content.trim()) {
if ( return renderErrorMessage("[表情包 - 无效链接]");
typeof content === "string" && }
(content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") ||
content.includes(".gif") || // 使用工具函数判断表情包URL
content.includes("emoji") || if (isEmojiUrl(content)) {
content.includes("sticker"))
) {
return ( return (
<div className={styles.emojiMessage}> <div className={styles.emojiMessage}>
<img <img
@@ -136,25 +176,91 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
onClick={() => window.open(content, "_blank")} onClick={() => window.open(content, "_blank")}
onError={e => { onError={e => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.style.display = "none"; const parent = target.parentElement;
target.parentElement!.innerHTML = `<div class="${styles.messageText}">[表情包]</div>`; if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[表情包加载失败]</div>`;
}
}} }}
/> />
</div> </div>
); );
} }
return <div className={styles.messageText}>[]</div>; return renderErrorMessage("[表情包]");
case 49: // 小程序/其他:图文、文件 case 49: // 小程序/其他:图文、文件
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[小程序/文件消息 - 无效内容]");
}
try { try {
const trimmedContent = content.trim();
// 尝试解析JSON格式的小程序消息 // 尝试解析JSON格式的小程序消息
if ( if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) {
typeof content === "string" && const miniProgramData = JSON.parse(trimmedContent);
content.trim().startsWith("{") &&
content.trim().endsWith("}") // 处理包含contentXml的小程序消息格式
) { if (
const miniProgramData = JSON.parse(content); miniProgramData.contentXml &&
if (miniProgramData.title || miniProgramData.appName) { 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 ( return (
<div className={styles.miniProgramMessage}> <div className={styles.miniProgramMessage}>
<div className={styles.miniProgramCard}> <div className={styles.miniProgramCard}>
@@ -163,6 +269,10 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
src={miniProgramData.thumb} src={miniProgramData.thumb}
alt="小程序缩略图" alt="小程序缩略图"
className={styles.miniProgramThumb} className={styles.miniProgramThumb}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/> />
)} )}
<div className={styles.miniProgramInfo}> <div className={styles.miniProgramInfo}>
@@ -180,20 +290,67 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
); );
} }
} }
// 文件消息处理
if ( // 增强的文件消息处理
typeof content === "string" && const isFileUrl =
(content.includes("http") || content.includes("file")) 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 ( return (
<div className={styles.fileMessage}> <div className={styles.fileMessage}>
<div className={styles.fileCard}> <div className={styles.fileCard}>
<div className={styles.fileIcon}>📄</div> <div className={styles.fileIcon}>{fileIcon}</div>
<div className={styles.fileInfo}> <div className={styles.fileInfo}>
<div className={styles.fileName}></div> <div className={styles.fileName}>
{fileName.length > 20
? fileName.substring(0, 20) + "..."
: fileName}
</div>
<div <div
className={styles.fileAction} className={styles.fileAction}
onClick={() => window.open(content, "_blank")} onClick={() => {
try {
window.open(content, "_blank");
} catch (e) {
console.error("文件打开失败:", e);
}
}}
> >
</div> </div>
@@ -202,41 +359,62 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
</div> </div>
); );
} }
return <div className={styles.messageText}>[/]</div>;
return renderErrorMessage("[小程序/文件消息]");
} catch (e) { } catch (e) {
return <div className={styles.messageText}>[/]</div>; console.warn("小程序/文件消息解析失败:", e);
return renderErrorMessage("[小程序/文件消息 - 解析失败]");
} }
default: default: {
// 兼容旧版本如果没有msgType或msgType不在已知范围内使用原有逻辑 // 兼容旧版本和未知消息类型的处理逻辑
// 检查是否为表情包(兼容旧逻辑) if (typeof content !== "string" || !content.trim()) {
if ( return renderErrorMessage(
typeof content === "string" && `[未知消息类型${msgType ? ` - ${msgType}` : ""}]`,
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") && );
content.includes("#") }
) {
// 智能识别消息类型(兼容旧版本数据)
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 ( return (
<div className={styles.emojiMessage}> <div className={styles.emojiMessage}>
<img <img
src={content} src={contentStr}
alt="表情包" alt="表情包"
style={{ maxWidth: "120px", maxHeight: "120px" }} 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> </div>
); );
} }
// 检查是否为带预览图的视频消息(兼容旧逻辑) // 2. 检查是否为视频消息(兼容旧逻辑)
try { if (contentStr.startsWith("{") && contentStr.endsWith("}")) {
if ( try {
typeof content === "string" && const videoData = JSON.parse(contentStr);
content.trim().startsWith("{") && if (
content.trim().endsWith("}") videoData &&
) { typeof videoData === "object" &&
const videoData = JSON.parse(content); videoData.previewImage &&
if (videoData.previewImage && videoData.tencentUrl) { (videoData.tencentUrl || videoData.videoUrl)
const previewImageUrl = videoData.previewImage.replace( ) {
const previewImageUrl = String(videoData.previewImage).replace(
/[`"']/g, /[`"']/g,
"", "",
); );
@@ -248,10 +426,17 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
alt="视频预览" alt="视频预览"
className={styles.videoPreview} className={styles.videoPreview}
onClick={() => { onClick={() => {
if (videoData.videoUrl) { const videoUrl =
window.open(videoData.videoUrl, "_blank"); videoData.videoUrl || videoData.tencentUrl;
} else if (videoData.tencentUrl) { if (videoUrl) {
window.open(videoData.tencentUrl, "_blank"); 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> </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>; return <div className={styles.messageText}>{content}</div>;
}
} }
}; };
@@ -331,13 +577,13 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
className={styles.messageAvatar} className={styles.messageAvatar}
/> />
<div className={styles.messageBubble}> <div>
{!isOwn && ( {!isOwn && (
<div className={styles.messageSender}> <div className={styles.messageSender}>
{contract.nickname} {contract.nickname}
</div> </div>
)} )}
{parseMessageContent(msg?.content, msg?.msgType)} <>{parseMessageContent(msg?.content, msg?.msgType)}</>
</div> </div>
</> </>
)} )}
@@ -351,25 +597,23 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
className={styles.messageAvatar} className={styles.messageAvatar}
/> />
<div className={styles.messageBubble}> <div>
{!isOwn && ( {!isOwn && (
<div className={styles.messageSender}> <div className={styles.messageSender}>
{msg?.sender?.nickname} {msg?.sender?.nickname}
</div> </div>
)} )}
{parseMessageContent( <>
clearWechatidInContent(msg?.sender, msg?.content), {parseMessageContent(
msg?.msgType, clearWechatidInContent(msg?.sender, msg?.content),
)} msg?.msgType,
)}
</>
</div> </div>
</> </>
)} )}
{isOwn && ( {isOwn && <>{parseMessageContent(msg?.content, msg?.msgType)}</>}
<div className={styles.messageBubble}>
{parseMessageContent(msg?.content, msg?.msgType)}
</div>
)}
</div> </div>
</div> </div>
); );