diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/SmallProgramMessage.module.scss b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/SmallProgramMessage.module.scss index 40124966..8aec8f11 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/SmallProgramMessage.module.scss +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/SmallProgramMessage.module.scss @@ -273,6 +273,12 @@ text-decoration: underline; } } + + .fileActionDisabled { + color: #999; + cursor: not-allowed; + pointer-events: none; + } } // 响应式设计 @@ -312,4 +318,4 @@ } } } -} \ No newline at end of file +} diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx index 51711685..11b6d335 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx @@ -1,14 +1,169 @@ import React from "react"; import { parseWeappMsgStr } from "@/utils/common"; +import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; +import { useWebSocketStore } from "@/store/module/websocket/websocket"; +import { useWeChatStore } from "@/store/module/weChat/weChat"; import styles from "./SmallProgramMessage.module.scss"; +const FILE_MESSAGE_TYPE = "file"; + +interface FileMessageData { + type: string; + title?: string; + fileName?: string; + filename?: string; + url?: string; + isDownloading?: boolean; + fileext?: string; + size?: number | string; + [key: string]: any; +} + +const isJsonLike = (value: string) => { + const trimmed = value.trim(); + return trimmed.startsWith("{") && trimmed.endsWith("}"); +}; + +const extractFileInfoFromXml = (source: string): FileMessageData | null => { + if (typeof source !== "string") { + return null; + } + + const trimmed = source.trim(); + if (!trimmed) { + return null; + } + + try { + if (typeof DOMParser !== "undefined") { + const parser = new DOMParser(); + const doc = parser.parseFromString(trimmed, "text/xml"); + if (doc.getElementsByTagName("parsererror").length === 0) { + const titleNode = doc.getElementsByTagName("title")[0]; + const fileExtNode = doc.getElementsByTagName("fileext")[0]; + const sizeNode = + doc.getElementsByTagName("totallen")[0] || + doc.getElementsByTagName("filesize")[0]; + + const result: FileMessageData = { type: FILE_MESSAGE_TYPE }; + const titleText = titleNode?.textContent?.trim(); + if (titleText) { + result.title = titleText; + } + + const fileExtText = fileExtNode?.textContent?.trim(); + if (fileExtText) { + result.fileext = fileExtText; + } + + const sizeText = sizeNode?.textContent?.trim(); + if (sizeText) { + const sizeNumber = Number(sizeText); + result.size = Number.isNaN(sizeNumber) ? sizeText : sizeNumber; + } + + return result; + } + } + } catch (error) { + console.warn("extractFileInfoFromXml parse failed:", error); + } + + const regexTitle = + trimmed.match(/<!\[CDATA\[(.*?)\]\]><\/title>/i) || + trimmed.match(/<title>([^<]+)<\/title>/i); + const regexExt = + trimmed.match(/<fileext><!\[CDATA\[(.*?)\]\]><\/fileext>/i) || + trimmed.match(/<fileext>([^<]+)<\/fileext>/i); + const regexSize = + trimmed.match(/<totallen>([^<]+)<\/totallen>/i) || + trimmed.match(/<filesize>([^<]+)<\/filesize>/i); + + if (!regexTitle && !regexExt && !regexSize) { + return null; + } + + const fallback: FileMessageData = { type: FILE_MESSAGE_TYPE }; + if (regexTitle?.[1]) { + fallback.title = regexTitle[1].trim(); + } + if (regexExt?.[1]) { + fallback.fileext = regexExt[1].trim(); + } + if (regexSize?.[1]) { + const sizeNumber = Number(regexSize[1]); + fallback.size = Number.isNaN(sizeNumber) ? regexSize[1].trim() : sizeNumber; + } + + return fallback; +}; + +const resolveFileMessageData = ( + messageData: any, + msg: ChatRecord, + rawContent: string, +): FileMessageData | null => { + const meta = + msg?.fileDownloadMeta && typeof msg.fileDownloadMeta === "object" + ? { ...(msg.fileDownloadMeta as Record<string, any>) } + : null; + + if (messageData && typeof messageData === "object") { + if (messageData.type === FILE_MESSAGE_TYPE) { + return { + type: FILE_MESSAGE_TYPE, + ...messageData, + ...(meta || {}), + }; + } + + if (typeof messageData.contentXml === "string") { + const xmlData = extractFileInfoFromXml(messageData.contentXml); + if (xmlData || meta) { + return { + ...(xmlData || {}), + ...(meta || {}), + type: FILE_MESSAGE_TYPE, + }; + } + } + } + + if (typeof rawContent === "string") { + const xmlData = extractFileInfoFromXml(rawContent); + if (xmlData || meta) { + return { + ...(xmlData || {}), + ...(meta || {}), + type: FILE_MESSAGE_TYPE, + }; + } + } + + if (meta) { + return { + type: FILE_MESSAGE_TYPE, + ...meta, + }; + } + + return null; +}; + interface SmallProgramMessageProps { content: string; + msg: ChatRecord; + contract: ContractData | weChatGroup; } const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ content, + msg, + contract, }) => { + const sendCommand = useWebSocketStore(state => state.sendCommand); + const setFileDownloading = useWeChatStore(state => state.setFileDownloading); + // 统一的错误消息渲染函数 const renderErrorMessage = (fallbackText: string) => ( <div className={styles.messageText}>{fallbackText}</div> @@ -20,12 +175,10 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ try { const trimmedContent = content.trim(); + const isJsonContent = isJsonLike(trimmedContent); + const messageData = isJsonContent ? JSON.parse(trimmedContent) : null; - // 尝试解析JSON格式的消息 - if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) { - const messageData = JSON.parse(trimmedContent); - - // 处理文章类型消息 + if (messageData && typeof messageData === "object") { if (messageData.type === "link") { const { title, desc, thumbPath, url } = messageData; @@ -37,10 +190,7 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ className={`${styles.miniProgramCard} ${styles.articleCard}`} onClick={() => window.open(url, "_blank")} > - {/* 标题在第一行 */} <div className={styles.articleTitle}>{title}</div> - - {/* 下方:文字在左,图片在右 */} <div className={styles.articleContent}> <div className={styles.articleTextArea}> {desc && ( @@ -67,7 +217,6 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ ); } - // 处理小程序消息 - 统一使用parseWeappMsgStr解析 if (messageData.type === "miniprogram") { try { const parsedData = parseWeappMsgStr(trimmedContent); @@ -77,16 +226,12 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ const title = appmsg.title || "小程序消息"; const appName = appmsg.sourcedisplayname || appmsg.appname || "小程序"; - - // 获取小程序类型 const miniProgramType = appmsg.weappinfo && appmsg.weappinfo.type ? parseInt(appmsg.weappinfo.type) : 1; - // 根据type类型渲染不同布局 if (miniProgramType === 2) { - // 类型2:图片区域布局 return ( <div className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`} @@ -113,146 +258,158 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ </div> </div> ); - } else { - // 默认类型:横向布局 - return ( - <div - className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`} - > - <div className={styles.miniProgramCard}> - <img - src={parsedData.previewImage} - 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> - ); } + + return ( + <div + className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`} + > + <div className={styles.miniProgramCard}> + <img + src={parsedData.previewImage} + 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> + ); } } catch (parseError) { console.error("parseWeappMsgStr解析失败:", parseError); return renderErrorMessage("[小程序消息 - 解析失败]"); } } + } - //处理文档类型消息 + const rawContentForResolve = + messageData && typeof messageData.contentXml === "string" + ? messageData.contentXml + : trimmedContent; + const fileMessageData = resolveFileMessageData( + messageData, + msg, + rawContentForResolve, + ); - if (messageData.type === "file") { - const { url, title } = messageData; - // 增强的文件消息处理 - const isFileUrl = - url.startsWith("http") || - url.startsWith("https") || - url.startsWith("file://") || - /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(url); + if (fileMessageData && fileMessageData.type === FILE_MESSAGE_TYPE) { + const { + url = "", + title, + fileName, + filename, + fileext, + isDownloading = false, + } = fileMessageData; + const resolvedFileName = + title || + fileName || + filename || + (typeof url === "string" && url + ? url.split("/").pop()?.split("?")[0] + : "") || + "文件"; + const resolvedExtension = ( + fileext || + resolvedFileName.split(".").pop() || + "" + ).toLowerCase(); - if (isFileUrl) { - // 尝试从URL中提取文件名 - const fileName = - title || url.split("/").pop()?.split("?")[0] || "文件"; - const fileExtension = fileName.split(".").pop()?.toLowerCase(); + const iconMap: Record<string, string> = { + pdf: "📕", + doc: "📘", + docx: "📘", + xls: "📗", + xlsx: "📗", + ppt: "📙", + pptx: "📙", + txt: "📝", + zip: "🗜️", + rar: "🗜️", + "7z": "🗜️", + jpg: "🖼️", + jpeg: "🖼️", + png: "🖼️", + gif: "🖼️", + mp4: "🎬", + avi: "🎬", + mov: "🎬", + mp3: "🎵", + wav: "🎵", + flac: "🎵", + }; + const fileIcon = iconMap[resolvedExtension] || "📄"; + const isUrlAvailable = typeof url === "string" && url.trim().length > 0; - // 根据文件类型选择图标 - 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] || "📄"; + const handleFileDownload = () => { + if (isDownloading || !contract || !msg?.id) return; + + setFileDownloading(msg.id, true); + sendCommand("CmdDownloadFile", { + wechatAccountId: contract.wechatAccountId, + friendMessageId: contract.chatroomId ? 0 : msg.id, + chatroomMessageId: contract.chatroomId ? msg.id : 0, + }); + }; + + const actionText = isUrlAvailable + ? "点击查看" + : isDownloading + ? "下载中..." + : "下载"; + const actionDisabled = !isUrlAvailable && isDownloading; + + const handleActionClick = (event: React.MouseEvent) => { + event.stopPropagation(); + if (isUrlAvailable) { + try { + window.open(url, "_blank"); + } catch (e) { + console.error("文件打开失败:", e); } + return; + } + handleFileDownload(); + }; - return ( - <div className={styles.fileMessage}> - <div className={styles.fileCard}> - <div className={styles.fileIcon}>{fileIcon}</div> - <div className={styles.fileInfo}> - <div className={styles.fileName}> - {fileName.length > 20 - ? fileName.substring(0, 20) + "..." - : fileName} - </div> - <div - className={styles.fileAction} - onClick={() => { - try { - window.open(messageData.url, "_blank"); - } catch (e) { - console.error("文件打开失败:", e); - } - }} - > - 点击查看 - </div> - </div> + return ( + <div className={styles.fileMessage}> + <div + className={styles.fileCard} + onClick={() => { + if (isUrlAvailable) { + window.open(url, "_blank"); + } else if (!isDownloading) { + handleFileDownload(); + } + }} + > + <div className={styles.fileIcon}>{fileIcon}</div> + <div className={styles.fileInfo}> + <div className={styles.fileName}> + {resolvedFileName.length > 20 + ? resolvedFileName.substring(0, 20) + "..." + : resolvedFileName} + </div> + <div + className={`${styles.fileAction} ${ + actionDisabled ? styles.fileActionDisabled : "" + }`} + onClick={handleActionClick} + > + {actionText} </div> </div> - ); - } - } - - // 验证传统JSON格式的小程序数据结构 - // if ( - // messageData && - // typeof messageData === "object" && - // (messageData.title || messageData.appName) - // ) { - // return ( - // <div className={styles.miniProgramMessage}> - // <div className={styles.miniProgramCard}> - // {messageData.thumb && ( - // <img - // src={messageData.thumb} - // alt="小程序缩略图" - // className={styles.miniProgramThumb} - // onError={e => { - // const target = e.target as HTMLImageElement; - // target.style.display = "none"; - // }} - // /> - // )} - // <div className={styles.miniProgramInfo}> - // <div className={styles.miniProgramTitle}> - // {messageData.title || "小程序消息"} - // </div> - // {messageData.appName && ( - // <div className={styles.miniProgramApp}> - // {messageData.appName} - // </div> - // )} - // </div> - // </div> - // </div> - // ); - // } + </div> + </div> + ); } return renderErrorMessage("[小程序/文件消息]"); diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx index 715c755b..1da7da52 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { CSSProperties, useEffect, useRef, useState } from "react"; import { Avatar, Checkbox } from "antd"; import { UserOutlined, LoadingOutlined } from "@ant-design/icons"; import AudioMessage from "./components/AudioMessage/AudioMessage"; @@ -17,6 +17,117 @@ import { useCustomerStore } from "@weChatStore/customer"; import { fetchReCallApi, fetchVoiceToTextApi } from "./api"; import TransmitModal from "./components/TransmitModal"; +const IMAGE_EXT_REGEX = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i; +const FILE_EXT_REGEX = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i; +const DEFAULT_IMAGE_STYLE: CSSProperties = { + maxWidth: "200px", + maxHeight: "200px", + borderRadius: "8px", +}; +const EMOJI_IMAGE_STYLE: CSSProperties = { + maxWidth: "120px", + maxHeight: "120px", +}; + +type ImageContentOptions = { + src: string; + alt: string; + fallbackText: string; + style?: CSSProperties; + wrapperClassName?: string; + withBubble?: boolean; + onClick?: () => void; +}; + +const openInNewTab = (url: string) => window.open(url, "_blank"); + +const handleImageError = ( + event: React.SyntheticEvent<HTMLImageElement>, + fallbackText: string, +) => { + const target = event.target as HTMLImageElement; + const parent = target.parentElement; + if (parent) { + parent.innerHTML = `<div class="${styles.messageText}">${fallbackText}</div>`; + } +}; + +const renderImageContent = ({ + src, + alt, + fallbackText, + style = DEFAULT_IMAGE_STYLE, + wrapperClassName = styles.imageMessage, + withBubble = false, + onClick, +}: ImageContentOptions) => { + const imageNode = ( + <div className={wrapperClassName}> + <img + src={src} + alt={alt} + style={style} + onClick={onClick ?? (() => openInNewTab(src))} + onError={event => handleImageError(event, fallbackText)} + /> + </div> + ); + + if (withBubble) { + return <div className={styles.messageBubble}>{imageNode}</div>; + } + + return imageNode; +}; + +const renderEmojiContent = (src: string) => + renderImageContent({ + src, + alt: "表情包", + fallbackText: "[表情包加载失败]", + style: EMOJI_IMAGE_STYLE, + wrapperClassName: styles.emojiMessage, + }); + +const renderFileContent = (url: string) => { + const fileName = url.split("/").pop()?.split("?")[0] || "文件"; + const displayName = + fileName.length > 20 ? `${fileName.substring(0, 20)}...` : fileName; + + return ( + <div className={styles.fileMessage}> + <div className={styles.fileCard}> + <div className={styles.fileIcon}>📄</div> + <div className={styles.fileInfo}> + <div className={styles.fileName}>{displayName}</div> + <div className={styles.fileAction} onClick={() => openInNewTab(url)}> + 点击查看 + </div> + </div> + </div> + </div> + ); +}; + +const isHttpUrl = (value: string) => /^https?:\/\//i.test(value); +const isHttpImageUrl = (value: string) => + isHttpUrl(value) && IMAGE_EXT_REGEX.test(value); +const isFileUrl = (value: string) => + isHttpUrl(value) && FILE_EXT_REGEX.test(value); + +const isLegacyEmojiContent = (content: string) => + IMAGE_EXT_REGEX.test(content) || + content.includes("emoji") || + content.includes("sticker"); + +const tryParseContentJson = (content: string): Record<string, any> | null => { + try { + return JSON.parse(content); + } catch (error) { + return null; + } +}; + interface MessageRecordProps { contract: ContractData | weChatGroup; } @@ -122,6 +233,115 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { return parts; }; + const renderUnknownContent = ( + rawContent: string, + trimmedContent: string, + msg?: ChatRecord, + contract?: ContractData | weChatGroup, + ) => { + if (isLegacyEmojiContent(trimmedContent)) { + return renderEmojiContent(rawContent); + } + + const jsonData = tryParseContentJson(trimmedContent); + + if (jsonData && typeof jsonData === "object") { + if (jsonData.type === "file" && msg && contract) { + return ( + <SmallProgramMessage + content={rawContent} + msg={msg} + contract={contract} + /> + ); + } + + if (jsonData.type === "link" && jsonData.title && jsonData.url) { + const { title, desc, thumbPath, url } = jsonData; + + return ( + <div + className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`} + > + <div + className={`${styles.miniProgramCard} ${styles.linkCard}`} + onClick={() => openInNewTab(url)} + > + {thumbPath && ( + <img + src={thumbPath} + alt="链接缩略图" + className={styles.miniProgramThumb} + onError={event => { + const target = event.target as HTMLImageElement; + target.style.display = "none"; + }} + /> + )} + <div className={styles.miniProgramInfo}> + <div className={styles.miniProgramTitle}>{title}</div> + {desc && <div className={styles.linkDescription}>{desc}</div>} + </div> + </div> + <div className={styles.miniProgramApp}>链接</div> + </div> + ); + } + + if (jsonData.previewImage && (jsonData.tencentUrl || jsonData.videoUrl)) { + const previewImageUrl = String(jsonData.previewImage).replace( + /[`"']/g, + "", + ); + return ( + <div className={styles.videoMessage}> + <div className={styles.videoContainer}> + <img + src={previewImageUrl} + alt="视频预览" + className={styles.videoPreview} + onClick={() => { + const videoUrl = jsonData.videoUrl || jsonData.tencentUrl; + if (videoUrl) { + openInNewTab(videoUrl); + } + }} + onError={event => { + const target = event.target as HTMLImageElement; + const parent = target.parentElement?.parentElement; + if (parent) { + parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`; + } + }} + /> + <div className={styles.playButton}> + <svg width="24" height="24" viewBox="0 0 24 24" fill="white"> + <path d="M8 5v14l11-7z" /> + </svg> + </div> + </div> + </div> + ); + } + } + + if (isHttpImageUrl(trimmedContent)) { + return renderImageContent({ + src: rawContent, + alt: "图片消息", + fallbackText: "[图片加载失败]", + }); + } + + if (isFileUrl(trimmedContent)) { + return renderFileContent(trimmedContent); + } + + return ( + <div className={styles.messageText}>{parseEmojiText(rawContent)}</div> + ); + }; + useEffect(() => { const prevMessages = prevMessagesRef.current; @@ -190,290 +410,84 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { <div className={styles.messageText}>{fallbackText}</div> ); - // 添加调试信息 - // console.log("MessageRecord - msgType:", msgType, "content:", content); + const isStringValue = typeof content === "string"; + const rawContent = isStringValue ? content : ""; + const trimmedContent = rawContent.trim(); - // 根据msgType进行消息类型判断 switch (msgType) { case 1: // 文本消息 return ( <div className={styles.messageBubble}> - <div className={styles.messageText}>{parseEmojiText(content)}</div> + <div className={styles.messageText}> + {parseEmojiText(rawContent)} + </div> </div> ); case 3: // 图片消息 - // 验证是否为有效的图片URL - if (typeof content !== "string" || !content.trim()) { + if (!isStringValue || !trimmedContent) { return renderErrorMessage("[图片消息 - 无效链接]"); } - return ( - <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> - ); + return renderImageContent({ + src: rawContent, + alt: "图片消息", + fallbackText: "[图片加载失败]", + withBubble: true, + }); case 34: // 语音消息 - if (typeof content !== "string" || !content.trim()) { + if (!isStringValue || !trimmedContent) { return renderErrorMessage("[语音消息 - 无效内容]"); } - // content直接是音频URL字符串 - return <AudioMessage audioUrl={content} msgId={String(msg.id)} />; + return <AudioMessage audioUrl={rawContent} msgId={String(msg.id)} />; case 43: // 视频消息 return ( - <VideoMessage content={content || ""} msg={msg} contract={contract} /> + <VideoMessage + content={isStringValue ? rawContent : ""} + msg={msg} + contract={contract} + /> ); case 47: // 动图表情包(gif、其他表情包) - if (typeof content !== "string" || !content.trim()) { + if (!isStringValue || !trimmedContent) { return renderErrorMessage("[表情包 - 无效链接]"); } - // 使用工具函数判断表情包URL - if (isEmojiUrl(content)) { - return ( - <div className={styles.emojiMessage}> - <img - src={content} - alt="表情包" - style={{ maxWidth: "120px", maxHeight: "120px" }} - 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> - ); + if (isEmojiUrl(trimmedContent)) { + return renderEmojiContent(rawContent); } return renderErrorMessage("[表情包]"); case 48: // 定位消息 - return <LocationMessage content={content || ""} />; + return <LocationMessage content={isStringValue ? rawContent : ""} />; case 49: // 小程序/文章/其他:图文、文件 - return <SmallProgramMessage content={content || ""} />; + return ( + <SmallProgramMessage + content={isStringValue ? rawContent : ""} + msg={msg} + contract={contract} + /> + ); case 10002: // 系统推荐备注消息 - return <SystemRecommendRemarkMessage content={content || ""} />; + return ( + <SystemRecommendRemarkMessage + content={isStringValue ? rawContent : ""} + /> + ); default: { - // 兼容旧版本和未知消息类型的处理逻辑 - if (typeof content !== "string" || !content.trim()) { + if (!isStringValue || !trimmedContent) { 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={contentStr} - alt="表情包" - style={{ maxWidth: "120px", maxHeight: "120px" }} - 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> - ); - } - - // 2. 检查是否为JSON格式消息(包括视频、链接等) - if (contentStr.startsWith("{") && contentStr.endsWith("}")) { - try { - const jsonData = JSON.parse(contentStr); - - // 检查是否为链接类型消息 - if (jsonData.type === "link" && jsonData.title && jsonData.url) { - const { title, desc, thumbPath, url } = jsonData; - - return ( - <div - className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`} - > - <div - className={`${styles.miniProgramCard} ${styles.linkCard}`} - onClick={() => window.open(url, "_blank")} - > - {thumbPath && ( - <img - src={thumbPath} - 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> - {desc && ( - <div className={styles.linkDescription}>{desc}</div> - )} - </div> - </div> - <div className={styles.miniProgramApp}>链接</div> - </div> - ); - } - - // 检查是否为视频消息(兼容旧逻辑) - if ( - jsonData && - typeof jsonData === "object" && - jsonData.previewImage && - (jsonData.tencentUrl || jsonData.videoUrl) - ) { - const previewImageUrl = String(jsonData.previewImage).replace( - /[`"']/g, - "", - ); - return ( - <div className={styles.videoMessage}> - <div className={styles.videoContainer}> - <img - src={previewImageUrl} - alt="视频预览" - className={styles.videoPreview} - onClick={() => { - const videoUrl = - jsonData.videoUrl || jsonData.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>`; - } - }} - /> - <div className={styles.playButton}> - <svg - width="24" - height="24" - viewBox="0 0 24 24" - fill="white" - > - <path d="M8 5v14l11-7z" /> - </svg> - </div> - </div> - </div> - ); - } - } catch (e) { - console.warn("兼容模式JSON解析失败:", e); - } - } - - // 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}>{parseEmojiText(content)}</div> - ); + return renderUnknownContent(rawContent, trimmedContent, msg, contract); } } }; diff --git a/Touchkebao/src/store/module/weChat/weChat.data.ts b/Touchkebao/src/store/module/weChat/weChat.data.ts index 00bc2c51..d23dfdf8 100644 --- a/Touchkebao/src/store/module/weChat/weChat.data.ts +++ b/Touchkebao/src/store/module/weChat/weChat.data.ts @@ -97,6 +97,11 @@ export interface WeChatState { setVideoLoading: (messageId: number, isLoading: boolean) => void; /** 设置视频消息URL */ setVideoUrl: (messageId: number, videoUrl: string) => void; + // ==================== 文件消息处理 ==================== + /** 设置文件消息下载状态 */ + setFileDownloading: (messageId: number, isDownloading: boolean) => void; + /** 设置文件消息URL */ + setFileDownloadUrl: (messageId: number, fileUrl: string) => void; // ==================== 消息接收处理 ==================== /** 接收新消息处理 */ diff --git a/Touchkebao/src/store/module/weChat/weChat.ts b/Touchkebao/src/store/module/weChat/weChat.ts index d5299f47..f161bac0 100644 --- a/Touchkebao/src/store/module/weChat/weChat.ts +++ b/Touchkebao/src/store/module/weChat/weChat.ts @@ -27,6 +27,169 @@ let aiRequestTimer: NodeJS.Timeout | null = null; let pendingMessages: ChatRecord[] = []; // 待处理的消息队列 let currentAiGenerationId: string | null = null; // 当前AI生成的唯一ID const AI_REQUEST_DELAY = 3000; // 3秒延迟 +const FILE_MESSAGE_TYPE = "file"; + +type FileMessagePayload = { + type?: string; + title?: string; + url?: string; + isDownloading?: boolean; + fileext?: string; + size?: number | string; + [key: string]: any; +}; + +const isJsonLike = (value: string) => { + const trimmed = value.trim(); + return trimmed.startsWith("{") && trimmed.endsWith("}"); +}; + +const parseFileJsonContent = ( + rawContent: unknown, +): FileMessagePayload | null => { + if (typeof rawContent !== "string") { + return null; + } + + const trimmed = rawContent.trim(); + if (!trimmed || !isJsonLike(trimmed)) { + return null; + } + + try { + const parsed = JSON.parse(trimmed); + if ( + parsed && + typeof parsed === "object" && + parsed.type === FILE_MESSAGE_TYPE + ) { + return parsed as FileMessagePayload; + } + } catch (error) { + console.warn("parseFileJsonContent failed:", error); + } + + return null; +}; + +const extractFileTitleFromContent = (rawContent: unknown): string => { + if (typeof rawContent !== "string") { + return ""; + } + + const trimmed = rawContent.trim(); + if (!trimmed) { + return ""; + } + + const cdataMatch = + trimmed.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/i) || + trimmed.match(/"title"\s*:\s*"([^"]+)"/i); + if (cdataMatch?.[1]) { + return cdataMatch[1].trim(); + } + + const simpleMatch = trimmed.match(/<title>([^<]+)<\/title>/i); + if (simpleMatch?.[1]) { + return simpleMatch[1].trim(); + } + + return ""; +}; + +const isFileLikeMessage = (msg: ChatRecord): boolean => { + if ((msg as any).fileDownloadMeta) { + return true; + } + + if (typeof msg.content === "string") { + const trimmed = msg.content.trim(); + if (!trimmed) { + return false; + } + + if ( + /"type"\s*:\s*"file"/i.test(trimmed) || + /<appattach/i.test(trimmed) || + /<fileext/i.test(trimmed) + ) { + return true; + } + } + + return false; +}; + +const normalizeFilePayload = ( + payload: FileMessagePayload | null | undefined, + msg: ChatRecord, +): FileMessagePayload => { + const fallbackTitle = + payload?.title || + ((msg as any).fileDownloadMeta && + typeof (msg as any).fileDownloadMeta === "object" + ? ((msg as any).fileDownloadMeta as FileMessagePayload).title + : undefined) || + extractFileTitleFromContent(msg.content) || + (msg as any).fileName || + (msg as any).title || + ""; + + return { + type: FILE_MESSAGE_TYPE, + ...payload, + title: payload?.title ?? fallbackTitle ?? "", + isDownloading: payload?.isDownloading ?? false, + }; +}; + +const updateFileMessageState = ( + msg: ChatRecord, + updater: (payload: FileMessagePayload) => FileMessagePayload, +): ChatRecord => { + const parsedPayload = parseFileJsonContent(msg.content); + + if (!parsedPayload && !isFileLikeMessage(msg)) { + return msg; + } + + const basePayload = parsedPayload + ? normalizeFilePayload(parsedPayload, msg) + : normalizeFilePayload( + (msg as any).fileDownloadMeta as FileMessagePayload | undefined, + msg, + ); + + const updatedPayload = updater(basePayload); + const sanitizedPayload: FileMessagePayload = { + ...basePayload, + ...updatedPayload, + type: FILE_MESSAGE_TYPE, + title: + updatedPayload.title ?? + basePayload.title ?? + extractFileTitleFromContent(msg.content) ?? + "", + isDownloading: + updatedPayload.isDownloading ?? basePayload.isDownloading ?? false, + }; + + if (parsedPayload) { + return { + ...msg, + content: JSON.stringify({ + ...parsedPayload, + ...sanitizedPayload, + }), + fileDownloadMeta: sanitizedPayload, + }; + } + + return { + ...msg, + fileDownloadMeta: sanitizedPayload, + }; +}; /** * 清除AI请求定时器和队列 @@ -618,6 +781,50 @@ export const useWeChatStore = create<WeChatState>()( })); }, + // ==================== 文件消息处理方法 ==================== + /** 更新文件消息下载状态 */ + setFileDownloading: (messageId: number, isDownloading: boolean) => { + set(state => ({ + currentMessages: state.currentMessages.map(msg => { + if (msg.id !== messageId) { + return msg; + } + + try { + return updateFileMessageState(msg, payload => ({ + ...payload, + isDownloading, + })); + } catch (error) { + console.error("更新文件下载状态失败:", error); + return msg; + } + }), + })); + }, + + /** 更新文件消息URL */ + setFileDownloadUrl: (messageId: number, fileUrl: string) => { + set(state => ({ + currentMessages: state.currentMessages.map(msg => { + if (msg.id !== messageId) { + return msg; + } + + try { + return updateFileMessageState(msg, payload => ({ + ...payload, + url: fileUrl, + isDownloading: false, + })); + } catch (error) { + console.error("更新文件URL失败:", error); + return msg; + } + }), + })); + }, + // ==================== 数据清理方法 ==================== /** 清空所有数据 */ clearAllData: () => { diff --git a/Touchkebao/src/store/module/websocket/msgManage.ts b/Touchkebao/src/store/module/websocket/msgManage.ts index 82058dc9..2fcaceb3 100644 --- a/Touchkebao/src/store/module/websocket/msgManage.ts +++ b/Touchkebao/src/store/module/websocket/msgManage.ts @@ -17,6 +17,8 @@ const updateMessage = useWeChatStore.getState().updateMessage; const updateMomentCommonLoading = useWeChatStore.getState().updateMomentCommonLoading; const addMomentCommon = useWeChatStore.getState().addMomentCommon; +const setFileDownloadUrl = useWeChatStore.getState().setFileDownloadUrl; +const setFileDownloading = useWeChatStore.getState().setFileDownloading; // 消息处理器映射 const messageHandlers: Record<string, MessageHandler> = { // 微信账号存活状态响应 @@ -104,6 +106,22 @@ const messageHandlers: Record<string, MessageHandler> = { console.log("视频下载结果:", message); // setVideoUrl(message.friendMessageId, message.url); }, + CmdDownloadFileResult: message => { + const messageId = message.friendMessageId || message.chatroomMessageId; + + if (!messageId) { + console.warn("文件下载结果缺少消息ID:", message); + return; + } + + if (!message.url) { + console.warn("文件下载结果缺少URL:", message); + setFileDownloading(messageId, false); + return; + } + + setFileDownloadUrl(messageId, message.url); + }, CmdFetchMomentResult: message => { addMomentCommon(message.result);