优化QuickWords组件:更新消息类型处理逻辑,支持LINK类型的内容格式化和展示,增强表单处理以支持多种文件类型上传,改善用户体验。

This commit is contained in:
乘风
2026-01-17 15:57:52 +08:00
parent f682456241
commit 87a2cea4fd
6 changed files with 1224 additions and 74 deletions

View File

@@ -273,15 +273,15 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
<>
{/* 使用 React.memo 优化列表项渲染 */}
{friends.map(friend => {
const isSelected = selectedFriendsMap.has(friend.id);
return (
<FriendListItem
key={friend.id}
friend={friend}
isSelected={isSelected}
onSelect={handleSelectFriend}
/>
);
const isSelected = selectedFriendsMap.has(friend.id);
return (
<FriendListItem
key={friend.id}
friend={friend}
isSelected={isSelected}
onSelect={handleSelectFriend}
/>
);
})}
{/* 加载更多指示器 */}

View File

@@ -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
*/

View File

@@ -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<QuickReplyModalProps> = ({
groupOptions,
defaultGroupId,
}) => {
const [form] = Form.useForm<AddReplyRequest>();
const [form] = Form.useForm();
const mergedInitialValues = useMemo(() => {
return {
const baseValues = {
groupId: defaultGroupId,
msgType: initialValues?.msgType || ["1"],
...initialValues,
} as Partial<AddReplyRequest>;
};
// 如果是编辑模式且是 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<QuickReplyModalProps> = ({
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<QuickReplyModalProps> = ({
} 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<QuickReplyModalProps> = ({
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<QuickReplyModalProps> = ({
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<QuickReplyModalProps> = ({
/>
)}
{selectedMsgType === 3 && (
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.IMAGE)
}
maxSize={1}
type={1}
slot={<Button icon={<PictureOutlined />}></Button>}
/>
<div style={{ maxWidth: "50%" }}>
<MainImgUpload
value={form.getFieldValue("content")}
onChange={(url) => {
form.setFieldsValue({ content: url });
}}
maxSize={5}
showPreview={true}
/>
</div>
)}
{selectedMsgType === 43 && (
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.VIDEO)
}
maxSize={1}
type={4}
slot={<Button icon={<VideoCameraOutlined />}></Button>}
/>
<>
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 8, fontSize: 14, color: "#666" }}>
</div>
<div style={{ maxWidth: "50%" }}>
<MainImgUpload
value={(() => {
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}
/>
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontSize: 14, color: "#666" }}>
</div>
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.VIDEO)
}
maxSize={50}
type={4}
slot={<Button icon={<VideoCameraOutlined />} block></Button>}
/>
{(() => {
try {
const videoData = JSON.parse(form.getFieldValue("content") || "{}");
const videoUrl = videoData.url;
if (videoUrl) {
return (
<div style={{ marginTop: 12, position: "relative" }}>
<video
src={videoUrl}
controls
style={{
width: "100%",
maxHeight: 300,
borderRadius: 6,
border: "1px solid #d9d9d9",
}}
/>
<Button
danger
size="small"
style={{ marginTop: 8 }}
onClick={() => {
try {
const currentContent = form.getFieldValue("content");
const videoData = currentContent ? JSON.parse(currentContent) : {};
delete videoData.url;
delete videoData.name;
form.setFieldsValue({ content: JSON.stringify(videoData) });
} catch {
form.setFieldsValue({ content: "" });
}
}}
>
</Button>
</div>
);
}
} catch {
return null;
}
return null;
})()}
</div>
</>
)}
{selectedMsgType === 49 && (
<Input
placeholder="请输入链接地址"
prefix={<LinkOutlined />}
value={form.getFieldValue("content")}
onChange={e => form.setFieldsValue({ content: e.target.value })}
/>
<>
<Input
placeholder="请输入链接地址"
prefix={<LinkOutlined />}
value={getLinkUrl}
onChange={(e) => {
const newUrl = e.target.value;
// LINK 类型下content 字段存储的是 url 字符串
// 提交时会与 thumbPath、desc 组合成对象
form.setFieldsValue({
content: newUrl
});
}}
/>
<div style={{ marginTop: 12 }}>
<Form.Item
name="thumbPath"
label="封面图"
style={{ marginBottom: 12 }}
>
<div style={{ maxWidth: "50%" }}>
<MainImgUpload
value={form.getFieldValue("thumbPath")}
onChange={(url) => {
form.setFieldsValue({ thumbPath: url });
}}
maxSize={5}
showPreview={true}
/>
</div>
</Form.Item>
<Form.Item
name="desc"
label="描述"
style={{ marginBottom: 0 }}
>
<Input.TextArea
rows={3}
placeholder="请输入链接描述"
maxLength={200}
showCount
/>
</Form.Item>
</div>
</>
)}
</Form.Item>

