Merge branch 'yongxu-v3' into develop

This commit is contained in:
乘风
2026-02-04 11:45:28 +08:00
11 changed files with 1390 additions and 38 deletions

View File

@@ -0,0 +1,253 @@
# 文件消息迁移说明
## 📋 迁移概述
将旧项目中的文件类型消息处理逻辑迁移到新的消息类型配置系统中,创建独立的 `FileMessage` 组件。
---
## 📁 文件结构
### **新增文件**
```
src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/
├── components/
│ └── FileMessage/ # ✅ 新增:独立的文件消息组件
│ ├── index.tsx # 文件消息组件主文件
│ └── FileMessage.module.scss # 文件消息样式
└── messageTypes/
├── MsgType49Renderer.tsx # ✅ 已更新:添加文件检测
└── messageTypeConfig.tsx # ✅ 已更新:添加文件检测器
```
---
## 🔧 核心变更
### 1. **创建独立的 FileMessage 组件**
**位置**`components/FileMessage/index.tsx`
**功能**
- 从旧的 `SmallProgramMessage` 中提取文件处理逻辑
- 支持多种文件信息提取方式JSON、XML、元数据
- 实现文件下载和查看功能
- 根据文件扩展名显示对应图标
**关键方法**
- `extractFileInfoFromXml()` - 从XML提取文件信息
- `resolveFileMessageData()` - 按优先级解析文件数据
- `handleFileDownload()` - 处理文件下载逻辑
---
### 2. **更新 MsgType49Renderer**
**位置**`messageTypes/MsgType49Renderer.tsx`
**变更**
- 添加 `isFileMessage()` 检测函数
- 在渲染器中将文件消息检测提升为最高优先级
- 优先于小程序消息检测,避免误判
**检测逻辑**
```typescript
// 优先级顺序:
// 1. 文件消息检测(新增)
// 2. 文章消息检测
// 3. 小程序消息检测
// 4. 兜底处理
```
---
### 3. **添加文件类型检测器**
**位置**`messageTypes/messageTypeConfig.tsx`
**变更**
-`SPECIAL_TYPE_DETECTORS` 中添加文件检测器
- 优先级设置为 90高于图片、表情包低于红包、转账
- 用于处理未知 msgType 但内容特征明显的文件消息
**检测条件**
1. JSON 中 `type === "file"`
2. JSON 中 `contentXml` 包含文件标签
3. 原始内容包含文件XML标签排除小程序
---
## 📊 文件消息识别方式
### **方式1JSON格式**
```json
{
"type": "file",
"title": "文件名.pdf",
"url": "https://...",
"fileext": "pdf",
"size": 1024
}
```
### **方式2JSON + XML**
```json
{
"type": "file",
"contentXml": "<title>文件名</title><fileext>pdf</fileext>"
}
```
### **方式3纯XML格式**
```xml
<msg>
<title><![CDATA[文件名.pdf]]></title>
<fileext><![CDATA[pdf]]></fileext>
<totallen>1024</totallen>
</msg>
```
### **方式4消息元数据**
```typescript
msg.fileDownloadMeta = {
title: "文件名.pdf",
url: "https://...",
fileext: "pdf"
}
```
---
## 🎯 数据解析优先级
`resolveFileMessageData()` 函数中:
1. **最高优先级**JSON 中 `type === "file"`
2. **次优先级**JSON 中 `contentXml` 字段
3. **第三优先级**:原始内容解析为 XML
4. **最低优先级**`msg.fileDownloadMeta` 元数据
---
## 🎨 UI 特性
### **文件图标映射**
- 📕 PDF
- 📘 Word (doc/docx)
- 📗 Excel (xls/xlsx)
- 📙 PowerPoint (ppt/pptx)
- 📝 文本 (txt)
- 🗜️ 压缩包 (zip/rar/7z)
- 🖼️ 图片 (jpg/png/gif)
- 🎬 视频 (mp4/avi/mov)
- 🎵 音频 (mp3/wav/flac)
- 📄 默认
### **交互行为**
- **有URL**:显示"点击查看",直接打开文件
- **无URL**:显示"下载"按钮,触发下载命令
- **下载中**:显示"下载中...",禁用操作
### **文件名显示**
- 超过20字符自动截断
- 显示省略号
---
## 🔄 下载流程
```typescript
// 1. 设置下载状态
setFileDownloading(msg.id, true);
// 2. 发送下载命令
sendCommand("CmdDownloadFile", {
wechatAccountId: contract.wechatAccountId,
friendMessageId: contract.chatroomId ? 0 : msg.id, // 好友消息
chatroomMessageId: contract.chatroomId ? msg.id : 0, // 群聊消息
});
// 3. 下载完成后,通过 WebSocket 回调更新文件URL
// 状态在 weChatStore 中管理
```
---
## ✅ 迁移完成清单
- [x] 创建独立的 `FileMessage` 组件
- [x] 提取文件信息解析逻辑
- [x] 实现文件下载功能
- [x] 添加文件图标映射
- [x] 更新 `MsgType49Renderer` 添加文件检测
- [x]`SPECIAL_TYPE_DETECTORS` 中添加文件检测器
- [x] 创建样式文件
- [x] 代码检查和测试
---
## 📝 使用示例
### **在消息类型配置中使用**
文件消息会在以下场景自动识别:
1. **msgType = 49** 且内容是文件 → 通过 `MsgType49Renderer` 识别
2. **未知 msgType** 但内容特征明显 → 通过 `SPECIAL_TYPE_DETECTORS` 识别
### **组件调用**
```typescript
<FileMessage
content={msg.content}
msg={msg}
contract={contract}
parsedJson={parsedJson} // 可选已解析的JSON
/>
```
---
## 🔍 测试要点
1. **文件识别测试**
- JSON格式文件消息
- XML格式文件消息
- 混合格式文件消息
- 通过元数据的文件消息
2. **下载功能测试**
- 好友消息文件下载
- 群聊消息文件下载
- 下载状态更新
- 下载错误处理
3. **UI显示测试**
- 文件图标正确显示
- 文件名截断
- 操作按钮状态
- 响应式布局
---
## 📚 相关文件
- **旧代码参考**`Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx`
- **新组件**`Touchkebao2/.../FileMessage/index.tsx`
- **配置更新**`Touchkebao2/.../messageTypes/messageTypeConfig.tsx`
- **渲染器更新**`Touchkebao2/.../messageTypes/MsgType49Renderer.tsx`
---
## 💡 注意事项
1. **优先级**:文件检测优先于小程序检测,避免误判
2. **兼容性**:保持与旧版 `SmallProgramMessage` 的兼容性
3. **状态管理**:文件下载状态通过 `weChatStore.setFileDownloading()` 管理
4. **错误处理**:所有解析错误都有兜底处理,显示友好提示
---
**迁移完成时间**2025-01-21
**迁移负责人**AI Assistant

