素材允许添加小程序

This commit is contained in:
Ghost
2026-04-07 16:05:50 +08:00
parent 0043de4390
commit 7f2769a7bf
5 changed files with 781 additions and 220 deletions

View File

@@ -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;
}

View File

@@ -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标签<title>八达通充值 Octopus Reloading</title>
* 2. 封面链接:<![CDATA[http://wx.qlogo.cn/mmhead/...]]>
*/
const extractMiniProgramInfo = (xmlContent: string): {
const TRUNCATED_PREFIX = "[该消息内容过长已截断]";
/** 去掉 wxid_ 行或仅 “:\n” 的前缀,得到纯 XML */
const normalizeContentXmlString = (raw: string): string => {
let s = raw;
const xmlDecl = s.indexOf("<?xml");
const msgOpen = s.indexOf("<msg>");
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("<?xml");
if (xmlStart < 0) {
const msgStart = text.indexOf("<msg>");
if (msgStart >= 0) {
const before = text.lastIndexOf("<?xml", msgStart);
xmlStart = before >= 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
// 匹配模式:<title>内容</title> 或 <title>内容(截断)
const titleMatch =
// 完整格式:<title>内容</title>
xmlContent.match(/<title>([^<]+)<\/title>/i) ||
// CDATA格式<title><![CDATA[内容]]></title>
xmlContent.match(/<title><!\[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(/&amp;/g, "&").trim();
}
}
if (previewImageUrl && previewImageUrl.includes("http")) {
result.previewImage = previewImageUrl
.replace(/<!\[CDATA\[|\]\]>/g, "")
.replace(/[`"']/g, "")
.replace(/&amp;/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>

View File

@@ -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}
/>
);
};

View File

@@ -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=URLthumbPath/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>
idgh_...
<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>

View File

@@ -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}</title>\n` +
`\t\t<des>${des}</des>\n` +
`\t\t<type>33</type>\n` +
`\t\t<showtype>0</showtype>\n` +
`\t\t<soundtype>0</soundtype>\n` +
`\t\t<contentattr>0</contentattr>\n` +
`\t\t<sourceusername>${ghUsername}</sourceusername>\n` +
`\t\t<weappinfo>\n` +
`\t\t\t<username><![CDATA[${ghUsername}]]></username>\n` +
`\t\t\t<appid><![CDATA[]]></appid>\n` +
`\t\t\t<type>2</type>\n` +
`\t\t\t<version>50</version>\n` +
`\t\t\t<weappiconurl><![CDATA[]]></weappiconurl>\n` +
`\t\t\t<pagepath><![CDATA[${pagepath}]]></pagepath>\n` +
`\t\t\t<pkginfo>\n` +
`\t\t\t\t<type>0</type>\n` +
`\t\t\t\t<md5><![CDATA[]]></md5>\n` +
`\t\t\t</pkginfo>\n` +
`\t\t\t<wadynamicpageinfo>\n` +
`\t\t\t\t<shouldUseDynamicPage>0</shouldUseDynamicPage>\n` +
`\t\t\t\t<cacheKey><![CDATA[]]></cacheKey>\n` +
`\t\t\t</wadynamicpageinfo>\n` +
`\t\t\t<appservicetype>0</appservicetype>\n` +
`\t\t</weappinfo>\n` +
`\t</appmsg>\n` +
`</msg>`;
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<QuickWordsProps> = ({ onInsert }) => {
}
} else if (reply.msgType === MessageType.LINK) {
try {
const linkData = reply.content ;
previewNode = (
<div
style={{
maxWidth: 400,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#fff"
}}
>
{/* 内容区域 */}
<div style={{ padding: "16px" }}>
{/* 1. 标题 */}
<div
style={{
fontWeight: 600,
fontSize: 16,
marginBottom: 12,
color: "#262626",
lineHeight: 1.4
}}
>
{reply.title}
</div>
{/* 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 = (
<div
style={{
maxWidth: 340,
margin: "0 auto",
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#fff",
border: "1px solid #e7e7e7",
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
}}
>
<div style={{ padding: "12px 12px 10px" }}>
<div
style={{
width: "100%",
marginBottom: 12,
borderRadius: 6,
overflow: "hidden",
backgroundColor: "#f5f5f5"
display: "flex",
alignItems: "center",
gap: 8,
minHeight: 28,
}}
>
<img
src={linkData.thumbPath}
alt="链接封面"
<Avatar
size={28}
shape="circle"
style={{
width: "100%",
height: "auto",
maxHeight: 200,
objectFit: "cover",
display: "block"
backgroundColor: "#07c160",
flexShrink: 0,
}}
icon={<AppstoreOutlined style={{ fontSize: 16 }} />}
/>
<span
style={{
fontSize: 14,
color: "#888",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{headerName}
</span>
</div>
)}
{/* 3. 链接地址 */}
<div
style={{
marginTop: 10,
fontWeight: 600,
fontSize: 16,
color: "#191919",
lineHeight: 1.45,
wordBreak: "break-word",
}}
>
{cardTitle}
</div>
{cover ? (
<div
style={{
marginTop: 10,
borderRadius: 4,
overflow: "hidden",
backgroundColor: "#f5f5f5",
maxHeight: 200,
}}
>
<img
src={cover}
alt="封面"
style={{
width: "100%",
height: 200,
objectFit: "cover",
display: "block",
}}
/>
</div>
) : (
<div
style={{
marginTop: 10,
height: 160,
borderRadius: 4,
backgroundColor: "#f2f2f2",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#bbb",
fontSize: 13,
}}
>
</div>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
backgroundColor: "#f5f5f5",
borderRadius: 4,
fontSize: 13,
marginBottom: linkData.desc ? 12 : 0
borderTop: "1px solid #ededed",
fontSize: 12,
color: "#8c8c8c",
}}
>
<LinkOutlined style={{ color: "#1677ff", marginRight: 6 }} />
<span
style={{
color: "#1677ff",
wordBreak: "break-all",
flex: 1
}}
>
{linkData.url || "未设置链接地址"}
</span>
<LinkOutlined style={{ color: "#576b95", fontSize: 14 }} />
<span></span>
</div>
{/* 4. 描述 */}
{linkData.desc && (
</div>
);
} else {
const linkData = parsed49.data;
previewNode = (
<div
style={{
maxWidth: 400,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#fff",
}}
>
<div style={{ padding: "16px" }}>
<div
style={{
color: "#8c8c8c",
fontSize: 14,
lineHeight: 1.5,
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis"
fontWeight: 600,
fontSize: 16,
marginBottom: 12,
color: "#262626",
lineHeight: 1.4,
}}
>
{linkData.desc}
{reply.title}
</div>
)}
{linkData.thumbPath && (
<div
style={{
width: "100%",
marginBottom: 12,
borderRadius: 6,
overflow: "hidden",
backgroundColor: "#f5f5f5",
}}
>
<img
src={linkData.thumbPath}
alt="链接封面"
style={{
width: "100%",
height: "auto",
maxHeight: 200,
objectFit: "cover",
display: "block",
}}
/>
</div>
)}
<div
style={{
display: "flex",
alignItems: "center",
padding: "8px 12px",
backgroundColor: "#f5f5f5",
borderRadius: 4,
fontSize: 13,
marginBottom: linkData.desc ? 12 : 0,
}}
>
<LinkOutlined style={{ color: "#1677ff", marginRight: 6 }} />
<span
style={{
color: "#1677ff",
wordBreak: "break-all",
flex: 1,
}}
>
{linkData.url || "未设置链接地址"}
</span>
</div>
{linkData.desc && (
<div
style={{
color: "#8c8c8c",
fontSize: 14,
lineHeight: 1.5,
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{linkData.desc}
</div>
)}
</div>
</div>
</div>
);
);
}
} catch {
// 如果解析失败,使用旧格式
previewNode = (
<div
style={{
maxWidth: 400,
padding: 16,
border: "1px solid #e8e8e8",
borderRadius: 8
borderRadius: 8,
}}
>
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 16 }}>
{reply.title}
</div>
<div style={{ color: "#1677ff", fontSize: 14 }}>
{typeof reply.content === 'string' ? reply.content : "链接内容"}
{typeof reply.content === "string" ? reply.content : "链接内容"}
</div>
</div>
);
@@ -347,15 +551,19 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
fetchQuickWords();
}, [fetchQuickWords]);
// 获取消息类型图标
const getMessageTypeIcon = (msgType: number) => {
switch (msgType) {
const getMessageTypeIcon = (reply: QuickWordsReply) => {
if (isMiniprogramReply(reply)) {
return <AppstoreOutlined style={{ color: "#07c160" }} />;
}
switch (reply.msgType) {
case MessageType.TEXT:
return <FileTextOutlined style={{ color: "#1890ff" }} />;
case MessageType.IMAGE:
return <PictureOutlined style={{ color: "#52c41a" }} />;
case MessageType.VIDEO:
return <PlayCircleOutlined style={{ color: "#fa8c16" }} />;
case MessageType.LINK:
return <LinkOutlined style={{ color: "#1677ff" }} />;
default:
return <FileTextOutlined style={{ color: "#8c8c8c" }} />;
}
@@ -429,7 +637,7 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{getMessageTypeIcon(reply.msgType)}
{getMessageTypeIcon(reply)}
<span>{reply.title}</span>
</div>
<div style={{ display: "flex", gap: 4 }}>