View File

@@ -21,6 +21,7 @@ import {
PictureOutlined,
PlayCircleOutlined,
SearchOutlined,
LinkOutlined,
} from "@ant-design/icons";
import {
QuickWordsItem,
@@ -90,13 +91,29 @@ const QuickWords: React.FC<QuickWordsProps> = ({ 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<QuickWordsProps> = ({ onInsert }) => {
/>
</div>
);
} else if (reply.msgType === MessageType.VIDEO) {
} else if (reply.msgType === MessageType.VIDEO) {
try {
const videoUrl = reply.content
if (videoUrl) {
// 如果有视频URL显示视频播放器
previewNode = (
<div style={{ textAlign: "center" }}>
<video
src={videoUrl}
controls
style={{
maxWidth: 360,
maxHeight: 320,
borderRadius: 6,
width: "100%",
}}
/>
</div>
);
} else {
// 如果没有视频URL显示默认提示
previewNode = (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div style={{ fontSize: 48, color: "#d9d9d9", marginBottom: 12 }}>
📹
</div>
<div style={{ color: "#999" }}></div>
</div>
);
}
} catch {
previewNode = <div></div>;
}
} else if (reply.msgType === MessageType.LINK) {
try {
const json = JSON.parse(reply.content || "{}");
const cover = json.previewImage || json.thumbPath || "";
const linkData = reply.content ;
previewNode = (
<div style={{ textAlign: "center" }}>
{cover ? (
<img
src={String(cover)}
alt="视频预览"
style={{ maxWidth: 360, maxHeight: 320, borderRadius: 6 }}
/>
) : (
<div></div>
)}
<div
style={{
maxWidth: 400,
border: "1px solid #e8e8e8",
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 && (
<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>
)}
{/* 3. 链接地址 */}
<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>
{/* 4. 描述 */}
{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>
);
} catch {
previewNode = <div></div>;
// 如果解析失败,使用旧格式
previewNode = (
<div
style={{
maxWidth: 400,
padding: 16,
border: "1px solid #e8e8e8",
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 : "链接内容"}
</div>
</div>
);
}
} else if (reply.msgType === MessageType.LINK) {
previewNode = (
<div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{reply.title}</div>
<div style={{ color: "#1677ff" }}>{reply.content}</div>
</div>
);
}
Modal.confirm({
@@ -326,10 +466,17 @@ const QuickWords: React.FC<QuickWordsProps> = ({ 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<QuickWordsProps> = ({ 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<QuickWordsProps> = ({ 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<QuickWordsProps> = ({ 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}

View File

@@ -435,9 +435,9 @@ const MessageList: React.FC<MessageListProps> = () => {
try {
result = await getMessageList({
page,
limit,
});
page,
limit,
});
// ⭐ 处理数据结构,提取实际的列表数据
let actualData = result;

View File

@@ -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` 包含 `<appmsg``appid`
2. `type === "miniprogram"`
3. 存在 `weappinfo``weappInfo` 对象
**返回结果**`[小程序消息]`
**示例输入**
```json
{
"contentXml": "<msg><appmsg appid=\"wx123456\">...</appmsg></msg>",
"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(
/<title>([^<]*(?:<!\[CDATA\[[^\]]*\]\]>[^<]*)*)<\/title>/i
)
```
**处理步骤**
1. 匹配 `<title>...</title>` 标签
2. 处理 CDATA`<![CDATA[文本]]>``文本`
3. 去除首尾空白
4. 限制长度为 50 字符
**示例输入**
```xml
<title><![CDATA[超值预售抢26年经济师《蓝宝典4.0》]]></title>
```
**返回**`超值预售抢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 字符串
**识别特征**(满足任一条件):
- 包含 `<?xml`
- 包含 `<msg>`
- 包含 `<appmsg`
**处理逻辑**
1. 尝试提取 `<title>` 标签内容
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处理** | 无专门处理 | 提取 `<title>` 标签显示有意义内容 | 新项目用户体验更好 |
| **长度限制** | 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/瓶【美味可口】海天上等蚝油]]></title></msg>"
}
```
**输出**`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` 实现编写,与实际代码保持同步。