From 1d00f02606757a7f98c4ffa16be398e5e80f2589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=98=E9=A3=8E?= Date: Mon, 19 Jan 2026 16:16:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E8=A7=A3=E6=9E=90=E9=80=BB=E8=BE=91=EF=BC=9A?= =?UTF-8?q?=E6=96=B0=E5=A2=9EextractMiniProgramInfo=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E4=BB=A5=E4=BB=8EXML=E4=B8=AD=E6=8F=90=E5=8F=96=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=85=B3=E9=94=AE=E4=BF=A1=E6=81=AF=EF=BC=8C?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E6=B6=88=E6=81=AF=E6=B8=B2=E6=9F=93=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=EF=BC=8C=E6=8F=90=E5=8D=87=E4=BB=A3=E7=A0=81=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB=E6=80=A7=E5=92=8C=E7=BB=B4=E6=8A=A4=E6=80=A7=E3=80=82?= =?UTF-8?q?=E5=90=8C=E6=97=B6=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=86=97=E4=BD=99?= =?UTF-8?q?=E7=9A=84JSON=E8=A7=A3=E6=9E=90=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E7=9B=B4=E6=8E=A5=E5=A4=84=E7=90=86XML?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E4=B8=B2=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/msgType49拆分-真实案例测试.md | 159 ++++++++ src/hooks/weChat/useMessageTypeParser.tsx | 1 - .../components/SmallProgramMessage/index.tsx | 363 ++++++++++-------- 3 files changed, 368 insertions(+), 155 deletions(-) create mode 100644 docs/msgType49拆分-真实案例测试.md diff --git a/docs/msgType49拆分-真实案例测试.md b/docs/msgType49拆分-真实案例测试.md new file mode 100644 index 0000000..a517765 --- /dev/null +++ b/docs/msgType49拆分-真实案例测试.md @@ -0,0 +1,159 @@ +# msgType=49 小程序消息真实案例测试 + +## 测试案例 + +### 案例 1:八达通充值小程序(Type 2) + +#### 原始数据 +``` +[该消息内容过长已截断]{"contentXml":"\n\n\t\n\t\t八达通充值 Octopus Reloading\n\t\t八达通充值 Octopus Reloading\n\t\t\n\t\t\t2\n\t\t\t\n\t\t\n\t\n","type":"miniprogram"} +``` + +#### 关键信息提取 + +| 字段 | XML 标签 | 提取值 | 说明 | +|------|----------|--------|------| +| 小程序标题 | `` | 八达通充值 Octopus Reloading | 显示在卡片上的标题 | +| 小程序名称 | `<sourcedisplayname>` | 八达通充值 Octopus Reloading | 显示在卡片底部 | +| 小程序类型 | `<weappinfo><type>` | 2 | Type 2 样式(大图展示) | +| 封面图 | `<weappiconurl>` | http://wx.qlogo.cn/mmhead/K6CEv0Hv9Dd1oxclTbYft9ddwMMXMWbiaetYd5WXtiaBtRQW7JN8e2nZkKwF8pDXNianpxOYnDE1Fs/96 | 小程序图标 | + +#### 处理流程 + +1. **前缀处理** + ```typescript + // 检测并去掉 [该消息内容过长已截断] 前缀 + const truncatedPrefix = "[该消息内容过长已截断]"; + if (trimmedContent.startsWith(truncatedPrefix)) { + trimmedContent = trimmedContent.substring(truncatedPrefix.length); + } + ``` + +2. **JSON 解析** + ```typescript + const messageData = JSON.parse(trimmedContent); + // messageData = { contentXml: "<?xml version...", type: "miniprogram" } + ``` + +3. **完整解析尝试** + ```typescript + try { + // 尝试使用 parseWeappMsgStr 完整解析 + const parsedData = parseWeappMsgStr(trimmedContent); + } catch (parseError) { + // 如果失败(例如缺少必要字段),使用 fallback + } + ``` + +4. **Fallback 提取** + ```typescript + // 使用正则表达式从 XML 中提取关键信息 + const fallbackInfo = extractMiniProgramInfoFromTruncatedXml(messageData.contentXml); + + // 提取结果: + // { + // title: "八达通充值 Octopus Reloading", + // appName: "八达通充值 Octopus Reloading", + // miniProgramType: 2, + // previewImage: "http://wx.qlogo.cn/mmhead/..." + // } + ``` + +5. **渲染小程序卡片** + ```tsx + // 根据 miniProgramType = 2,渲染 Type 2 样式 + <div className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}> + <div className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}> + <div className={styles.miniProgramAppTop}>八达通充值 Octopus Reloading</div> + <div className={styles.miniProgramTitle}>八达通充值 Octopus Reloading</div> + <div className={styles.miniProgramImageArea}> + <img src="http://wx.qlogo.cn/mmhead/..." alt="小程序图片" /> + </div> + <div className={styles.miniProgramContent}> + <div className={styles.miniProgramIdentifier}>小程序</div> + </div> + </div> + </div> + ``` + +## 正则表达式说明 + +### 1. 提取 title +```javascript +/<title><!\[CDATA\[(.*?)\]\]><\/title>/i // 支持 CDATA 格式 +/<title>([^<]+)<\/title>/i // 支持普通格式 +``` + +**示例匹配:** +- `<title>八达通充值 Octopus Reloading` ✅ +- `<![CDATA[八达通充值]]>` ✅ +- `` ❌ 过滤空标签 + +### 2. 提取 sourcedisplayname +```javascript +/<sourcedisplayname><!\[CDATA\[(.*?)\]\]><\/sourcedisplayname>/i +/<sourcedisplayname>([^<]+)<\/sourcedisplayname>/i +``` + +**示例匹配:** +- `<sourcedisplayname>八达通充值 Octopus Reloading</sourcedisplayname>` ✅ + +### 3. 提取 weappinfo.type +```javascript +/<weappinfo>[\s\S]*?<type><!\[CDATA\[(.*?)\]\]><\/type>/i +/<weappinfo>[\s\S]*?<type>([^<]+)<\/type>/i +``` + +**示例匹配:** +- `<weappinfo>...<type>2</type>...</weappinfo>` ✅ +- `[\s\S]*?` 匹配任意字符(包括换行) + +### 4. 提取 weappiconurl +```javascript +/<weappiconurl><!\[CDATA\[(.*?)\]\]><\/weappiconurl>/i +/<weappiconurl>([^<]+)<\/weappiconurl>/i +``` + +**示例匹配:** +- `<weappiconurl><![CDATA[http://wx.qlogo.cn/...]]></weappiconurl>` ✅ +- 自动清理 CDATA 标记、引号和 `&` + +## 调试日志 + +当消息被正确处理时,控制台会输出: + +``` +✅ 提取到小程序标题: 八达通充值 Octopus Reloading +✅ 提取到小程序名称 (sourcedisplayname): 八达通充值 Octopus Reloading +✅ 提取到小程序类型: 2 +✅ 提取到小程序封面: http://wx.qlogo.cn/mmhead/K6CEv0Hv9Dd1oxclTbYft9ddwMMXMWbiaetYd5WXtiaBtRQW7JN8e2nZkKwF8pDXNianpxOYnDE1Fs/96 +✅ XML 信息提取成功: {title: "八达通充值 Octopus Reloading", appName: "八达通充值 Octopus Reloading", miniProgramType: 2, previewImage: "http://..."} +``` + +## 容错能力 + +### ✅ 支持的情况 +1. **完整 XML** - 使用 `parseWeappMsgStr` 完整解析 +2. **残缺 XML** - 使用正则提取关键字段 +3. **带前缀** - 自动去除 `[该消息内容过长已截断]` +4. **CDATA 格式** - 支持 `<![CDATA[...]]>` 包裹的内容 +5. **空标签** - 自动过滤 `<title />` 等空标签 +6. **转义字符** - 自动处理 `&` 等 HTML 实体 + +### ❌ 不支持的情况 +1. **关键字段缺失** - 如果 `title` 和 `sourcedisplayname` 都不存在,返回 null +2. **无效 URL** - 如果 `weappiconurl` 不包含 "http",不提取 +3. **非小程序消息** - 如果没有 `<weappinfo>` 标签,可能无法识别类型 + +## 文件位置 + +- **组件文件**: `src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx` +- **提取函数**: `extractMiniProgramInfoFromTruncatedXml`(第 11-117 行) +- **调用位置**: + - `contentXml` 检测路径(第 194 行) + - `type === "miniprogram"` 兼容路径(第 346 行) + +## 相关文档 + +- [消息类型配置指南](./消息类型配置指南.md) +- [msgType49拆分-文章vs小程序](./msgType49拆分-文章vs小程序.md) diff --git a/src/hooks/weChat/useMessageTypeParser.tsx b/src/hooks/weChat/useMessageTypeParser.tsx index e741970..c5d75fc 100644 --- a/src/hooks/weChat/useMessageTypeParser.tsx +++ b/src/hooks/weChat/useMessageTypeParser.tsx @@ -115,7 +115,6 @@ export const useMessageTypeParser = (contract: ContractData | weChatGroup) => { // 1. 如果有明确的 msgType,优先查找配置 if (msgType !== undefined && MESSAGE_TYPE_MAP[msgType]) { const config = MESSAGE_TYPE_MAP[msgType]; - console.log(`✅ 使用 msgType=${msgType} (${config.type})`); return config.nodeFunc(nodeProps); } diff --git a/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx b/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx index 1fab363..d81a179 100644 --- a/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx +++ b/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx @@ -1,11 +1,144 @@ import React from "react"; -import { parseWeappMsgStr } from "@/utils/common"; import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; import styles from "./SmallProgramMessage.module.scss"; -const isJsonLike = (value: string) => { - const trimmed = value.trim(); - return trimmed.startsWith("{") && trimmed.endsWith("}"); +/** + * 从截断的 XML 中提取小程序关键信息 + * 重点匹配特征: + * 1. title标签:<title>八达通充值 Octopus Reloading + * 2. 封面链接: + */ +const extractMiniProgramInfo = (xmlContent: string): { + title?: string; + appName?: string; + miniProgramType?: number; + previewImage?: string; +} | null => { + + if (!xmlContent || typeof xmlContent !== "string") { + return null; + } + + try { + const result: { + title?: string; + appName?: string; + miniProgramType?: number; + previewImage?: string; + } = {}; + + // ⭐ 提取 title(重点特征匹配,支持截断的XML) + // 匹配模式:内容内容(截断) + const titleMatch = + // 完整格式:<title>内容 + xmlContent.match(/([^<]+)<\/title>/i) || + // CDATA格式:<title><![CDATA[内容]]> + xmlContent.match(/<!\[CDATA\[([^\]]+)\]\]><\/title>/i) || + // 截断格式:<title>内容(后面可能没有闭合标签) + xmlContent.match(/<title>([^<\n\r]+?)(?:\s*<|$)/i); + + + if (titleMatch?.[1]) { + const title = titleMatch[1].trim(); + // 过滤空值和无效值 + if (title && title !== "/" && title.length > 0) { + result.title = title; + } + } + + // 提取 sourcedisplayname(小程序显示名称,支持截断) + const sourcedisplaynameMatch = + xmlContent.match(/<sourcedisplayname>([^<]+)<\/sourcedisplayname>/i) || + xmlContent.match(/<sourcedisplayname><!\[CDATA\[([^\]]+)\]\]><\/sourcedisplayname>/i) || + xmlContent.match(/<sourcedisplayname>([^<\n\r]+?)(?:\s*<|$)/i); + + if (sourcedisplaynameMatch?.[1]) { + const appName = sourcedisplaynameMatch[1].trim(); + if (appName && appName !== "/" && appName.length > 0) { + result.appName = appName; + } + } + + // 提取 appname(备用,支持截断) + if (!result.appName) { + const appnameMatch = + xmlContent.match(/<appname>([^<]+)<\/appname>/i) || + xmlContent.match(/<appname><!\[CDATA\[([^\]]+)\]\]><\/appname>/i) || + xmlContent.match(/<appname>([^<\n\r]+?)(?:\s*<|$)/i); + + if (appnameMatch?.[1]) { + const appName = appnameMatch[1].trim(); + if (appName && appName !== "/" && appName.length > 0) { + result.appName = appName; + } + } + } + + // 提取 weappinfo.type(小程序类型:1 或 2) + const weappinfoTypeMatch = + xmlContent.match(/<weappinfo>[\s\S]*?<type>([^<]+)<\/type>/i) || + xmlContent.match(/<weappinfo>[\s\S]*?<type><!\[CDATA\[([^\]]+)\]\]><\/type>/i) || + xmlContent.match(/<type>([^<\n\r]+?)(?:\s*<|$)/i); + + if (weappinfoTypeMatch?.[1]) { + const typeNum = parseInt(weappinfoTypeMatch[1].trim()); + if (!Number.isNaN(typeNum) && (typeNum === 1 || typeNum === 2)) { + result.miniProgramType = typeNum; + } + } + + // ⭐ 提取封面链接(重点特征匹配:http://wx.qlogo.cn/mmhead/) + // 优先匹配完整的 weappiconurl 标签 + let previewImageUrl: string | undefined; + + const weappiconurlMatch = + xmlContent.match(/<weappiconurl><!\[CDATA\[([^\]]+)\]\]><\/weappiconurl>/i) || + xmlContent.match(/<weappiconurl>([^<]+)<\/weappiconurl>/i); + + if (weappiconurlMatch?.[1]) { + previewImageUrl = weappiconurlMatch[1].trim(); + } else { + // ⭐ 如果标签不完整,直接搜索包含特征字符的CDATA块 + // 匹配模式:<![CDATA[http://wx.qlogo.cn/mmhead/...]]> + const cdataMatch = xmlContent.match( + /<!\[CDATA\[(https?:\/\/wx\.qlogo\.cn\/mmhead\/[^\]]+)\]\]>/i + ); + if (cdataMatch?.[1]) { + previewImageUrl = cdataMatch[1]; + } else { + // 更宽松的匹配:直接搜索包含特征字符的URL + const urlMatch = xmlContent.match( + /(https?:\/\/wx\.qlogo\.cn\/mmhead\/[^\s<"']+)/i + ); + if (urlMatch?.[1]) { + previewImageUrl = urlMatch[1]; + } + } + } + + if (previewImageUrl) { + // 清理URL + let url = previewImageUrl + .replace(/<!\[CDATA\[|\]\]>/g, "") + .replace(/[`"']/g, "") + .replace(/&/g, "&") + .trim(); + + if (url && url.includes("http")) { + result.previewImage = url; + } + } + + // 如果至少提取到了 title 或 appName,认为提取成功 + if (result.title || result.appName) { + return result; + } + + return null; + } catch (error) { + console.warn("从 XML 提取信息失败:", error); + return null; + } }; interface SmallProgramMessageProps { @@ -14,12 +147,15 @@ interface SmallProgramMessageProps { contract: ContractData | weChatGroup; } +/** + * 小程序消息渲染组件 + * 处理格式:[该消息内容过长已截断]<?xml version="1.0"?>... + */ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ content, msg, contract, }) => { - // 统一的错误消息渲染函数 const renderErrorMessage = (fallbackText: string) => ( <div className={styles.messageText}>{fallbackText}</div> ); @@ -29,162 +165,81 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ } try { - // ⭐ 去掉 [该消息内容过长已截断] 前缀 + // 去掉 [该消息内容过长已截断] 前缀 let trimmedContent = content.trim(); const truncatedPrefix = "[该消息内容过长已截断]"; if (trimmedContent.startsWith(truncatedPrefix)) { - trimmedContent = trimmedContent.substring(truncatedPrefix.length); + trimmedContent = trimmedContent.substring(truncatedPrefix.length).trim(); } - const isJsonContent = isJsonLike(trimmedContent); - const messageData = isJsonContent ? JSON.parse(trimmedContent) : null; - - if (messageData && typeof messageData === "object") { - // ⭐ 检测小程序消息(通过 contentXml 字段) - if (messageData.contentXml && typeof messageData.contentXml === "string") { - try { - const parsedData = parseWeappMsgStr(trimmedContent); - - if (parsedData.appmsg) { - const { appmsg } = parsedData; - const title = appmsg.title || "小程序消息"; - const appName = - appmsg.sourcedisplayname || appmsg.appname || "小程序"; - const miniProgramType = - appmsg.weappinfo && appmsg.weappinfo.type - ? parseInt(appmsg.weappinfo.type) - : 1; - - if (miniProgramType === 2) { - return ( - <div - className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`} - > - <div - className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`} - > - <div className={styles.miniProgramAppTop}>{appName}</div> - <div className={styles.miniProgramTitle}>{title}</div> - <div className={styles.miniProgramImageArea}> - <img - src={parsedData.previewImage} - alt="小程序图片" - className={styles.miniProgramImage} - onError={e => { - const target = e.target as HTMLImageElement; - target.style.display = "none"; - }} - /> - </div> - <div className={styles.miniProgramContent}> - <div className={styles.miniProgramIdentifier}>小程序</div> - </div> - </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("小程序消息解析失败 (contentXml):", parseError); - } - } - - // ⭐ 兼容旧格式:type === "miniprogram" - if (messageData.type === "miniprogram") { - try { - const parsedData = parseWeappMsgStr(trimmedContent); - - if (parsedData.appmsg) { - const { appmsg } = parsedData; - const title = appmsg.title || "小程序消息"; - const appName = - appmsg.sourcedisplayname || appmsg.appname || "小程序"; - const miniProgramType = - appmsg.weappinfo && appmsg.weappinfo.type - ? parseInt(appmsg.weappinfo.type) - : 1; - - if (miniProgramType === 2) { - return ( - <div - className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`} - > - <div - className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`} - > - <div className={styles.miniProgramAppTop}>{appName}</div> - <div className={styles.miniProgramTitle}>{title}</div> - <div className={styles.miniProgramImageArea}> - <img - src={parsedData.previewImage} - alt="小程序图片" - className={styles.miniProgramImage} - onError={e => { - const target = e.target as HTMLImageElement; - target.style.display = "none"; - }} - /> - </div> - <div className={styles.miniProgramContent}> - <div className={styles.miniProgramIdentifier}>小程序</div> - </div> - </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("[小程序消息 - 解析失败]"); - } - } + // trimmedContent 直接就是 XML 字符串,不需要 JSON 解析 + // 从 XML 中提取信息 + const info = extractMiniProgramInfo(trimmedContent); + if (!info) { + return renderErrorMessage("[小程序消息 - 信息提取失败]"); } - return renderErrorMessage("[小程序消息]"); + const title = info.title || "小程序消息"; + const appName = info.appName || "小程序"; + const miniProgramType = info.miniProgramType || 1; + const previewImage = info.previewImage || ""; + + // 根据类型渲染不同的 UI + if (miniProgramType === 2) { + // 类型 2:垂直图片布局 + return ( + <div + className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`} + > + <div + className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`} + > + <div className={styles.miniProgramAppTop}>{appName}</div> + {previewImage && ( + <div className={styles.miniProgramImageArea}> + <img + src={previewImage} + alt="小程序图片" + className={styles.miniProgramImage} + onError={e => { + const target = e.target as HTMLImageElement; + target.style.display = "none"; + }} + /> + </div> + )} + <div className={styles.miniProgramContent}> + <div className={styles.miniProgramIdentifier}>小程序</div> + </div> + </div> + </div> + ); + } + + // 类型 1:默认横向布局 + return ( + <div + className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`} + > + <div className={styles.miniProgramCard}> + {previewImage && ( + <img + src={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 (e) { console.warn("小程序消息解析失败:", e); return renderErrorMessage("[小程序消息 - 解析失败]");