素材允许添加小程序
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(/&/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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user