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 3cc95355..07fb13f5 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 @@ -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; + } + } + } } 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 f57195e2..ba4e40c6 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 @@ -20,6 +20,17 @@ const MessageRecord: React.FC = ({ 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 = ({ contract }) => { return
消息内容不可用
; } + // 统一的错误消息渲染函数 + const renderErrorMessage = (fallbackText: string) => ( +
{fallbackText}
+ ); + // 根据msgType进行消息类型判断 switch (msgType) { case 1: // 文本消息 - return
{content}
; + return ( +
+
{content}
+
+ ); case 3: // 图片消息 + // 验证是否为有效的图片URL + if (typeof content !== "string" || !content.trim()) { + return renderErrorMessage("[图片消息 - 无效链接]"); + } return ( -
- 图片消息 window.open(content, "_blank")} - onError={e => { - const target = e.target as HTMLImageElement; - target.style.display = "none"; - target.parentElement!.innerHTML = `
图片加载失败
`; - }} - /> +
+
+ 图片消息 window.open(content, "_blank")} + onError={e => { + const target = e.target as HTMLImageElement; + const parent = target.parentElement; + if (parent) { + parent.innerHTML = `
[图片加载失败]
`; + } + }} + /> +
); 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 (
@@ -90,10 +125,17 @@ const MessageRecord: React.FC = ({ 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 = `
[视频预览加载失败]
`; } }} /> @@ -112,21 +154,19 @@ const MessageRecord: React.FC = ({ contract }) => { ); } } - // 如果JSON解析失败或格式不正确,显示视频消息提示 - return
[视频消息]
; + return renderErrorMessage("[视频消息]"); } catch (e) { - return
[视频消息]
; + 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 (
= ({ contract }) => { onClick={() => window.open(content, "_blank")} onError={e => { const target = e.target as HTMLImageElement; - target.style.display = "none"; - target.parentElement!.innerHTML = `
[表情包]
`; + const parent = target.parentElement; + if (parent) { + parent.innerHTML = `
[表情包加载失败]
`; + } }} />
); } - return
[表情包]
; + 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>/); + 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(/&/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> );