From 87a2cea4fd0ee3088eb250163ae28d0ab610277f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=98=E9=A3=8E?= Date: Sat, 17 Jan 2026 15:57:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96QuickWords=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=9A=E6=9B=B4=E6=96=B0=E6=B6=88=E6=81=AF=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?LINK=E7=B1=BB=E5=9E=8B=E7=9A=84=E5=86=85=E5=AE=B9=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=96=E5=92=8C=E5=B1=95=E7=A4=BA=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E8=A1=A8=E5=8D=95=E5=A4=84=E7=90=86=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E7=A7=8D=E6=96=87=E4=BB=B6=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=EF=BC=8C=E6=94=B9=E5=96=84=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TwoColumnSelection/TwoColumnSelection.tsx | 18 +- .../ProfileCard/components/QuickWords/api.ts | 6 +- .../QuickWords/components/QuickReplyModal.tsx | 274 +++++- .../components/QuickWords/index.tsx | 205 ++++- .../SidebarMenu/MessageList/index.tsx | 6 +- .../MessageList/会话列表预览消息规则.md | 789 ++++++++++++++++++ 6 files changed, 1224 insertions(+), 74 deletions(-) create mode 100644 src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/会话列表预览消息规则.md diff --git a/src/components/TwoColumnSelection/TwoColumnSelection.tsx b/src/components/TwoColumnSelection/TwoColumnSelection.tsx index 16cdebd..9836780 100644 --- a/src/components/TwoColumnSelection/TwoColumnSelection.tsx +++ b/src/components/TwoColumnSelection/TwoColumnSelection.tsx @@ -273,15 +273,15 @@ const TwoColumnSelection: React.FC = ({ <> {/* 使用 React.memo 优化列表项渲染 */} {friends.map(friend => { - const isSelected = selectedFriendsMap.has(friend.id); - return ( - - ); + const isSelected = selectedFriendsMap.has(friend.id); + return ( + + ); })} {/* 加载更多指示器 */} diff --git a/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/api.ts b/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/api.ts index a1a12a5..63db8a7 100644 --- a/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/api.ts +++ b/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/api.ts @@ -6,7 +6,7 @@ export interface QuickWordsReply { userId: number; title: string; msgType: number; - content: string; + content: any; createTime: string; lastUpdateTime: string; sortIndex: string; @@ -41,7 +41,7 @@ export interface AddReplyRequest { /** * 1文本 3图片 43视频 49链接 等 */ - msgType?: string[]; + msgType?: number; /** * 默认50 */ @@ -72,7 +72,7 @@ export interface AddGroupRequest { /** * 0 公共 1私有 2部门 */ - replyType?: string[]; + replyType?: number; /** * 默认50 */ 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 f905f79..26bc422 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 @@ -6,6 +6,7 @@ import { LinkOutlined, } from "@ant-design/icons"; import SimpleFileUpload from "@/components/Upload/SimpleFileUpload"; +import MainImgUpload from "@/components/Upload/MainImgUpload"; // 简化版不再使用样式与解析组件 import { AddReplyRequest } from "../api"; @@ -28,16 +29,53 @@ const QuickReplyModal: React.FC = ({ groupOptions, defaultGroupId, }) => { - const [form] = Form.useForm(); + const [form] = Form.useForm(); const mergedInitialValues = useMemo(() => { - return { + const baseValues = { groupId: defaultGroupId, msgType: initialValues?.msgType || ["1"], ...initialValues, - } as Partial; + }; + + // 如果是编辑模式且是 link 类型,解析 content 中的 JSON + if (initialValues?.msgType && Array.isArray(initialValues.msgType) && initialValues.msgType[0] === "49" && initialValues.content) { + try { + // 处理 content 可能是对象或字符串两种情况 + let linkData: any; + if (typeof initialValues.content === 'string') { + // 如果是字符串,尝试解析 JSON + linkData = JSON.parse(initialValues.content); + } else if (typeof initialValues.content === 'object') { + // 如果已经是对象,直接使用 + linkData = initialValues.content; + } else { + return baseValues; + } + + return { + ...baseValues, + content: linkData.url || "", + thumbPath: linkData.thumbPath || "", + desc: linkData.desc || "", + }; + } catch { + // 如果解析失败,保持原值 + return baseValues; + } + } + + return baseValues; }, [initialValues, defaultGroupId]); + // 监听 modal 打开和模式变化,重置表单 + React.useEffect(() => { + if (open) { + form.resetFields(); + form.setFieldsValue(mergedInitialValues); + } + }, [open, mode, form, mergedInitialValues]); + // 监听类型变化 const msgTypeWatch = Form.useWatch("msgType", form); const selectedMsgType = useMemo(() => { @@ -46,6 +84,26 @@ const QuickReplyModal: React.FC = ({ return Number(raw || "1"); }, [msgTypeWatch]); + // 监听 content 变化,用于 LINK 类型输入框 + const contentWatch = Form.useWatch("content", form); + + // 获取 LINK 类型的 url 值 + const getLinkUrl = useMemo(() => { + if (selectedMsgType === 49 && contentWatch) { + if (typeof contentWatch === 'string') { + try { + const linkData = JSON.parse(contentWatch); + return linkData.url || ""; + } catch { + return contentWatch; + } + } else if (typeof contentWatch === 'object' && contentWatch !== null) { + return contentWatch.url || ""; + } + } + return ""; + }, [selectedMsgType, contentWatch]); + // 根据文件格式判断消息类型 const getMsgTypeByFileFormat = (filePath: string): number => { const extension = filePath.toLowerCase().split(".").pop() || ""; @@ -84,7 +142,7 @@ const QuickReplyModal: React.FC = ({ } as const; const handleFileUploaded = ( - filePath: string | { url: string; durationMs: number }, + filePath: string | { url: string; durationMs?: number; name?: string }, fileType: number, ) => { let msgType = 1; @@ -100,11 +158,22 @@ const QuickReplyModal: React.FC = ({ msgType = 49; } + // 根据文件类型处理 content + let contentValue: string; + if (([FileType.AUDIO, FileType.VIDEO] as number[]).includes(fileType)) { + // 音频和视频需要保存完整的 JSON 对象,以供预览组件使用 + contentValue = JSON.stringify(filePath); + } else if (typeof filePath === 'string') { + // 其他类型如果是字符串就直接用 + contentValue = filePath; + } else { + // 其他类型如果是对象就取 url + contentValue = filePath.url; + } + form.setFieldsValue({ msgType: [String(msgType)], - content: ([FileType.AUDIO] as number[]).includes(fileType) - ? JSON.stringify(filePath) - : (filePath as string), + content: contentValue, }); }; @@ -132,11 +201,28 @@ const QuickReplyModal: React.FC = ({ form={form} layout="vertical" onFinish={values => { + // 处理 link 类型,将 content、thumbPath、desc 组合成 JSON + let finalValues = { ...values }; + if (selectedMsgType === 49) { + const linkData = { + url: values.content || "", + thumbPath: values.thumbPath || "", + desc: values.desc || "", + }; + finalValues = { + ...values, + content: JSON.stringify(linkData), + }; + // 移除额外的字段 + delete finalValues.thumbPath; + delete finalValues.desc; + } + const normalized = { - ...values, - msgType: Array.isArray(values.msgType) - ? values.msgType - : [String(values.msgType)], + ...finalValues, + msgType: Array.isArray(finalValues.msgType) + ? finalValues.msgType + : [String(finalValues.msgType)], } as AddReplyRequest; onSubmit(normalized); }} @@ -195,32 +281,154 @@ const QuickReplyModal: React.FC = ({ /> )} {selectedMsgType === 3 && ( - - handleFileUploaded(filePath, FileType.IMAGE) - } - maxSize={1} - type={1} - slot={} - /> +
+ { + form.setFieldsValue({ content: url }); + }} + maxSize={5} + showPreview={true} + /> +
)} {selectedMsgType === 43 && ( - - handleFileUploaded(filePath, FileType.VIDEO) - } - maxSize={1} - type={4} - slot={} - /> + <> +
+
+ 视频封面图 +
+
+ { + try { + const videoData = JSON.parse(form.getFieldValue("content") || "{}"); + return videoData.previewImage || videoData.thumbPath || ""; + } catch { + return ""; + } + })()} + onChange={(previewUrl) => { + // 保留原有的视频数据,只更新预览图 + try { + const currentContent = form.getFieldValue("content"); + const videoData = currentContent ? JSON.parse(currentContent) : {}; + videoData.previewImage = previewUrl; + form.setFieldsValue({ content: JSON.stringify(videoData) }); + } catch { + form.setFieldsValue({ content: JSON.stringify({ previewImage: previewUrl }) }); + } + }} + maxSize={5} + showPreview={true} + /> +
+
+ +
+
+ 视频文件 +
+ + handleFileUploaded(filePath, FileType.VIDEO) + } + maxSize={50} + type={4} + slot={} + /> + {(() => { + try { + const videoData = JSON.parse(form.getFieldValue("content") || "{}"); + const videoUrl = videoData.url; + if (videoUrl) { + return ( +
+
+ ); + } + } catch { + return null; + } + return null; + })()} +
+ )} {selectedMsgType === 49 && ( - } - value={form.getFieldValue("content")} - onChange={e => form.setFieldsValue({ content: e.target.value })} - /> + <> + } + value={getLinkUrl} + onChange={(e) => { + const newUrl = e.target.value; + // LINK 类型下,content 字段存储的是 url 字符串 + // 提交时会与 thumbPath、desc 组合成对象 + form.setFieldsValue({ + content: newUrl + }); + }} + /> +
+ +
+ { + form.setFieldsValue({ thumbPath: 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 eac9b77..58d7042 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 @@ -21,6 +21,7 @@ import { PictureOutlined, PlayCircleOutlined, SearchOutlined, + LinkOutlined, } from "@ant-design/icons"; import { QuickWordsItem, @@ -90,13 +91,29 @@ const QuickWords: React.FC = ({ onInsert }) => { const sendQuickReplyNow = (reply: QuickWordsReply) => { if (!currentContract) return; const messageId = Date.now(); + + // 处理 link 类型,转换为文章格式 + let content = reply.content; + if (reply.msgType === MessageType.LINK) { + // link 类型按照文章消息格式发送 + const linkData = reply.content; + + content = JSON.stringify({ + type: "link", + title: reply.title || "文章链接", + desc: linkData.desc || "", + thumbPath: linkData.thumbPath || "", + url: linkData.url || "" + }); + } + const params = { wechatAccountId: currentContract.wechatAccountId, wechatChatroomId: currentContract?.chatroomId ? currentContract.id : 0, wechatFriendId: currentContract?.chatroomId ? 0 : currentContract.id, msgSubType: 0, msgType: reply.msgType, - content: reply.content, + content: content, seq: messageId, } as any; @@ -141,33 +158,156 @@ const QuickWords: React.FC = ({ onInsert }) => { /> ); - } else if (reply.msgType === MessageType.VIDEO) { + } else if (reply.msgType === MessageType.VIDEO) { + try { + const videoUrl = reply.content + if (videoUrl) { + // 如果有视频URL,显示视频播放器 + previewNode = ( +
+
+ ); + } else { + // 如果没有视频URL,显示默认提示 + previewNode = ( +
+
+ 📹 +
+
暂无视频内容
+
+ ); + } + } catch { + previewNode =
视频消息
; + } + } else if (reply.msgType === MessageType.LINK) { try { - const json = JSON.parse(reply.content || "{}"); - const cover = json.previewImage || json.thumbPath || ""; + const linkData = reply.content ; previewNode = ( -
- {cover ? ( - 视频预览 - ) : ( -
视频消息
- )} +
+ {/* 内容区域 */} +
+ {/* 1. 标题 */} +
+ {reply.title} +
+ + {/* 2. 封面图 */} + {linkData.thumbPath && ( +
+ 链接封面 +
+ )} + + {/* 3. 链接地址 */} +
+ + + {linkData.url || "未设置链接地址"} + +
+ + {/* 4. 描述 */} + {linkData.desc && ( +
+ {linkData.desc} +
+ )} +
); } catch { - previewNode =
视频消息
; + // 如果解析失败,使用旧格式 + previewNode = ( +
+
+ {reply.title} +
+
+ {typeof reply.content === 'string' ? reply.content : "链接内容"} +
+
+ ); } - } else if (reply.msgType === MessageType.LINK) { - previewNode = ( -
-
{reply.title}
-
{reply.content}
-
- ); } Modal.confirm({ @@ -326,10 +466,17 @@ const QuickWords: React.FC = ({ onInsert }) => { selectedKeys[0]?.toString().replace("group-", "") || groupOptions[0]?.value || ""; + + // 处理 msgType:从字符串数组转换为 number + const msgType = Array.isArray(values.msgType) + ? Number(values.msgType[0]) + : Number(values.msgType); + await addReply({ ...values, + msgType, groupId: values.groupId || fallbackGroupId, - replyType: [activeTab.toString()], + replyType: activeTab, // ✅ 直接传 number 类型 }); message.success("添加快捷回复成功"); setAddModalVisible(false); @@ -351,8 +498,14 @@ const QuickWords: React.FC = ({ onInsert }) => { if (!editingItem) return; try { + // 处理 msgType:从字符串数组转换为 number + const msgType = Array.isArray(values.msgType) + ? Number(values.msgType[0]) + : Number(values.msgType); + await updateReply({ ...values, + msgType, id: editingItem.id.toString(), }); message.success("更新快捷回复成功"); @@ -422,7 +575,7 @@ const QuickWords: React.FC = ({ onInsert }) => { parentId: selectedKeys[0]?.toString().startsWith("group-") ? selectedKeys[0]?.toString().replace("group-", "") : "0", - replyType: [activeTab.toString()], + replyType: activeTab, // ✅ 直接传 number 类型 }); message.success("新增分组成功"); setGroupModalVisible(false); @@ -582,14 +735,14 @@ const QuickWords: React.FC = ({ onInsert }) => { defaultGroupId={selectedKeys[0]?.toString().replace("group-", "")} initialValues={ editingItem - ? { + ? ({ title: editingItem.title, content: editingItem.content, msgType: [editingItem.msgType.toString()], groupId: editingItem.groupId?.toString?.() || selectedKeys[0]?.toString().replace("group-", ""), - } + } as any) : undefined } onSubmit={handleUpdateReply} diff --git a/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx b/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx index 3b773a8..5700111 100644 --- a/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx +++ b/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx @@ -435,9 +435,9 @@ const MessageList: React.FC = () => { try { result = await getMessageList({ - page, - limit, - }); + page, + limit, + }); // ⭐ 处理数据结构,提取实际的列表数据 let actualData = result; diff --git a/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/会话列表预览消息规则.md b/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/会话列表预览消息规则.md new file mode 100644 index 0000000..7d35de9 --- /dev/null +++ b/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/会话列表预览消息规则.md @@ -0,0 +1,789 @@ +# 会话列表预览消息规则 + +> **文档说明**:详细记录会话列表中消息预览的格式化规则和处理逻辑(框架无关,适用于 React/Vue 项目改造) + +## 📋 目录 + +- [核心概述](#核心概述) +- [数据来源](#数据来源) +- [处理流程](#处理流程) +- [规则详解](#规则详解) +- [与旧项目对比](#与旧项目对比) +- [代码实现](#代码实现) +- [测试用例](#测试用例) + +--- + +## 核心概述 + +### 基本信息 + +| 项目 | 说明 | +| ------------ | ----------------------------------------------------------------- | +| **工具函数** | `formatMessagePreview()` / `messageFilter()` | +| **文件位置** | `utils/messagePreview.ts`(新项目)或 `utils/filter.ts`(旧项目) | +| **使用场景** | 会话列表消息预览、通知预览、消息摘要 | +| **数据来源** | `session.latestMessage.content` 或 `session.content` | +| **返回类型** | `string`(永远不会返回空值) | +| **框架支持** | ✅ React、Vue、Angular、原生 JS 等 | + +### 设计原则 + +1. **兜底处理**:所有异常情况都有友好提示,不会显示原始错误 +2. **优先级明确**:按照消息类型的匹配优先级依次判断 +3. **长度限制**:文本消息最多显示50个字符,超出部分显示省略号 +4. **特殊符号**:富媒体消息使用中括号包裹,如 `[图片]`、`[视频]` +5. **兼容性强**:处理 JSON 不完整、XML 截断等边界情况 + +--- + +## 数据来源 + +### 会话列表数据字段 + +在会话列表中,预览消息的数据来源字段: + +| 字段 | 说明 | 优先级 | +| ------------------------------- | ------------ | -------- | +| `session.latestMessage.content` | 最新消息内容 | 优先使用 | +| `session.content` | 会话缓存内容 | 兜底字段 | + +### 调用示例 + +**新项目(Vue)**: + +```typescript +const previewText = formatMessagePreview( + session?.latestMessage?.content || session?.content +) +``` + +**旧项目(React)**: + +```typescript +const previewText = messageFilter(session.content) +``` + +### 函数签名 + +```typescript +/** + * 格式化消息预览内容 + * @param content 原始消息内容 + * @returns 格式化后的预览文本 + */ +function formatMessagePreview(content: string | null | undefined): string +``` + +### 使用说明 + +- ✅ **框架无关**:可用于 React、Vue、Angular 等任何框架 +- ✅ **输入类型**:`string | null | undefined` +- ✅ **输出类型**:`string`(永远不会返回空值) +- ✅ **使用场景**:会话列表预览、通知预览、消息摘要等 + +--- + +## 处理流程 + +### 流程图 + +``` +输入 content + ↓ +① 空值检查 → null/undefined/空字符串 → "暂无消息" + ↓ +② 阿里云OSS链接检查 → 匹配到 → 根据扩展名返回 [图片]/[视频]/[音频] + ↓ +③ JSON解析尝试 + ├─ 成功 + │ ├─ 小程序消息 → "[小程序消息]" + │ ├─ JSON中包含OSS链接 → 根据扩展名返回 + │ ├─ contentXml提取title → 显示title(最多50字符) + │ ├─ JSON过长(>500字符) → "[文本过长]" + │ ├─ JSON.title字段 → 显示title(最多50字符) + │ ├─ JSON.content字段 → 显示content(最多50字符) + │ └─ 无法识别 → "[消息]" + └─ 失败(非JSON) + ↓ +④ 普通HTTP链接检查 + ├─ 图片扩展名 → "[图片]" + ├─ 视频扩展名 → "[视频]" + ├─ 音频扩展名 → "[音频]" + └─ 其他链接 → "[链接]" + ↓ +⑤ XML字符串检查 → 提取title或返回"[文本过长]" + ↓ +⑥ 普通文本 → 显示文本(最多50字符) +``` + +--- + +## 规则详解 + +### 1️⃣ 空值处理 + +```typescript +if (!content || typeof content !== 'string') { + return '暂无消息' +} + +const trimmed = content.trim() +if (!trimmed) { + return '暂无消息' +} +``` + +**处理情况**: + +- `null`、`undefined` +- 非字符串类型 +- 空字符串或纯空白字符 + +**返回结果**:`"暂无消息"` + +--- + +### 2️⃣ 阿里云 OSS 链接识别 + +#### OSS 前缀 + +```typescript +const ALIYUN_OSS_PREFIX = 'https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com' +``` + +#### 文件类型判断 + +| 类型 | 扩展名 | 返回值 | +| -------- | --------------------------------------------------------------- | -------- | +| **图片** | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp`, `.svg` | `[图片]` | +| **视频** | `.mp4`, `.avi`, `.mov`, `.wmv`, `.flv`, `.mkv`, `.webm`, `.m4v` | `[视频]` | +| **音频** | `.mp3`, `.wav`, `.wma`, `.flac`, `.aac`, `.ogg`, `.m4a` | `[音频]` | + +#### 示例 + +**输入**: + +``` +https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/weremote/chat-logs/xxx/9160773596410687940.jpg +``` + +**输出**:`[图片]` + +--- + +### 3️⃣ JSON 格式消息 + +#### 3.1 小程序消息 + +**识别特征**(满足任一条件): + +1. `contentXml` 包含 `...", + "type": "miniprogram" +} +``` + +--- + +#### 3.2 JSON 中包含 OSS 链接 + +递归遍历 JSON 所有字段,查找包含 OSS 前缀的链接: + +```typescript +const findAliyunOssLink = (obj: any): string | null => { + if (typeof obj === 'string' && obj.includes(ALIYUN_OSS_PREFIX)) { + return obj + } + if (typeof obj === 'object' && obj !== null) { + for (const value of Object.values(obj)) { + const link = findAliyunOssLink(value) + if (link) return link + } + } + return null +} +``` + +**示例**: + +```json +{ + "previewImage": "https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/.../image.png", + "type": "image" +} +``` + +**返回**:`[图片]` + +--- + +#### 3.3 从 contentXml 提取 title + +**匹配规则**: + +```typescript +const titleMatch = xmlString.match( + /([^<]*(?:<!\[CDATA\[[^\]]*\]\]>[^<]*)*)<\/title>/i +) +``` + +**处理步骤**: + +1. 匹配 `<title>...` 标签 +2. 处理 CDATA:`` → `文本` +3. 去除首尾空白 +4. 限制长度为 50 字符 + +**示例输入**: + +```xml +<![CDATA[超值预售!抢26年经济师《蓝宝典4.0》]]> +``` + +**返回**:`超值预售!抢26年经济师《蓝宝典4.0》` + +--- + +#### 3.4 JSON 过长处理 + +**触发条件**:JSON 字符串长度 > 500 字符 + +**处理逻辑**: + +1. 尝试从 XML 中提取 title +2. 提取成功 → 显示 title(最多50字符) +3. 提取失败 → 返回 `[文本过长]` + +--- + +#### 3.5 提取 JSON 字段 + +**字段优先级**: + +| 优先级 | 字段名 | 处理 | +| ------ | --------- | ------------------------------- | +| 1 | `title` | 显示 title 内容(最多50字符) | +| 2 | `content` | 显示 content 内容(最多50字符) | +| 3 | 无匹配 | 返回 `[消息]` | + +--- + +### 4️⃣ 普通 HTTP 链接 + +**匹配规则**:`/^https?:\/\//i` + +| 链接类型 | 扩展名匹配 | 返回值 | +| -------- | --------------- | -------- | +| 图片链接 | IMAGE_EXT_REGEX | `[图片]` | +| 视频链接 | VIDEO_EXT_REGEX | `[视频]` | +| 音频链接 | AUDIO_EXT_REGEX | `[音频]` | +| 其他链接 | - | `[链接]` | + +**示例**: + +``` +https://example.com/video.mp4 → [视频] +https://example.com/page.html → [链接] +``` + +--- + +### 5️⃣ XML 字符串 + +**识别特征**(满足任一条件): + +- 包含 `` +- 包含 `` 标签内容 +2. 成功 → 显示 title(最多50字符) +3. 失败 → 返回 `[文本过长]` + +--- + +### 6️⃣ 普通文本消息 + +**处理规则**: + +- 最大长度:50 字符 +- 超出部分:截断并添加 `...` +- 不做任何格式转换 + +**示例**: + +```typescript +输入: '先生上的飞机啊立刻搭街坊拉萨,这是一条很长的消息内容,超过了五十个字符的限制' +输出: '先生上的飞机啊立刻搭街坊拉萨,这是一条很长的消息内容,超过了五...' +``` + +--- + +## 与旧项目对比 + +### 旧项目实现(messageFilter) + +旧项目使用 `messageFilter()` 函数(位于 `old/src/utils/filter.ts`): + +```typescript +export const messageFilter = (message: string) => { + if (!message) return '' + + try { + const parsed = JSON.parse(message) + + switch (true) { + case !!(parsed.previewImage || parsed.tencentUrl): + return '[图片]' + case !!(parsed.videoUrl || parsed.video): + return '[视频]' + case !!( + parsed.voiceUrl || + parsed.voice || + (parsed.url && parsed.durationMs) + ): + return parsed.text ? `[语音] ${parsed.text}` : '[语音]' + // ... 其他判断 + } + } catch { + return message.length > 30 ? message.substring(0, 30) + '...' : message + } +} +``` + +### 核心差异 + +| 对比项 | 旧项目 | 新项目 | 优势对比 | +| ---------------- | --------------------------------------------- | ------------------------------------------ | ------------------------ | +| **JSON字段判断** | 硬编码字段名(如 `previewImage`, `videoUrl`) | 动态查找 OSS 链接 + 字段提取 | 新项目更灵活,兼容性更好 | +| **小程序识别** | 无专门处理 | 多维度识别(`appid`、`type`、`weappinfo`) | 新项目识别更准确 | +| **XML处理** | 无专门处理 | 提取 `` 标签显示有意义内容 | 新项目用户体验更好 | +| **长度限制** | 30 字符 | 50 字符 | 新项目显示更多信息 | +| **截断处理** | JSON 被截断时显示原始 JSON | 尝试提取 title 或标记 `[文本过长]` | 新项目更优雅 | +| **OSS 链接** | 无专门处理 | 递归查找 JSON 中的 OSS 链接 | 新项目支持嵌套结构 | + +### 新项目优势 + +✅ **更强大的 XML 解析**:能从复杂的 `contentXml` 中提取 title +✅ **递归查找 OSS 链接**:支持深层嵌套的 JSON 结构 +✅ **小程序消息识别**:多维度判断,更准确 +✅ **优雅的边界处理**:JSON 不完整、XML 截断都有友好提示 +✅ **更长的文本预览**:50字符 vs 30字符 + +--- + +## 代码实现 + +### 核心函数(完整实现) + +```typescript +/** + * 消息预览格式化工具 + * 用于会话列表中显示消息预览,参考 content数据实例.md + */ + +// 图片扩展名正则 +const IMAGE_EXT_REGEX = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i + +// 视频扩展名正则 +const VIDEO_EXT_REGEX = /\.(mp4|avi|mov|wmv|flv|mkv|webm|m4v)$/i + +// 音频扩展名正则 +const AUDIO_EXT_REGEX = /\.(mp3|wav|wma|flac|aac|ogg|m4a)$/i + +// 阿里云 OSS 前缀 +const ALIYUN_OSS_PREFIX = 'https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com' + +/** + * 尝试解析 JSON + */ +const tryParseJson = (content: string): Record<string, any> | null => { + try { + return JSON.parse(content) + } catch { + return null + } +} + +/** + * 从 XML 字符串中提取 title + */ +const extractTitleFromXml = (xmlString: string): string | null => { + try { + // 尝试提取 <title> 标签内容 + const titleMatch = xmlString.match( + /<title>([^<]*(?:<!\[CDATA\[[^\]]*\]\]>[^<]*)*)<\/title>/i + ) + if (titleMatch && titleMatch[1]) { + let title = titleMatch[1] + // 处理 CDATA + title = title.replace(/<!\[CDATA\[(.*?)\]\]>/gi, '$1') + // 去除首尾空白 + title = title.trim() + if (title) { + return title + } + } + } catch { + // 解析失败,返回 null + } + return null +} + +/** + * 检查是否为阿里云 OSS 链接,并判断类型 + */ +const checkAliyunOssLink = (url: string): '图片' | '视频' | '音频' | null => { + if (!url.includes(ALIYUN_OSS_PREFIX)) { + return null + } + + // 根据文件扩展名判断类型 + if (IMAGE_EXT_REGEX.test(url)) { + return '图片' + } + if (VIDEO_EXT_REGEX.test(url)) { + return '视频' + } + if (AUDIO_EXT_REGEX.test(url)) { + return '音频' + } + + return null +} + +/** + * 检查是否为小程序消息 + */ +const isMiniProgramMessage = (jsonData: Record<string, any>): boolean => { + // 检查是否有 contentXml 且包含 appid + if (jsonData.contentXml && typeof jsonData.contentXml === 'string') { + const xmlContent = jsonData.contentXml + // 检查是否包含 <appmsg appid= 或 <appid> + if (xmlContent.includes('<appmsg') && xmlContent.includes('appid')) { + return true + } + } + + // 检查是否有 type: "miniprogram" + if (jsonData.type === 'miniprogram') { + return true + } + + // 检查是否有 weappinfo 对象 + if (jsonData.weappinfo || jsonData.weappInfo) { + return true + } + + return false +} + +/** + * 格式化消息预览内容 + * @param content 原始消息内容 + * @returns 格式化后的预览文本 + */ +export function formatMessagePreview( + content: string | null | undefined +): string { + // 处理空值 + if (!content || typeof content !== 'string') { + return '暂无消息' + } + + const trimmed = content.trim() + + if (!trimmed) { + return '暂无消息' + } + + // 1. 检查是否为阿里云 OSS 链接(纯链接字符串) + const aliyunOssType = checkAliyunOssLink(trimmed) + if (aliyunOssType) { + return `[${aliyunOssType}]` + } + + // 2. 尝试解析 JSON + const jsonData = tryParseJson(trimmed) + + if (jsonData && typeof jsonData === 'object') { + // 2.1 检查是否为小程序消息 + if (isMiniProgramMessage(jsonData)) { + return '[小程序消息]' + } + + // 2.2 检查 JSON 中是否有阿里云 OSS 链接 + // 遍历 JSON 对象的所有值,查找链接 + const findAliyunOssLink = (obj: any): string | null => { + if (typeof obj === 'string' && obj.includes(ALIYUN_OSS_PREFIX)) { + return obj + } + if (typeof obj === 'object' && obj !== null) { + for (const value of Object.values(obj)) { + const link = findAliyunOssLink(value) + if (link) { + return link + } + } + } + return null + } + + const ossLink = findAliyunOssLink(jsonData) + if (ossLink) { + const ossType = checkAliyunOssLink(ossLink) + if (ossType) { + return `[${ossType}]` + } + } + + // 2.3 尝试从 contentXml 中提取 title + if (jsonData.contentXml && typeof jsonData.contentXml === 'string') { + const title = extractTitleFromXml(jsonData.contentXml) + if (title) { + // 限制长度 + const maxLength = 50 + return title.length > maxLength + ? title.substring(0, maxLength) + '...' + : title + } + } + + // 2.4 检查 JSON 是否过长或被截断 + // 如果 JSON 字符串很长(超过 500 字符),可能被截断 + if (trimmed.length > 500) { + // 尝试提取 title + const title = extractTitleFromXml(trimmed) + if (title) { + const maxLength = 50 + return title.length > maxLength + ? title.substring(0, maxLength) + '...' + : title + } + return '[文本过长]' + } + + // 2.5 尝试从 JSON 中提取有意义的信息 + if (jsonData.title) { + const title = String(jsonData.title) + const maxLength = 50 + return title.length > maxLength + ? title.substring(0, maxLength) + '...' + : title + } + + if (jsonData.content) { + const content = String(jsonData.content) + const maxLength = 50 + return content.length > maxLength + ? content.substring(0, maxLength) + '...' + : content + } + + // 2.6 无法识别的 JSON,返回通用提示 + return '[消息]' + } + + // 3. 检查是否为普通 HTTP 链接 + if (/^https?:\/\//i.test(trimmed)) { + // 检查是否为图片链接 + if (IMAGE_EXT_REGEX.test(trimmed)) { + return '[图片]' + } + // 检查是否为视频链接 + if (VIDEO_EXT_REGEX.test(trimmed)) { + return '[视频]' + } + // 检查是否为音频链接 + if (AUDIO_EXT_REGEX.test(trimmed)) { + return '[音频]' + } + // 普通链接 + return '[链接]' + } + + // 4. 检查是否为 XML 字符串(但没有被 JSON 包裹) + if ( + trimmed.includes('<?xml') || + trimmed.includes('<msg>') || + trimmed.includes('<appmsg') + ) { + const title = extractTitleFromXml(trimmed) + if (title) { + const maxLength = 50 + return title.length > maxLength + ? title.substring(0, maxLength) + '...' + : title + } + return '[文本过长]' + } + + // 5. 普通文本消息 + // 限制长度,避免过长文本影响显示 + const maxLength = 50 + if (trimmed.length > maxLength) { + return trimmed.substring(0, maxLength) + '...' + } + + return trimmed +} +``` + +--- + +## 测试用例 + +### 1. 空值测试 + +| 输入 | 输出 | +| ----------- | ---------- | +| `null` | `暂无消息` | +| `undefined` | `暂无消息` | +| `""` | `暂无消息` | +| `" "` | `暂无消息` | + +--- + +### 2. 阿里云 OSS 链接 + +| 输入 | 输出 | +| ------------------------------------------------------------------ | -------- | +| `https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/.../file.jpg` | `[图片]` | +| `https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/.../file.mp4` | `[视频]` | +| `https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/.../file.mp3` | `[音频]` | + +--- + +### 3. 小程序消息 + +**输入**: + +```json +{ + "contentXml": "<msg><appmsg appid=\"wx123\">...</appmsg></msg>", + "type": "miniprogram" +} +``` + +**输出**:`[小程序消息]` + +--- + +### 4. JSON 嵌套 OSS 链接 + +**输入**: + +```json +{ + "data": { + "media": { + "url": "https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/.../image.png" + } + } +} +``` + +**输出**:`[图片]` + +--- + +### 5. XML 提取 title + +**输入**: + +```json +{ + "contentXml": "<msg><title><![CDATA[1kg/瓶【美味可口】海天上等蚝油]]>" +} +``` + +**输出**:`1kg/瓶【美味可口】海天上等蚝油` + +--- + +### 6. JSON 过长 + +**输入**:长度 > 500 字符的 JSON,且无 title + +**输出**:`[文本过长]` + +--- + +### 7. 普通 HTTP 链接 + +| 输入 | 输出 | +| ------------------------------- | -------- | +| `https://example.com/image.jpg` | `[图片]` | +| `https://example.com/video.mp4` | `[视频]` | +| `https://example.com/page.html` | `[链接]` | + +--- + +### 8. 纯文本 + +| 输入 | 输出 | +| ----------------------------------------------------------------- | --------------------------------------------------------------- | +| `"你好"` | `你好` | +| `"这是一条很长的消息,超过了五十个字符的限制,需要被截断处理..."` | `这是一条很长的消息,超过了五十个字符的限制,需要被截断处理...` | + +--- + +## 📌 注意事项 + +### 1. 性能优化 + +- ✅ **正则表达式**:所有正则都定义在模块顶层,避免重复编译 +- ✅ **递归查找**:`findAliyunOssLink` 找到第一个匹配后立即返回 +- ✅ **提前返回**:每个判断成功后立即返回,减少不必要的计算 + +### 2. 数据兼容性 + +- ✅ **JSON 不完整**:解析失败时走 XML 或文本处理流程 +- ✅ **XML 截断**:无法提取 title 时返回 `[文本过长]` +- ✅ **嵌套结构**:递归查找支持任意深度的 JSON 嵌套 + +### 3. 用户体验 + +- ✅ **友好提示**:所有异常情况都有清晰的中文提示 +- ✅ **信息优先**:优先显示有意义的 title/content,而非 `[消息]` +- ✅ **长度控制**:50字符刚好能显示完整语义,又不会过长 + +### 4. 扩展性 + +如需添加新的消息类型识别: + +1. 在 `formatMessagePreview` 函数中添加新的判断分支 +2. 遵循现有的优先级顺序(从特殊到一般) +3. 确保有兜底的返回值 + +--- + +## 📝 变更记录 + +| 日期 | 版本 | 变更内容 | +| ---------- | ---- | -------------------------------- | +| 2026-01-16 | v1.0 | 创建文档,记录新项目消息预览规则 | + +--- + +## 🔗 相关文档 + +- [content数据实例.md](./content数据实例.md) - 消息内容格式说明 +- [开发日志.md](./开发日志.md) - 项目开发记录 +- [会话列表排序优化实施总结.md](./会话列表排序优化实施总结.md) - 会话列表优化说明 + +--- + +**📌 提示**:本文档基于 `src/utils/messagePreview.ts` 实现编写,与实际代码保持同步。