View File

@@ -0,0 +1,448 @@
# 文件类型消息解析分析报告
## 📋 概述
本文档分析旧逻辑中文件类型消息的解析方式,包括判断方法、数据结构特征和消息样本特征。
---
## 🔍 文件类型判断方式
### 1. **通过 URL 扩展名判断**
```typescript
const FILE_EXT_REGEX = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i;
const isFileUrl = (value: string) =>
isHttpUrl(value) && FILE_EXT_REGEX.test(value);
```
**支持的文件类型**
- PDF: `.pdf`
- Word: `.doc`, `.docx`
- Excel: `.xls`, `.xlsx`
- PowerPoint: `.ppt`, `.pptx`
- 文本: `.txt`
- 压缩包: `.zip`, `.rar`, `.7z`
**判断条件**
- 必须是 HTTP/HTTPS URL
- 扩展名匹配上述正则表达式
---
### 2. **通过 JSON 内容判断**
```typescript
const jsonData = JSON.parse(content);
if (jsonData && typeof jsonData === "object" && jsonData.type === "file") {
// 文件类型消息
}
```
**JSON 结构特征**
```json
{
"type": "file",
"title": "文件名",
"fileName": "文件名",
"filename": "文件名",
"url": "文件下载URL",
"fileext": "文件扩展名",
"size": 文件大小,
"contentXml": "XML格式的文件信息可选"
}
```
---
### 3. **通过 XML 内容判断**
从 XML 字符串中提取文件信息:
```typescript
const extractFileInfoFromXml = (source: string): FileMessageData | null => {
// 使用 DOMParser 解析 XML
const parser = new DOMParser();
const doc = parser.parseFromString(source, "text/xml");
// 提取信息
const titleNode = doc.getElementsByTagName("title")[0];
const fileExtNode = doc.getElementsByTagName("fileext")[0];
const sizeNode = doc.getElementsByTagName("totallen")[0]
|| doc.getElementsByTagName("filesize")[0];
}
```
**XML 结构特征**
**格式1CDATA**
```xml
<msg>
<title><![CDATA[文件名]]></title>
<fileext><![CDATA[pdf]]></fileext>
<totallen>1024</totallen>
</msg>
```
**格式2普通XML**
```xml
<msg>
<title>文件名</title>
<fileext>pdf</fileext>
<filesize>1024</filesize>
</msg>
```
**正则匹配(备用方案)**
```typescript
// 标题
/<title><!\[CDATA\[(.*?)\]\]><\/title>/i
/<title>([^<]+)<\/title>/i
// 扩展名
/<fileext><!\[CDATA\[(.*?)\]\]><\/fileext>/i
/<fileext>([^<]+)<\/fileext>/i
// 文件大小
/<totallen>([^<]+)<\/totallen>/i
/<filesize>([^<]+)<\/filesize>/i
```
---
### 4. **通过消息元数据判断**
`msg.fileDownloadMeta` 对象获取文件信息:
```typescript
const meta = msg?.fileDownloadMeta && typeof msg.fileDownloadMeta === "object"
? { ...(msg.fileDownloadMeta as Record<string, any>) }
: null;
```
**元数据结构**
```typescript
interface FileDownloadMeta {
url?: string;
fileName?: string;
filename?: string;
title?: string;
fileext?: string;
size?: number | string;
isDownloading?: boolean;
[key: string]: any;
}
```
---
## 📊 消息类型样本特征
### **场景1msgType = 49小程序/文件消息)**
```typescript
case 49: // 小程序/文章/其他:图文、文件
return (
<SmallProgramMessage
content={content}
msg={msg}
contract={contract}
/>
);
```
**内容格式**
- **JSON 字符串**:包含 `type: "file"` 的对象
- **XML 字符串**:包含文件信息的 XML
- **混合格式**JSON 中包含 `contentXml` 字段
---
### **场景2未知消息类型renderUnknownContent**
`msgType` 不匹配已知类型时,会尝试解析为文件:
```typescript
const jsonData = tryParseContentJson(trimmedContent);
if (jsonData && typeof jsonData === "object") {
if (jsonData.type === "file" && msg && contract) {
return <SmallProgramMessage ... />;
}
}
if (isFileUrl(trimmedContent)) {
return renderFileContent(trimmedContent);
}
```
---
## 🎯 文件消息数据解析流程
### **解析优先级**(从高到低)
1. **JSON 对象中的 type 字段**
```json
{ "type": "file", ... }
```
2. **JSON 对象中的 contentXml 字段**
```json
{
"contentXml": "<title>...</title><fileext>...</fileext>"
}
```
3. **原始内容中的 XML**
```xml
<title>文件名</title>
<fileext>pdf</fileext>
```
4. **消息元数据fileDownloadMeta**
```typescript
msg.fileDownloadMeta = {
url: "...",
fileName: "..."
}
```
5. **URL 扩展名匹配**
```
https://example.com/file.pdf
```
---
## 📝 文件消息数据结构
### **完整的文件数据结构**
```typescript
interface FileMessageData {
type: "file"; // 固定值
title?: string; // 文件名优先级1
fileName?: string; // 文件名优先级2
filename?: string; // 文件名优先级3
url?: string; // 文件下载URL
fileext?: string; // 文件扩展名
size?: number | string; // 文件大小
isDownloading?: boolean; // 是否正在下载
contentXml?: string; // XML格式的文件信息
[key: string]: any; // 其他扩展字段
}
```
---
## 🔧 文件渲染逻辑
### **1. 简单文件渲染renderFileContent**
适用于直接是文件 URL 的情况:
```typescript
const renderFileContent = (url: string) => {
const fileName = url.split("/").pop()?.split("?")[0] || "文件";
const displayName = fileName.length > 20
? `${fileName.substring(0, 20)}...`
: fileName;
return (
<div className={styles.fileMessage}>
<div className={styles.fileCard}>
<div className={styles.fileIcon}>📄</div>
<div className={styles.fileInfo}>
<div className={styles.fileName}>{displayName}</div>
<div className={styles.fileAction} onClick={() => openInNewTab(url)}>
点击查看
</div>
</div>
</div>
</div>
);
};
```
---
### **2. 复杂文件渲染SmallProgramMessage**
适用于需要下载的文件:
```typescript
// 文件图标映射
const iconMap: Record<string, string> = {
pdf: "📕",
doc: "📘", docx: "📘",
xls: "📗", xlsx: "📗",
ppt: "📙", pptx: "📙",
txt: "📝",
zip: "🗜️", rar: "🗜️", "7z": "🗜️",
jpg: "🖼️", jpeg: "🖼️", png: "🖼️", gif: "🖼️",
mp4: "🎬", avi: "🎬", mov: "🎬",
mp3: "🎵", wav: "🎵", flac: "🎵",
};
// 文件操作
- 如果有 URL显示"点击查看",直接打开
- 如果没有 URL显示"下载"按钮,调用下载接口
- 下载中:显示"下载中...",禁用操作
```
---
## 📋 消息样本示例
### **示例1JSON 格式文件消息**
```json
{
"type": "file",
"title": "项目报告.pdf",
"fileName": "项目报告.pdf",
"url": "https://example.com/files/report.pdf",
"fileext": "pdf",
"size": 2048576
}
```
---
### **示例2XML 格式文件消息**
```xml
<msg>
<title><![CDATA[数据表格.xlsx]]></title>
<fileext><![CDATA[xlsx]]></fileext>
<totallen>1048576</totallen>
</msg>
```
---
### **示例3混合格式JSON + XML**
```json
{
"type": "file",
"contentXml": "<title>演示文档.pptx</title><fileext>pptx</fileext><filesize>5242880</filesize>"
}
```
---
### **示例4直接文件URL**
```
https://cdn.example.com/files/document.docx
```
---
### **示例5通过 fileDownloadMeta**
```typescript
{
msgType: 49,
content: "...",
fileDownloadMeta: {
url: "https://example.com/file.pdf",
fileName: "重要文件.pdf",
fileext: "pdf",
size: 1024000,
isDownloading: false
}
}
```
---
## 🔄 文件下载流程
### **下载命令**
```typescript
sendCommand("CmdDownloadFile", {
wechatAccountId: contract.wechatAccountId,
friendMessageId: contract.chatroomId ? 0 : msg.id,
chatroomMessageId: contract.chatroomId ? msg.id : 0,
});
```
**状态管理**
- `setFileDownloading(msg.id, true)` - 设置下载状态
- `isDownloading` - 判断是否正在下载
---
## 📌 关键特征总结
### **判断文件类型消息的关键点**
1. ✅ **msgType === 49** 且内容符合文件格式
2. ✅ **URL 扩展名匹配** `FILE_EXT_REGEX`
3. ✅ **JSON 中 `type === "file"`**
4. ✅ **XML 中包含文件信息标签**`<title>`, `<fileext>`, `<totallen>`
5. ✅ **存在 `msg.fileDownloadMeta` 元数据**
### **文件信息提取顺序**
1. `messageData.type === "file"` JSON对象
2. `messageData.contentXml` XML字符串
3. `rawContent` 直接解析为 XML
4. `msg.fileDownloadMeta` (消息元数据)
### **文件信息字段优先级**
**文件名**
1. `title`
2. `fileName`
3. `filename`
4. URL 中的文件名
**文件扩展名**
1. `fileext`
2. 从文件名中提取
**文件大小**
1. `<totallen>` (XML)
2. `<filesize>` (XML)
3. `size` (JSON)
---
## 🎨 UI 渲染特征
- **文件卡片样式**`.fileMessage > .fileCard`
- **文件图标**:根据扩展名显示不同图标
- **文件名**超过20字符自动截断
- **操作按钮**
- 有 URL`点击查看`
- 无 URL 未下载:`下载`
- 下载中:`下载中...`(禁用)
---
## 💡 注意事项
1. **文件类型判断是多层次的**,需要按优先级依次尝试
2. **XML 解析需要兼容多种格式**CDATA 和普通文本)
3. **文件下载需要区分好友消息和群聊消息**
4. **文件大小可能是字符串或数字**,需要统一处理
5. **文件名可能包含特殊字符**,需要妥善处理显示
---
## 📚 相关代码位置
- **文件类型判断**`MessageRecord/index.tsx` (line 21, 115-116, 339-340)
- **文件内容渲染**`MessageRecord/index.tsx` (line 92-110)
- **复杂文件处理**`components/SmallProgramMessage/index.tsx`
- **文件信息提取**`components/SmallProgramMessage/index.tsx` (line 27-99, 101-151)
---
**文档生成时间**2025-01-21
**分析基于**:旧版 MessageRecord 组件逻辑

