diff --git a/src/hooks/weChat/useMessageTypeParser.tsx b/src/hooks/weChat/useMessageTypeParser.tsx index c5d75fc..490cf02 100644 --- a/src/hooks/weChat/useMessageTypeParser.tsx +++ b/src/hooks/weChat/useMessageTypeParser.tsx @@ -9,12 +9,18 @@ import { } from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/messageTypeConfig"; import styles from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/com.module.scss"; +const TRUNCATED_MSG_PREFIX = "[该消息内容过长已截断]"; + /** - * 尝试解析 JSON + * 尝试解析 JSON(去掉服务端截断提示前缀,便于拿到 previewImage / contentXml) */ const tryParseJson = (content: string): any => { + let s = content.trim(); + if (s.startsWith(TRUNCATED_MSG_PREFIX)) { + s = s.slice(TRUNCATED_MSG_PREFIX.length).trim(); + } try { - return JSON.parse(content); + return JSON.parse(s); } catch { return null; } 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 d81a179..9e59358 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 @@ -2,134 +2,173 @@ import React from "react"; import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; import styles from "./SmallProgramMessage.module.scss"; -/** - * 从截断的 XML 中提取小程序关键信息 - * 重点匹配特征: - * 1. title标签:八达通充值 Octopus Reloading - * 2. 封面链接: - */ -const extractMiniProgramInfo = (xmlContent: string): { +const TRUNCATED_PREFIX = "[该消息内容过长已截断]"; + +/** 去掉 wxid_ 行或仅 “:\n” 的前缀,得到纯 XML */ +const normalizeContentXmlString = (raw: string): string => { + let s = raw; + const xmlDecl = s.indexOf(""); + const cut = xmlDecl >= 0 ? xmlDecl : msgOpen >= 0 ? msgOpen : 0; + if (cut > 0) { + s = s.slice(cut); + } + return s.trim(); +}; + +/** 从整段文本中抠出 XML 字符串(兼容截断 JSON、转义字符) */ +const extractXmlStringFromText = (text: string): string => { + const previewKey = '","previewImage"'; + let xmlStart = text.indexOf(""); + if (msgStart >= 0) { + const before = text.lastIndexOf("= 0 ? before : msgStart; + } + } + if (xmlStart < 0) { + return text; + } + let end = text.length; + const pk = text.indexOf(previewKey, xmlStart); + if (pk >= 0) { + end = pk; + } + let frag = text.slice(xmlStart, end); + frag = frag + .replace(/\\n/g, "\n") + .replace(/\\t/g, "\t") + .replace(/\\"/g, '"') + .replace(/\\\\/g, "\\"); + return normalizeContentXmlString(frag); +}; + +/** 从未必完整的 JSON 文本中提取 previewImage */ +const extractPreviewImageFromText = (text: string): string | undefined => { + const m = text.match(/"previewImage"\s*:\s*"([^"]*)"/); + if (m?.[1]) { + return m[1].replace(/\\"/g, '"').trim(); + } + return undefined; +}; + +type MiniExtract = { title?: string; appName?: string; miniProgramType?: number; previewImage?: string; -} | null => { +}; +/** + * 仅从 XML 提取展示用字段(不要求完整闭合) + */ +const extractMiniProgramInfo = (xmlContent: string): MiniExtract | null => { if (!xmlContent || typeof xmlContent !== "string") { return null; } try { - const result: { - title?: string; - appName?: string; - miniProgramType?: number; - previewImage?: string; - } = {}; + const result: MiniExtract = {}; - // ⭐ 提取 title(重点特征匹配,支持截断的XML) - // 匹配模式:内容内容(截断) const titleMatch = - // 完整格式:<title>内容 xmlContent.match(/([^<]+)<\/title>/i) || - // CDATA格式:<title><![CDATA[内容]]> - xmlContent.match(/<!\[CDATA\[([^\]]+)\]\]><\/title>/i) || - // 截断格式:<title>内容(后面可能没有闭合标签) + xmlContent.match(/<title><!\[CDATA\[([^\]]*)\]\]><\/title>/i) || xmlContent.match(/<title>([^<\n\r]+?)(?:\s*<|$)/i); - - if (titleMatch?.[1]) { const title = titleMatch[1].trim(); - // 过滤空值和无效值 - if (title && title !== "/" && title.length > 0) { + if (title && title !== "/") { 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; + const desMatch = + xmlContent.match(/<des>([^<]+)<\/des>/i) || + xmlContent.match(/<des><!\[CDATA\[([^\]]*)\]\]><\/des>/i) || + xmlContent.match(/<des>([^<\n\r]+?)(?:\s*<|$)/i); + let desVal: string | undefined; + if (desMatch?.[1]) { + const des = desMatch[1].trim(); + if (des && des !== "/" && des !== "null") { + desVal = des; + } + } + + const sourcedisplaynameMatch = + xmlContent.match(/<sourcedisplayname>([^<]+)<\/sourcedisplayname>/i) || + xmlContent.match( + /<sourcedisplayname><!\[CDATA\[([^\]]*)\]\]><\/sourcedisplayname>/i, + ) || + xmlContent.match(/<sourcedisplayname>([^<\n\r]+?)(?:\s*<|$)/i); + if (sourcedisplaynameMatch?.[1]) { + const n = sourcedisplaynameMatch[1].trim(); + if (n && n !== "/" && n !== "null") { + result.appName = n; } } - // 提取 appname(备用,支持截断) if (!result.appName) { const appnameMatch = xmlContent.match(/<appname>([^<]+)<\/appname>/i) || - xmlContent.match(/<appname><!\[CDATA\[([^\]]+)\]\]><\/appname>/i) || - xmlContent.match(/<appname>([^<\n\r]+?)(?:\s*<|$)/i); - + xmlContent.match(/<appname><!\[CDATA\[([^\]]*)\]\]><\/appname>/i); if (appnameMatch?.[1]) { - const appName = appnameMatch[1].trim(); - if (appName && appName !== "/" && appName.length > 0) { - result.appName = appName; + const n = appnameMatch[1].trim(); + if (n && n !== "/") { + result.appName = n; } } } - // 提取 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 (!result.appName && desVal) { + result.appName = desVal; + } - if (weappinfoTypeMatch?.[1]) { - const typeNum = parseInt(weappinfoTypeMatch[1].trim()); - if (!Number.isNaN(typeNum) && (typeNum === 1 || typeNum === 2)) { - result.miniProgramType = typeNum; + const weappBlock = + /<weappinfo>[\s\S]*?<\/weappinfo>/i.exec(xmlContent)?.[0] ?? ""; + if (weappBlock) { + const typeInWeapp = weappBlock.match(/<type>([^<]+)<\/type>/i); + if (typeInWeapp?.[1]) { + const typeNum = parseInt(typeInWeapp[1].trim(), 10); + 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]) { + weappBlock.match(/<weappiconurl><!\[CDATA\[([^\]]*)\]\]><\/weappiconurl>/i) || + weappBlock.match(/<weappiconurl>([^<]+)<\/weappiconurl>/i); + if (weappiconurlMatch?.[1]?.trim()) { 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) { + const thumbMatch = + xmlContent.match(/<thumburl><!\[CDATA\[(https?:\/\/[^\]]+)\]\]><\/thumburl>/i) || + xmlContent.match(/<thumburl>(https?:\/\/[^<]+)<\/thumburl>/i); + if (thumbMatch?.[1]) { + previewImageUrl = thumbMatch[1].trim(); } } - if (previewImageUrl) { - // 清理URL - let url = previewImageUrl + if (!previewImageUrl) { + const anyHttp = xmlContent.match( + /(https?:\/\/[^\s<"']*(?:mmbiz|qpic|qlogo|aliyuncs|oss-cn|amazonaws|cloudfront)[^\s<"']*)/i, + ); + if (anyHttp?.[1]) { + previewImageUrl = anyHttp[1].replace(/&/g, "&").trim(); + } + } + + if (previewImageUrl && previewImageUrl.includes("http")) { + result.previewImage = 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; } @@ -145,16 +184,13 @@ interface SmallProgramMessageProps { content: string; msg: ChatRecord; contract: ContractData | weChatGroup; + /** useMessageTypeParser 已解析的 JSON(去掉截断前缀后) */ + parsedJson?: Record<string, unknown> | null; } -/** - * 小程序消息渲染组件 - * 处理格式:[该消息内容过长已截断]<?xml version="1.0"?>... - */ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ content, - msg, - contract, + parsedJson, }) => { const renderErrorMessage = (fallbackText: string) => ( <div className={styles.messageText}>{fallbackText}</div> @@ -165,28 +201,57 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ } try { - // 去掉 [该消息内容过长已截断] 前缀 - let trimmedContent = content.trim(); - const truncatedPrefix = "[该消息内容过长已截断]"; - if (trimmedContent.startsWith(truncatedPrefix)) { - trimmedContent = trimmedContent.substring(truncatedPrefix.length).trim(); + let working = content.trim(); + if (working.startsWith(TRUNCATED_PREFIX)) { + working = working.slice(TRUNCATED_PREFIX.length).trim(); } - // trimmedContent 直接就是 XML 字符串,不需要 JSON 解析 - // 从 XML 中提取信息 - const info = extractMiniProgramInfo(trimmedContent); + let jsonPreview = + typeof parsedJson?.previewImage === "string" + ? parsedJson.previewImage.trim() + : ""; + let xmlRaw = ""; + + if ( + parsedJson && + typeof parsedJson.contentXml === "string" && + parsedJson.contentXml + ) { + xmlRaw = normalizeContentXmlString(parsedJson.contentXml); + } else if (working.startsWith("{")) { + try { + const obj = JSON.parse(working) as { + contentXml?: string; + previewImage?: string; + }; + if (typeof obj.contentXml === "string") { + xmlRaw = normalizeContentXmlString(obj.contentXml); + } + if (typeof obj.previewImage === "string" && obj.previewImage) { + jsonPreview = obj.previewImage.trim(); + } + } catch { + xmlRaw = extractXmlStringFromText(working); + if (!jsonPreview) { + jsonPreview = extractPreviewImageFromText(working) ?? ""; + } + } + } else { + xmlRaw = normalizeContentXmlString(working); + } + + const info = extractMiniProgramInfo(xmlRaw); if (!info) { return renderErrorMessage("[小程序消息 - 信息提取失败]"); } - const title = info.title || "小程序消息"; + const title = info.title || "小程序"; const appName = info.appName || "小程序"; - const miniProgramType = info.miniProgramType || 1; - const previewImage = info.previewImage || ""; + const miniProgramType = info.miniProgramType ?? 1; + const previewImage = + jsonPreview || info.previewImage || ""; - // 根据类型渲染不同的 UI if (miniProgramType === 2) { - // 类型 2:垂直图片布局 return ( <div className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`} @@ -195,11 +260,23 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`} > <div className={styles.miniProgramAppTop}>{appName}</div> - {previewImage && ( + <div + style={{ + padding: "10px 16px 0", + fontWeight: 600, + fontSize: 15, + color: "#191919", + lineHeight: 1.45, + wordBreak: "break-word", + }} + > + {title} + </div> + {previewImage ? ( <div className={styles.miniProgramImageArea}> <img src={previewImage} - alt="小程序图片" + alt="小程序封面" className={styles.miniProgramImage} onError={e => { const target = e.target as HTMLImageElement; @@ -207,7 +284,7 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ }} /> </div> - )} + ) : null} <div className={styles.miniProgramContent}> <div className={styles.miniProgramIdentifier}>小程序</div> </div> @@ -216,13 +293,12 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ ); } - // 类型 1:默认横向布局 return ( <div className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`} > <div className={styles.miniProgramCard}> - {previewImage && ( + {previewImage ? ( <img src={previewImage} alt="小程序缩略图" @@ -232,7 +308,7 @@ const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({ target.style.display = "none"; }} /> - )} + ) : null} <div className={styles.miniProgramInfo}> <div className={styles.miniProgramTitle}>{title}</div> </div> diff --git a/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/MsgType49Renderer.tsx b/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/MsgType49Renderer.tsx index 5a551fd..6435408 100644 --- a/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/MsgType49Renderer.tsx +++ b/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/MsgType49Renderer.tsx @@ -82,9 +82,23 @@ export const renderMsgType49 = (props: MessageTypeNodeProps): React.ReactNode => content.includes("contentXml") || content.startsWith("[该消息内容过长已截断]") ) { - return <SmallProgramMessage content={content} msg={msg} contract={contract} />; + return ( + <SmallProgramMessage + content={content} + msg={msg} + contract={contract} + parsedJson={parsedJson} + /> + ); } // 4. 兜底:使用 SmallProgramMessage 处理(包含其他未识别类型) - return <SmallProgramMessage content={content} msg={msg} contract={contract} />; + return ( + <SmallProgramMessage + content={content} + msg={msg} + contract={contract} + parsedJson={parsedJson} + /> + ); }; diff --git a/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/QuickReplyModal.tsx b/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/QuickReplyModal.tsx index 26bc422..4b1b15b 100644 --- a/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/QuickReplyModal.tsx +++ b/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/components/QuickReplyModal.tsx @@ -1,15 +1,149 @@ import React, { useMemo } from "react"; -import { Modal, Form, Input, Select, Space, Button } from "antd"; +import { Modal, Form, Input, Select, Space, Button, Popover, Image, Typography } from "antd"; import { PictureOutlined, VideoCameraOutlined, LinkOutlined, + QuestionCircleOutlined, } from "@ant-design/icons"; import SimpleFileUpload from "@/components/Upload/SimpleFileUpload"; import MainImgUpload from "@/components/Upload/MainImgUpload"; // 简化版不再使用样式与解析组件 import { AddReplyRequest } from "../api"; +const MINIPROGRAM_NATIVE_ID_TUTORIAL_IMAGES = [ + "http://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/2026/04/07/e3eb9174b7891d3a2edb60b9ac567fe4.png", + "http://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/2026/04/07/79232d47338e5dfa87236e83606c0de1.png", + "http://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/2026/04/07/29076514baf128cfc6157ac1121743e7.jpg", +] as const; + +/** 教程气泡高于常见 Modal(1000 起跳);图片预览再高于气泡,避免点击放大后遮罩层级错乱 */ +const MINIPROGRAM_TUTORIAL_POPOVER_Z = 2050; +const MINIPROGRAM_TUTORIAL_PREVIEW_Z = 3100; + +const MiniProgramGhTutorialPopover = () => ( + <Popover + title={ + <span style={{ fontWeight: 600, fontSize: 14 }}>如何查看小程序原生 id</span> + } + trigger="click" + placement="rightTop" + zIndex={MINIPROGRAM_TUTORIAL_POPOVER_Z} + styles={{ + root: { zIndex: MINIPROGRAM_TUTORIAL_POPOVER_Z, maxWidth: 360 }, + }} + overlayInnerStyle={{ + padding: "10px 12px 12px", + boxShadow: "0 6px 24px rgba(0,0,0,0.12)", + }} + destroyTooltipOnHide + getPopupContainer={() => document.body} + content={ + <div style={{ width: 300, maxWidth: "min(300px, 88vw)" }}> + <Typography.Paragraph + type="secondary" + style={{ marginBottom: 8, fontSize: 11, lineHeight: 1.5 }} + > + 在微信公众平台 / 小程序后台找到「账号原始 ID」(通常形如{" "} + <Typography.Text code style={{ fontSize: 12 }}> + gh_xxxxxxxx + </Typography.Text> + ),复制到输入框即可,无需带{" "} + <Typography.Text code style={{ fontSize: 12 }}> + @app + </Typography.Text> + 。点击图片可放大查看。 + </Typography.Paragraph> + <div + style={{ + maxHeight: "min(42vh, 380px)", + overflowY: "auto", + marginRight: -4, + paddingRight: 4, + }} + > + <Image.PreviewGroup + preview={{ + zIndex: MINIPROGRAM_TUTORIAL_PREVIEW_Z, + getContainer: () => document.body, + }} + > + {MINIPROGRAM_NATIVE_ID_TUTORIAL_IMAGES.map((src, i) => ( + <div key={src}> + <Typography.Text + type="secondary" + style={{ + display: "block", + fontSize: 11, + marginBottom: 6, + letterSpacing: 0.2, + }} + > + 步骤 {i + 1} / {MINIPROGRAM_NATIVE_ID_TUTORIAL_IMAGES.length} + </Typography.Text> + <Image + src={src} + alt={`小程序原生 id 教程 ${i + 1}/${MINIPROGRAM_NATIVE_ID_TUTORIAL_IMAGES.length}`} + width={276} + style={{ + maxWidth: "100%", + height: "auto", + display: "block", + borderRadius: 6, + border: "1px solid rgba(0,0,0,0.06)", + cursor: "zoom-in", + }} + /> + {i < MINIPROGRAM_NATIVE_ID_TUTORIAL_IMAGES.length - 1 ? ( + <div style={{ height: 14 }} /> + ) : null} + </div> + ))} + </Image.PreviewGroup> + </div> + </div> + } + > + <span + role="button" + tabIndex={0} + title="查看填写说明" + style={{ display: "inline-flex", alignItems: "center", marginLeft: 6 }} + onClick={e => { + e.preventDefault(); + e.stopPropagation(); + }} + onKeyDown={e => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + } + }} + > + <QuestionCircleOutlined + style={{ + color: "#8c8c8c", + cursor: "pointer", + fontSize: 15, + padding: 2, + borderRadius: "50%", + transition: "color 0.2s, background-color 0.2s", + }} + onMouseEnter={e => { + const el = e.currentTarget; + el.style.color = "#1677ff"; + el.style.backgroundColor = "rgba(22, 119, 255, 0.08)"; + }} + onMouseLeave={e => { + const el = e.currentTarget; + el.style.color = "#8c8c8c"; + el.style.backgroundColor = "transparent"; + }} + /> + </span> + </Popover> +); + export interface QuickReplyModalProps { open: boolean; mode: "add" | "edit"; @@ -31,6 +165,12 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({ }) => { const [form] = Form.useForm(); + const normalizeGh = (ghValue?: string) => { + if (!ghValue) return ""; + // 用户一般不需要手动填写 @app,后端适配器会自动补全 + return String(ghValue).replace(/@app$/i, ""); + }; + const mergedInitialValues = useMemo(() => { const baseValues = { groupId: defaultGroupId, @@ -38,8 +178,13 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({ ...initialValues, }; - // 如果是编辑模式且是 link 类型,解析 content 中的 JSON - if (initialValues?.msgType && Array.isArray(initialValues.msgType) && initialValues.msgType[0] === "49" && initialValues.content) { + // 如果是编辑模式且是 msgType=49(链接/小程序复合类型),解析 content 中的 JSON + if ( + initialValues?.msgType && + Array.isArray(initialValues.msgType) && + initialValues.msgType[0] === "49" && + initialValues.content + ) { try { // 处理 content 可能是对象或字符串两种情况 let linkData: any; @@ -53,6 +198,22 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({ return baseValues; } + // 小程序:content 通常是 { type: 'miniprogram', title, des, gh, pagepath, previewImage, ... } + if (linkData?.type === "miniprogram" || linkData?.contentXml) { + return { + ...baseValues, + // UI 层使用 50 标识“小程序”,实际提交仍会归一为 msgType=49 + msgType: ["50"], + des: linkData.des || "", + gh: normalizeGh(linkData.gh || linkData.miniProgramId || ""), + pagepath: linkData.pagepath || linkData.pagePath || "", + previewImage: linkData.previewImage || linkData.cover || linkData.thumbPath || "", + // content 字段不参与小程序表单 + content: "", + }; + } + + // 链接:content 通常是 { url, thumbPath, desc } return { ...baseValues, content: linkData.url || "", @@ -201,8 +362,10 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({ form={form} layout="vertical" onFinish={values => { - // 处理 link 类型,将 content、thumbPath、desc 组合成 JSON - let finalValues = { ...values }; + // 处理 link / 小程序:把需要的字段组合成 content JSON + let finalValues: any = { ...values }; + + // 链接:content=URL,thumbPath/desc 组装成 JSON if (selectedMsgType === 49) { const linkData = { url: values.content || "", @@ -212,18 +375,46 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({ finalValues = { ...values, content: JSON.stringify(linkData), + // 链接提交为 msgType=49 + msgType: ["49"], }; // 移除额外的字段 delete finalValues.thumbPath; delete finalValues.desc; } - const normalized = { + // 小程序:用 UI 字段生成 { type:'miniprogram', title, des, gh, pagepath, previewImage } + if (selectedMsgType === 50) { + const miniData = { + type: "miniprogram", + title: values.title || "", + des: values.des || "", + gh: normalizeGh(values.gh), + pagepath: values.pagepath || "", + previewImage: values.previewImage || "", + }; + + finalValues = { + ...values, + // 后端按 msgType=49(复合类型)处理 + msgType: ["49"], + content: JSON.stringify(miniData), + }; + + // 移除 UI 专用字段(后端只关心 content/msgType/title) + delete finalValues.des; + delete finalValues.gh; + delete finalValues.pagepath; + delete finalValues.previewImage; + } + + const normalized: AddReplyRequest = { ...finalValues, msgType: Array.isArray(finalValues.msgType) ? finalValues.msgType : [String(finalValues.msgType)], - } as AddReplyRequest; + }; + onSubmit(normalized); }} initialValues={mergedInitialValues} @@ -263,15 +454,22 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({ <Select.Option value="3">图片</Select.Option> <Select.Option value="43">视频</Select.Option> <Select.Option value="49">链接</Select.Option> + <Select.Option value="50">小程序</Select.Option> </Select> </Form.Item> - <Form.Item - name="content" - label="内容" - rules={[{ required: true, message: "请输入/上传内容" }]} - > - {selectedMsgType === 1 && ( + {selectedMsgType !== 50 && ( + <Form.Item + name="content" + label="内容" + rules={[ + { + required: selectedMsgType !== 50, + message: "请输入/上传内容", + }, + ]} + > + {selectedMsgType === 1 && ( <Input.TextArea rows={4} placeholder="请输入文本内容" @@ -279,8 +477,8 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({ onChange={e => form.setFieldsValue({ content: e.target.value })} onKeyDown={handleKeyPress} /> - )} - {selectedMsgType === 3 && ( + )} + {selectedMsgType === 3 && ( <div style={{ maxWidth: "50%" }}> <MainImgUpload value={form.getFieldValue("content")} @@ -291,8 +489,8 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({ showPreview={true} /> </div> - )} - {selectedMsgType === 43 && ( + )} + {selectedMsgType === 43 && ( <> <div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 8, fontSize: 14, color: "#666" }}> @@ -382,8 +580,8 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({ })()} </div> </> - )} - {selectedMsgType === 49 && ( + )} + {selectedMsgType === 49 && ( <> <Input placeholder="请输入链接地址" @@ -429,8 +627,67 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({ </Form.Item> </div> </> - )} - </Form.Item> + )} + </Form.Item> + )} + + {selectedMsgType === 50 && ( + <> + <Form.Item + name="des" + label="描述" + rules={[{ required: true, message: "请输入小程序描述" }]} + style={{ marginBottom: 12 }} + > + <Input + placeholder="请输入小程序描述" + maxLength={200} + showCount + /> + </Form.Item> + + <Form.Item + name="gh" + label={ + <span> + 小程序原生id(gh_...) + <MiniProgramGhTutorialPopover /> + </span> + } + rules={[{ required: true, message: "请输入小程序原生id(不需要 @app)" }]} + style={{ marginBottom: 12 }} + > + <Input placeholder="例如:gh_5c672bbbc96f" /> + </Form.Item> + + <Form.Item + name="pagepath" + label="小程序页面路径" + rules={[{ required: true, message: "请输入小程序页面路径" }]} + style={{ marginBottom: 12 }} + > + <Input placeholder="例如:pages/index/index.html?uid=1" /> + </Form.Item> + + <Form.Item + name="previewImage" + label="封面图" + rules={[{ required: true, message: "请上传封面图" }]} + style={{ marginBottom: 0 }} + > + <div style={{ maxWidth: 260 }}> + <MainImgUpload + value={form.getFieldValue("previewImage")} + onChange={(url) => { + form.setFieldsValue({ previewImage: url }); + }} + maxSize={5} + showPreview={true} + /> + </div> + </Form.Item> + </> + )} <Form.Item> <Space> diff --git a/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx b/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx index 93251c4..689088f 100644 --- a/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx +++ b/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx @@ -11,6 +11,7 @@ import { Tooltip, Spin, Dropdown, + Avatar, } from "antd"; import { PlusOutlined, @@ -23,6 +24,7 @@ import { SearchOutlined, LinkOutlined, QuestionCircleOutlined, + AppstoreOutlined, } from "@ant-design/icons"; import { QuickWordsItem, @@ -42,6 +44,7 @@ import QuickReplyModal from "./components/QuickReplyModal"; import GroupModal from "./components/GroupModal"; import { useWeChatStore } from "@/store/module/weChat/weChat"; import { useWebSocketStore } from "@/store/module/websocket/websocket"; +import { getCurrentCustomer } from "@/store/module/weChat/customer"; import { ChatRecord } from "@/pages/pc/ckbox/data"; // 消息类型枚举 @@ -63,6 +66,37 @@ export interface QuickWordsProps { onInsert?: (reply: QuickWordsReply) => void; } +/** msgType=49:区分普通链接与小程序 JSON */ +const parseMsg49Content = ( + reply: QuickWordsReply, +): { kind: "miniprogram"; data: Record<string, any> } | { kind: "link"; data: Record<string, any> } => { + let raw: any = reply.content; + if (typeof raw === "string") { + try { + raw = JSON.parse(raw); + } catch { + return { kind: "link", data: { url: reply.content, thumbPath: "", desc: "" } }; + } + } + if (!raw || typeof raw !== "object") { + return { kind: "link", data: { url: "", thumbPath: "", desc: "" } }; + } + if (raw.type === "miniprogram" || raw.contentXml) { + return { kind: "miniprogram", data: raw }; + } + return { + kind: "link", + data: { + url: raw.url || "", + thumbPath: raw.thumbPath || "", + desc: raw.desc || "", + }, + }; +}; + +const isMiniprogramReply = (reply: QuickWordsReply) => + reply.msgType === MessageType.LINK && parseMsg49Content(reply).kind === "miniprogram"; + const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => { const [activeTab, setActiveTab] = useState<QuickWordsType>( QuickWordsType.PERSONAL, @@ -95,19 +129,75 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => { if (!currentContract) return; const messageId = Date.now(); - // 处理 link 类型,转换为文章格式 let content = reply.content; if (reply.msgType === MessageType.LINK) { - // link 类型按照文章消息格式发送 - const linkData = reply.content; + const parsed = parseMsg49Content(reply); + if (parsed.kind === "miniprogram") { + const d = parsed.data; + // 如果 content 已经包含 contentXml,直接用原始字符串,无需重建 + if (d.contentXml) { + content = + typeof reply.content === "string" + ? reply.content + : JSON.stringify(d); + } else { + // 从存储的字段重建 contentXml,格式与 SyncContentJob.php 保持一致 + const wxid = getCurrentCustomer()?.wechatId || ""; + const title = d.title || reply.title || ""; + const des = d.des || ""; + const ghRaw: string = d.gh || d.miniProgramId || ""; + const ghUsername = ghRaw.endsWith("@app") ? ghRaw : `${ghRaw}@app`; + const pagepath = d.pagepath || d.pagePath || ""; + const previewImage = d.previewImage || d.cover || d.thumbPath || ""; - content = JSON.stringify({ - type: "link", - title: reply.title || "文章链接", - desc: linkData.desc || "", - thumbPath: linkData.thumbPath || "", - url: linkData.url || "" - }); + const contentXml = + `${wxid}:\n` + + `<?xml version="1.0"?>\n` + + `<msg>\n` + + `\t<appmsg appid="" sdkver="0">\n` + + `\t\t<title>${title}\n` + + `\t\t${des}\n` + + `\t\t33\n` + + `\t\t0\n` + + `\t\t0\n` + + `\t\t0\n` + + `\t\t${ghUsername}\n` + + `\t\t\n` + + `\t\t\t\n` + + `\t\t\t\n` + + `\t\t\t2\n` + + `\t\t\t50\n` + + `\t\t\t\n` + + `\t\t\t\n` + + `\t\t\t\n` + + `\t\t\t\t0\n` + + `\t\t\t\t\n` + + `\t\t\t\n` + + `\t\t\t\n` + + `\t\t\t\t0\n` + + `\t\t\t\t\n` + + `\t\t\t\n` + + `\t\t\t0\n` + + `\t\t\n` + + `\t\n` + + ``; + + content = JSON.stringify({ + contentXml, + previewImage, + type: "miniprogram", + }); + } + } else { + const linkData = parsed.data; + content = JSON.stringify({ + type: "link", + title: reply.title || "文章链接", + desc: linkData.desc || "", + thumbPath: linkData.thumbPath || "", + url: linkData.url || "", + }); + } } const params = { @@ -201,116 +291,230 @@ const QuickWords: React.FC = ({ onInsert }) => { } } else if (reply.msgType === MessageType.LINK) { try { - const linkData = reply.content ; - previewNode = ( -
- {/* 内容区域 */} -
- {/* 1. 标题 */} -
- 标题: {reply.title} -
- - {/* 2. 封面图 */} - {linkData.thumbPath && ( + const parsed49 = parseMsg49Content(reply); + if (parsed49.kind === "miniprogram") { + const d = parsed49.data; + const cardTitle = + (typeof d.title === "string" && d.title) || reply.title || "小程序"; + const headerName = + (typeof d.des === "string" && d.des.trim()) || "小程序"; + const cover = + d.previewImage || d.cover || d.thumbPath || d.weappiconurl || ""; + previewNode = ( +
+
- 链接封面} /> + + {headerName} +
- )} - - {/* 3. 链接地址 */} +
+ {cardTitle} +
+ {cover ? ( +
+ 封面 +
+ ) : ( +
+ 暂无封面 +
+ )} +
- - - {linkData.url || "未设置链接地址"} - + + 小程序
- - {/* 4. 描述 */} - {linkData.desc && ( +
+ ); + } else { + const linkData = parsed49.data; + previewNode = ( +
+
- 描述: {linkData.desc} + 标题: {reply.title}
- )} + + {linkData.thumbPath && ( +
+ 链接封面 +
+ )} + +
+ + + {linkData.url || "未设置链接地址"} + +
+ + {linkData.desc && ( +
+ 描述: {linkData.desc} +
+ )} +
-
- ); + ); + } } catch { - // 如果解析失败,使用旧格式 previewNode = (
{reply.title}
- {typeof reply.content === 'string' ? reply.content : "链接内容"} + {typeof reply.content === "string" ? reply.content : "链接内容"}
); @@ -347,15 +551,19 @@ const QuickWords: React.FC = ({ onInsert }) => { fetchQuickWords(); }, [fetchQuickWords]); - // 获取消息类型图标 - const getMessageTypeIcon = (msgType: number) => { - switch (msgType) { + const getMessageTypeIcon = (reply: QuickWordsReply) => { + if (isMiniprogramReply(reply)) { + return ; + } + switch (reply.msgType) { case MessageType.TEXT: return ; case MessageType.IMAGE: return ; case MessageType.VIDEO: return ; + case MessageType.LINK: + return ; default: return ; } @@ -429,7 +637,7 @@ const QuickWords: React.FC = ({ onInsert }) => { }} >
- {getMessageTypeIcon(reply.msgType)} + {getMessageTypeIcon(reply)} {reply.title}