- {previewImage && (
+ {previewImage ? (

= ({
target.style.display = "none";
}}
/>
- )}
+ ) : null}
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
;
+ return (
+
+ );
}
// 4. 兜底:使用 SmallProgramMessage 处理(包含其他未识别类型)
- return
;
+ return (
+
+ );
};
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 = () => (
+
如何查看小程序原生 id
+ }
+ 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={
+
+
+ 在微信公众平台 / 小程序后台找到「账号原始 ID」(通常形如{" "}
+
+ gh_xxxxxxxx
+
+ ),复制到输入框即可,无需带{" "}
+
+ @app
+
+ 。点击图片可放大查看。
+
+
+
document.body,
+ }}
+ >
+ {MINIPROGRAM_NATIVE_ID_TUTORIAL_IMAGES.map((src, i) => (
+
+
+ 步骤 {i + 1} / {MINIPROGRAM_NATIVE_ID_TUTORIAL_IMAGES.length}
+
+
+ {i < MINIPROGRAM_NATIVE_ID_TUTORIAL_IMAGES.length - 1 ? (
+
+ ) : null}
+
+ ))}
+
+
+
+ }
+ >
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onKeyDown={e => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }}
+ >
+ {
+ 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";
+ }}
+ />
+
+
+);
+
export interface QuickReplyModalProps {
open: boolean;
mode: "add" | "edit";
@@ -31,6 +165,12 @@ const QuickReplyModal: React.FC
= ({
}) => {
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 = ({
...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 = ({
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 = ({
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 = ({
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 = ({
图片
视频
链接
+ 小程序
-
- {selectedMsgType === 1 && (
+ {selectedMsgType !== 50 && (
+
+ {selectedMsgType === 1 && (
= ({
onChange={e => form.setFieldsValue({ content: e.target.value })}
onKeyDown={handleKeyPress}
/>
- )}
- {selectedMsgType === 3 && (
+ )}
+ {selectedMsgType === 3 && (
= ({
showPreview={true}
/>
- )}
- {selectedMsgType === 43 && (
+ )}
+ {selectedMsgType === 43 && (
<>
@@ -382,8 +580,8 @@ const QuickReplyModal: React.FC = ({
})()}
>
- )}
- {selectedMsgType === 49 && (
+ )}
+ {selectedMsgType === 49 && (
<>
= ({
>
- )}
-
+ )}
+
+ )}
+
+ {selectedMsgType === 50 && (
+ <>
+
+
+
+
+
+ 小程序原生id(gh_...)
+
+
+ }
+ rules={[{ required: true, message: "请输入小程序原生id(不需要 @app)" }]}
+ style={{ marginBottom: 12 }}
+ >
+
+
+
+
+
+
+
+
+
+ {
+ form.setFieldsValue({ previewImage: url });
+ }}
+ maxSize={5}
+ showPreview={true}
+ />
+
+
+ >
+ )}
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 } | { kind: "link"; data: Record } => {
+ 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 = ({ onInsert }) => {
const [activeTab, setActiveTab] = useState(
QuickWordsType.PERSONAL,
@@ -95,19 +129,75 @@ const QuickWords: React.FC = ({ 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` +
+ `\n` +
+ `\n` +
+ `\t\n` +
+ `\t\t${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}