View File

@@ -27,28 +27,52 @@ const EditMomentModal: React.FC<EditMomentModalProps> = ({
url: "",
});
// 将发布时间转为 datetime-local 所需格式 "YYYY-MM-DDTHH:mm"(本地时间)
const toDatetimeLocalValue = (
raw: number | string | undefined | null,
): string => {
if (raw == null || raw === "") return "";
const num = Number(raw);
let date: Date;
if (Number.isFinite(num)) {
if (num === 0) return ""; // 0 视为未设置
date = new Date(num < 1e12 ? num * 1000 : num);
} else {
date = new Date(String(raw)); // 接口可能返回 "2024-01-01 12:00:00" 等字符串
}
if (Number.isNaN(date.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
useEffect(() => {
if (visible && momentData) {
// 填充表单数据
// 发布时间:优先 sendTime兼容 timingTime 或其它字段
const rawTime =
momentData.sendTime ??
(momentData as any).timingTime ??
(momentData as any).publishTime;
const sendTimeStr = toDatetimeLocalValue(rawTime);
// 填充表单数据(列表接口返回 content兼容可能的 text 字段)
form.setFieldsValue({
content: momentData.text,
content: momentData.content ?? (momentData as any).text ?? "",
type: momentData.momentContentType.toString(),
sendTime: momentData.sendTime
? new Date(momentData.sendTime * 1000).toISOString().slice(0, 16)
: "",
sendTime: sendTimeStr,
});
setContentType(momentData.momentContentType);
setResUrls(momentData.picUrlList || []);
// 处理链接数据
if (momentData.link && momentData.link.length > 0) {
setLinkData({
desc: momentData.link[0] || "",
image: "",
url: momentData.link[0] || "",
});
}
setLinkData(
momentData.link && momentData.link.length > 0
? {
desc: momentData.link[0] || "",
image: "",
url: momentData.link[0] || "",
}
: { desc: "", image: "", url: "" },
);
}
}, [visible, momentData, form]);
@@ -57,11 +81,24 @@ const EditMomentModal: React.FC<EditMomentModalProps> = ({
const values = await form.validateFields();
setLoading(true);
// 发布账号:从详情 accounts 取 wechatAccountId 传给接口accountCount 是数量不是 ID
const accounts = (momentData as any)?.accounts as
| { wechatAccountId: number }[]
| undefined;
const wechatIds =
accounts?.length > 0
? accounts.map(a => String(a.wechatAccountId))
: [];
if (wechatIds.length === 0) {
message.warning("请选择发布账号");
setLoading(false);
return;
}
const updateData: any = {
id: momentData?.id,
content: values.content,
type: values.type,
"wechatIds[]": [momentData?.accountCount || 1], // 这里需要根据实际情况调整
wechatIds: wechatIds,
};
// 根据内容类型添加相应字段
@@ -96,15 +133,10 @@ const EditMomentModal: React.FC<EditMomentModalProps> = ({
updateData.timingTime = values.sendTime;
}
const success = await updateMoment(updateData);
if (success) {
message.success("更新成功!");
onSuccess();
onCancel();
} else {
message.error("更新失败,请重试");
}
await updateMoment(updateData);
message.success("更新成功!");
onSuccess();
onCancel();
} catch (error) {
console.error("更新失败:", error);
message.error("更新失败,请重试");

View File

@@ -94,7 +94,7 @@ const PreviewMomentModal: React.FC<PreviewMomentModalProps> = ({
</div>
<div className="preview-content">
<div className="preview-text">{momentData.text || "无文本内容"}</div>
<div className="preview-text">{momentData.content ?? (momentData as any).text ?? "无文本内容"}</div>
{/* 图片预览 */}
{momentData.picUrlList && momentData.picUrlList.length > 0 && (

View File

@@ -28,7 +28,7 @@ import {
FileTextOutlined,
AppstoreOutlined,
} from "@ant-design/icons";
import { getMomentList, deleteMoment, listData } from "./api";
import { getMomentList, getMomentDetail, deleteMoment, listData } from "./api";
import EditMomentModal from "./EditMomentModal";
import PreviewMomentModal from "./PreviewMomentModal";
import styles from "./PublishSchedule.module.scss";
@@ -134,8 +134,18 @@ const PublishSchedule = forwardRef<PublishScheduleRef>((props, ref) => {
}
};
const handleEditPost = (post: listData) => {
setSelectedMoment(post);
const handleEditPost = async (post: listData) => {
// 列表项可能没有 accounts编辑保存需要 wechatIds故无 accounts 时拉取详情
if (!post.accounts?.length) {
try {
const detail = await getMomentDetail(post.id);
setSelectedMoment(detail);
} catch {
setSelectedMoment(post);
}
} else {
setSelectedMoment(post);
}
setEditModalVisible(true);
};

View File

@@ -1,4 +1,13 @@
import request from "@/api/request";
/** 详情/列表中的账号项 */
export interface MomentAccountItem {
wechatAccountId: number;
wechatId?: string;
nickName?: string;
avatar?: string;
labels?: unknown[];
}
export interface listData {
id: number;
content: "";
@@ -11,6 +20,8 @@ export interface listData {
createTime: number;
sendTime: number;
accountCount: number;
/** 发布账号列表(详情/列表可能返回) */
accounts?: MomentAccountItem[];
}
interface listResponse {
@@ -25,6 +36,11 @@ export const getMomentList = (data: {
return request("/v1/kefu/moments/list", data, "GET");
};
// 朋友圈定时发布 - 详情(含 accounts编辑时用于回填发布账号
export const getMomentDetail = (id: number): Promise<listData> => {
return request(`/v1/kefu/moments/detail`, { id }, "GET");
};
export interface MomentRequest {
id?: number;
/**

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Collapse } from "antd";
import { ChromeOutlined } from "@ant-design/icons";
import { MomentList } from "./components/friendCard";
@@ -26,20 +26,17 @@ const FriendsCircle: React.FC<FriendsCircleProps> = ({ wechatFriendId }) => {
state => state.MomentCommonLoading,
);
// 页面重新渲染时重置MomentCommonLoading状态
useEffect(() => {
updateMomentCommonLoading(false);
}, []);
// 状态管理
// 状态管理(必须在所有 useEffect 之前声明)
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
// 当前页码,用于分页
const currentPageRef = useRef<number>(1);
// 当前场景的 wechatId用于好友朋友圈
const friendWechatIdRef = useRef<string | undefined>(undefined);
// 保存上一次的客服ID用于检测客服切换
const previousCustomerIdRef = useRef<number | null>(null);
// 加载朋友圈数据
const loadMomentData = async (
const loadMomentData = useCallback(async (
loadMore: boolean = false,
forceKey?: string,
) => {
@@ -196,7 +193,67 @@ const FriendsCircle: React.FC<FriendsCircleProps> = ({ wechatFriendId }) => {
} finally {
updateMomentCommonLoading(false);
}
};
}, [
currentCustomer,
expandedKeys,
wechatFriendId,
friendWechatIdRef,
currentPageRef,
updateMomentCommonLoading,
addMomentCommon,
updateMomentCommon,
]);
// 页面重新渲染时重置MomentCommonLoading状态
useEffect(() => {
updateMomentCommonLoading(false);
}, [updateMomentCommonLoading]);
// 监听客服切换,重新加载朋友圈数据
useEffect(() => {
const currentCustomerId = currentCustomer?.id || null;
// 如果是首次渲染只记录当前客服ID不触发加载
if (previousCustomerIdRef.current === null) {
previousCustomerIdRef.current = currentCustomerId;
return;
}
// 检测客服是否切换ID变化
const isCustomerSwitched =
previousCustomerIdRef.current !== currentCustomerId;
if (isCustomerSwitched) {
console.log("🔄 检测到客服切换:", {
previousId: previousCustomerIdRef.current,
currentId: currentCustomerId,
});
// 更新保存的客服ID
previousCustomerIdRef.current = currentCustomerId;
// 如果当前展开的是"我的朋友圈"key === "1"),需要重新加载数据
const currentKey = expandedKeys[0];
if (currentKey === "1") {
console.log("✅ 客服切换且当前展开'我的朋友圈',重新加载数据");
// 清空旧数据
clearMomentCommon();
// 重置页码
currentPageRef.current = 1;
// 重新加载数据
loadMomentData(false, "1");
} else if (currentKey) {
// 如果是其他场景(朋友圈广场或好友朋友圈),也清空数据但不需要重新加载
// 因为朋友圈广场不依赖客服,好友朋友圈依赖好友而非客服
console.log(
" 客服切换但当前场景不依赖客服,仅清空数据",
currentKey,
);
// 可以选择是否清空数据,这里选择清空以避免显示错误数据
clearMomentCommon();
}
}
}, [currentCustomer?.id, expandedKeys, clearMomentCommon, loadMomentData]);
// 处理折叠面板展开/收起
const handleCollapseChange = (keys: string | string[]) => {

View File

@@ -0,0 +1,69 @@
// 文件消息样式
.fileMessage {
.fileCard {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #fafafa;
cursor: pointer;
transition: all 0.2s;
width: 280px;
&:hover {
background: #f0f0f0;
border-color: #1890ff;
}
}
.fileIcon {
font-size: 24px;
color: #1890ff;
flex-shrink: 0;
}
.fileInfo {
flex: 1;
min-width: 0;
}
.fileName {
font-weight: 500;
color: #262626;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
line-height: 1.4;
}
.fileAction {
font-size: 12px;
color: #1890ff;
cursor: pointer;
user-select: none;
&:hover {
text-decoration: underline;
}
}
.fileActionDisabled {
color: #999;
cursor: not-allowed;
pointer-events: none;
}
}
// 通用消息文本样式
.messageText {
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
color: #999;
font-size: 13px;
padding: 8px 12px;
}

View File

@@ -0,0 +1,354 @@
import React from "react";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { getWechatFriendDetail } from "../../api";
import styles from "./FileMessage.module.scss";
const FILE_MESSAGE_TYPE = "file";
/**
* 文件消息数据结构
*/
interface FileMessageData {
type: string;
title?: string;
fileName?: string;
filename?: string;
url?: string;
isDownloading?: boolean;
fileext?: string;
size?: number | string;
[key: string]: any;
}
/**
* 从 XML 字符串中提取文件信息
*/
const extractFileInfoFromXml = (source: string): FileMessageData | null => {
if (typeof source !== "string") {
return null;
}
const trimmed = source.trim();
if (!trimmed) {
return null;
}
try {
// 方法1: 使用 DOMParser 解析 XML
if (typeof DOMParser !== "undefined") {
const parser = new DOMParser();
const doc = parser.parseFromString(trimmed, "text/xml");
if (doc.getElementsByTagName("parsererror").length === 0) {
const titleNode = doc.getElementsByTagName("title")[0];
const fileExtNode = doc.getElementsByTagName("fileext")[0];
const sizeNode =
doc.getElementsByTagName("totallen")[0] ||
doc.getElementsByTagName("filesize")[0];
const result: FileMessageData = { type: FILE_MESSAGE_TYPE };
const titleText = titleNode?.textContent?.trim();
if (titleText) {
result.title = titleText;
}
const fileExtText = fileExtNode?.textContent?.trim();
if (fileExtText) {
result.fileext = fileExtText;
}
const sizeText = sizeNode?.textContent?.trim();
if (sizeText) {
const sizeNumber = Number(sizeText);
result.size = Number.isNaN(sizeNumber) ? sizeText : sizeNumber;
}
return result;
}
}
} catch (error) {
console.warn("extractFileInfoFromXml parse failed:", error);
}
// 方法2: 使用正则表达式匹配(备用方案)
const regexTitle =
trimmed.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/i) ||
trimmed.match(/<title>([^<]+)<\/title>/i);
const regexExt =
trimmed.match(/<fileext><!\[CDATA\[(.*?)\]\]><\/fileext>/i) ||
trimmed.match(/<fileext>([^<]+)<\/fileext>/i);
const regexSize =
trimmed.match(/<totallen>([^<]+)<\/totallen>/i) ||
trimmed.match(/<filesize>([^<]+)<\/filesize>/i);
if (!regexTitle && !regexExt && !regexSize) {
return null;
}
const fallback: FileMessageData = { type: FILE_MESSAGE_TYPE };
if (regexTitle?.[1]) {
fallback.title = regexTitle[1].trim();
}
if (regexExt?.[1]) {
fallback.fileext = regexExt[1].trim();
}
if (regexSize?.[1]) {
const sizeNumber = Number(regexSize[1]);
fallback.size = Number.isNaN(sizeNumber)
? regexSize[1].trim()
: sizeNumber;
}
return fallback;
};
/**
* 解析文件消息数据
* 优先级JSON.type === "file" > JSON.contentXml > rawContent XML > msg.fileDownloadMeta
*/
const resolveFileMessageData = (
messageData: any,
msg: ChatRecord,
rawContent: string,
): FileMessageData | null => {
// 从消息元数据中获取文件信息
const meta =
msg?.fileDownloadMeta && typeof msg.fileDownloadMeta === "object"
? { ...(msg.fileDownloadMeta as Record<string, any>) }
: null;
// 优先级1: JSON对象中 type === "file"
if (messageData && typeof messageData === "object") {
if (messageData.type === FILE_MESSAGE_TYPE) {
return {
type: FILE_MESSAGE_TYPE,
...messageData,
...(meta || {}),
};
}
// 优先级2: JSON对象中的 contentXml 字段
if (typeof messageData.contentXml === "string") {
const xmlData = extractFileInfoFromXml(messageData.contentXml);
if (xmlData || meta) {
return {
...(xmlData || {}),
...(meta || {}),
type: FILE_MESSAGE_TYPE,
};
}
}
}
// 优先级3: 原始内容解析为XML
if (typeof rawContent === "string") {
const xmlData = extractFileInfoFromXml(rawContent);
if (xmlData || meta) {
return {
...(xmlData || {}),
...(meta || {}),
type: FILE_MESSAGE_TYPE,
};
}
}
// 优先级4: 消息元数据
if (meta) {
return {
type: FILE_MESSAGE_TYPE,
...meta,
};
}
return null;
};
/**
* 判断是否为JSON格式字符串
*/
const isJsonLike = (value: string) => {
const trimmed = value.trim();
return trimmed.startsWith("{") && trimmed.endsWith("}");
};
interface FileMessageProps {
content: string;
msg: ChatRecord;
contract: ContractData | weChatGroup;
parsedJson?: any; // 可选已解析的JSON
}
/**
* 文件消息组件
* 用于渲染各种文件类型的消息pdf、doc、xls、ppt等
*/
export const FileMessage: React.FC<FileMessageProps> = ({
content,
msg,
contract,
parsedJson,
}) => {
const sendCommand = useWebSocketStore(state => state.sendCommand);
const setFileDownloading = useWeChatStore(state => state.setFileDownloading);
// 统一的错误消息渲染函数
const renderErrorMessage = (fallbackText: string) => (
<div className={styles.messageText}>{fallbackText}</div>
);
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[文件消息 - 无效内容]");
}
try {
const trimmedContent = content.trim();
const isJsonContent = isJsonLike(trimmedContent);
const messageData = parsedJson || (isJsonContent ? JSON.parse(trimmedContent) : null);
// 确定用于解析的内容源
const rawContentForResolve =
messageData && typeof messageData.contentXml === "string"
? messageData.contentXml
: trimmedContent;
// 解析文件消息数据
const fileMessageData = resolveFileMessageData(
messageData,
msg,
rawContentForResolve,
);
if (!fileMessageData || fileMessageData.type !== FILE_MESSAGE_TYPE) {
return renderErrorMessage("[文件消息 - 解析失败]");
}
// 提取文件信息
const {
url = "",
title,
fileName,
filename,
fileext,
isDownloading = false,
} = fileMessageData;
// 解析文件名优先级title > fileName > filename > URL中提取
const resolvedFileName =
title ||
fileName ||
filename ||
(typeof url === "string" && url
? url.split("/").pop()?.split("?")[0]
: "") ||
"文件";
// 解析文件扩展名
const resolvedExtension = (
fileext ||
resolvedFileName.split(".").pop() ||
""
).toLowerCase();
// 文件图标映射
const iconMap: Record<string, string> = {
pdf: "📕",
doc: "📘",
docx: "📘",
xls: "📗",
xlsx: "📗",
ppt: "📙",
pptx: "📙",
txt: "📝",
zip: "🗜️",
rar: "🗜️",
"7z": "🗜️",
jpg: "🖼️",
jpeg: "🖼️",
png: "🖼️",
gif: "🖼️",
mp4: "🎬",
avi: "🎬",
mov: "🎬",
mp3: "🎵",
wav: "🎵",
flac: "🎵",
};
const fileIcon = iconMap[resolvedExtension] || "📄";
// 判断是否有可用的文件URL
const isUrlAvailable =
typeof url === "string" && url.trim().length > 0;
// 文件下载处理函数
const handleFileDownload = () => {
if (isDownloading || !contract || !msg?.id) return;
setFileDownloading(msg.id, true);
sendCommand("CmdDownloadFile", {
wechatAccountId: contract.wechatAccountId,
friendMessageId: contract.chatroomId ? 0 : msg.id,
chatroomMessageId: contract.chatroomId ? msg.id : 0,
});
};
// 操作按钮文本和状态
const actionText = isUrlAvailable
? "点击查看"
: isDownloading
? "下载中..."
: "下载";
const actionDisabled = !isUrlAvailable && isDownloading;
// 操作按钮点击处理
const handleActionClick = (event: React.MouseEvent) => {
event.stopPropagation();
if (isUrlAvailable) {
try {
window.open(url, "_blank");
} catch (e) {
console.error("文件打开失败:", e);
}
return;
}
handleFileDownload();
};
return (
<div className={styles.fileMessage}>
<div
className={styles.fileCard}
onClick={() => {
if (isUrlAvailable) {
window.open(url, "_blank");
} else if (!isDownloading) {
handleFileDownload();
}
}}
>
<div className={styles.fileIcon}>{fileIcon}</div>
<div className={styles.fileInfo}>
<div className={styles.fileName}>
{resolvedFileName.length > 20
? resolvedFileName.substring(0, 20) + "..."
: resolvedFileName}
</div>
<div
className={`${styles.fileAction} ${
actionDisabled ? styles.fileActionDisabled : ""
}`}
onClick={handleActionClick}
>
{actionText}
</div>
</div>
</div>
</div>
);
} catch (e) {
console.warn("文件消息解析失败:", e);
return renderErrorMessage("[文件消息 - 解析失败]");
}
};
export default FileMessage;

View File

@@ -2,8 +2,61 @@ import React from "react";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import SmallProgramMessage from "../components/SmallProgramMessage";
import { ArticleMessage } from "./ArticleMessage";
import { FileMessage } from "../components/FileMessage";
import { MessageTypeNodeProps } from "./messageTypeConfig";
/**
* 检测是否为文件消息
*/
const isFileMessage = (
parsedJson: any,
content: string,
msg: ChatRecord,
): boolean => {
// 方法1: JSON 中 type === "file"
if (parsedJson?.type === "file") {
return true;
}
// 方法2: JSON 中存在 contentXml且包含文件相关标签
if (parsedJson?.contentXml) {
const xmlContent = String(parsedJson.contentXml);
if (
xmlContent.includes("<title>") &&
(xmlContent.includes("<fileext>") ||
xmlContent.includes("<totallen>") ||
xmlContent.includes("<filesize>"))
) {
return true;
}
}
// 方法3: 原始内容中包含文件相关XML标签
if (
typeof content === "string" &&
(content.includes("<title>") ||
content.includes("<fileext>") ||
content.includes("<totallen>") ||
content.includes("<filesize>"))
) {
// 排除小程序消息(小程序消息通常包含 weappinfo
if (!content.includes("<weappinfo>")) {
return true;
}
}
// 方法4: 消息元数据中存在文件信息
if (
msg?.fileDownloadMeta &&
typeof msg.fileDownloadMeta === "object" &&
(msg.fileDownloadMeta as any).title
) {
return true;
}
return false;
};
/**
* msgType=49 复合消息类型渲染器
* 根据 content 内容判断具体类型:文章、小程序、文件等
@@ -11,12 +64,17 @@ import { MessageTypeNodeProps } from "./messageTypeConfig";
export const renderMsgType49 = (props: MessageTypeNodeProps): React.ReactNode => {
const { content, msg, contract, parsedJson } = props;
// 1. 检测文消息type: "link"
// 1. 检测文消息(优先级最高,避免被误判为小程序)
if (isFileMessage(parsedJson, content, msg)) {
return <FileMessage content={content} msg={msg} contract={contract} parsedJson={parsedJson} />;
}
// 2. 检测文章消息type: "link"
if (parsedJson?.type === "link") {
return <ArticleMessage content={content} />;
}
// 2. 检测小程序消息:包含 XML 标签或被截断的内容
// 3. 检测小程序消息:包含 XML 标签或被截断的内容
// 注意:[该消息内容过长已截断] 说明 JSON 不完整,不要尝试解析,直接用内容特征判断
if (
content.includes("<weappinfo>") ||
@@ -27,6 +85,6 @@ export const renderMsgType49 = (props: MessageTypeNodeProps): React.ReactNode =>
return <SmallProgramMessage content={content} msg={msg} contract={contract} />;
}
// 3. 兜底:使用 SmallProgramMessage 处理(包含文件等其他类型)
// 4. 兜底:使用 SmallProgramMessage 处理(包含其他未识别类型)
return <SmallProgramMessage content={content} msg={msg} contract={contract} />;
};

View File

@@ -7,6 +7,7 @@ import LocationMessage from "../components/LocationMessage";
import SystemRecommendRemarkMessage from "../components/SystemRecommendRemarkMessage/index";
import RedPacketMessage from "../components/RedPacketMessage";
import TransferMessage from "../components/TransferMessage";
import { FileMessage } from "../components/FileMessage";
import { TextMessage } from "./TextMessage";
import { ImageMessage } from "./ImageMessage";
import { EmojiMessage } from "./EmojiMessage";
@@ -286,6 +287,60 @@ export const SPECIAL_TYPE_DETECTORS: Array<{
nodeFunc: ({ content }) => <ImageMessage content={content} />,
},
/**
* 文件消息msgType=49的补充检测
* 当 msgType 未知但内容是文件时,可以通过内容特征识别
*/
{
name: "文件",
priority: 90,
detector: (content, parsedJson) => {
// 检测 JSON 格式的文件消息
if (parsedJson?.type === "file") {
return true;
}
// 检测 JSON 中的 contentXml 包含文件信息
if (parsedJson?.contentXml) {
const xmlContent = String(parsedJson.contentXml);
if (
xmlContent.includes("<title>") &&
(xmlContent.includes("<fileext>") ||
xmlContent.includes("<totallen>") ||
xmlContent.includes("<filesize>"))
) {
// 排除小程序(小程序通常包含 weappinfo
if (!xmlContent.includes("<weappinfo>")) {
return true;
}
}
}
// 检测原始内容中的文件XML标签
if (typeof content === "string") {
if (
(content.includes("<title>") &&
(content.includes("<fileext>") ||
content.includes("<totallen>") ||
content.includes("<filesize>"))) &&
!content.includes("<weappinfo>")
) {
return true;
}
}
return false;
},
nodeFunc: ({ content, msg, contract, parsedJson }) => (
<FileMessage
content={content}
msg={msg}
contract={contract}
parsedJson={parsedJson}
/>
),
},
/**
* 表情包msgType=47的补充检测
*/