Merge branch 'develop' of https://gitee.com/cunkebao/CKB-Touchkebao into develop

# Conflicts:
#	old/src/api/types.ts
#	old/src/hooks/weChat/useMessageTypeParser.tsx
#	old/src/utils/messagePreview.ts
This commit is contained in:
乘风
2026-02-04 11:31:12 +08:00
53 changed files with 4952 additions and 2336 deletions

View File

@@ -0,0 +1,338 @@
# msgType=49 拆分:文章 vs 小程序
## 📋 问题背景
`msgType=49` 是一个**复合类型**,包含多种消息子类型:
- 📰 文章/链接
- 🎮 小程序
- 📄 文件
- 💰 红包
- 💸 转账
之前使用同一个 `SmallProgramMessage` 组件处理所有情况,不够精细。
## 🔍 数据结构分析
### 1. 文章类型特征
```json
{
"msgType": 49,
"content": "{
\"type\": \"link\",
\"title\": \"新晋打工皇帝周受资\",
\"desc\": \"打工人的江湖里...\",
\"thumbPath\": \"https://...\",
\"url\": \"https://mp.weixin.qq.com/s?__biz=...\",
}"
}
```
**特征**
-`content` 是纯 JSON 格式
- ✅ 包含 `type: "link"`
- ✅ 包含 `title``url``desc``thumbPath` 字段
-**没有** `contentXml` 字段
-**没有** `<weappinfo>` 标签
### 2. 小程序类型特征
```json
{
"msgType": 49,
"content": "[该消息内容过长已截断]{
\"contentXml\": \"<?xml version=\\\"1.0\\\"?>\\n<msg>\\n\\t<appmsg>...
<weappinfo>
<username>gh_335906d9a6a1@app</username>
<appid>wxb0656180c68edbdc</appid>
...
</weappinfo>
...\"
}"
}
```
**特征**
-`content` 可能以 `[该消息内容过长已截断]` 开头
- ✅ 包含 `contentXml` 字段
-`contentXml` 内容是 XML 格式
- ✅ 包含 `<weappinfo>` 标签
- ✅ 包含 `gh_xxx@app` 格式的用户名
- ✅ 包含小程序相关的 `appid``pagepath`
## ✅ 解决方案
### 1. 创建独立的 `ArticleMessage` 组件
**文件**: `src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/ArticleMessage.tsx`
```typescript
export const ArticleMessage: React.FC<ArticleMessageProps> = ({ content }) => {
const articleData = typeof content === "string" ? JSON.parse(content) : content;
const { title, desc, thumbPath, url } = articleData;
return (
<div onClick={() => window.open(url, "_blank")}>
{/* 封面图 */}
{thumbPath && <img src={thumbPath} />}
{/* 标题 */}
{title && <div>{title}</div>}
{/* 描述 */}
{desc && <div>{desc}</div>}
{/* 链接标识 */}
<div>🔗 点击查看文章</div>
</div>
);
};
```
### 2. 更新 `messageTypeConfig.tsx`
#### 移除 msgType=49 的直接配置
```typescript
// ❌ 旧方式:直接配置 msgType=49
49: {
type: "小程序/文章",
nodeFunc: ({ content, msg, contract }) => (
<SmallProgramMessage content={content} msg={msg} contract={contract} />
),
},
// ✅ 新方式:不配置,让检测器处理
// 49 类型由 SPECIAL_TYPE_DETECTORS 检测器处理,不在此配置
```
#### 添加文章和小程序检测器
```typescript
export const SPECIAL_TYPE_DETECTORS = [
/**
* 文章消息msgType=49
* 优先级92高于小程序
*/
{
name: "文章",
priority: 92,
detector: (content, parsedJson) => {
// 方式1: 通过 type 字段
if (parsedJson && parsedJson.type === "link") {
return true;
}
// 方式2: 通过字段组合判断
if (
parsedJson &&
parsedJson.title &&
parsedJson.url &&
typeof parsedJson.url === "string" &&
parsedJson.url.startsWith("http")
) {
// 必须有url且不能包含小程序特征
return !parsedJson.contentXml && !content.includes("<weappinfo>");
}
return false;
},
nodeFunc: ({ content }) => <ArticleMessage content={content} />,
},
/**
* 小程序消息msgType=49
* 优先级91低于文章
*/
{
name: "小程序",
priority: 91,
detector: (content, parsedJson) => {
// 方式1: 包含 contentXml 字段
if (parsedJson && parsedJson.contentXml) {
return true;
}
// 方式2: 内容包含小程序特征标签
if (
content.includes("<weappinfo>") ||
content.includes("gh_") ||
content.includes("@app")
) {
return true;
}
// 方式3: 包含截断前缀(通常是小程序)
if (content.startsWith("[该消息内容过长已截断]")) {
return true;
}
return false;
},
nodeFunc: ({ content, msg, contract }) => (
<SmallProgramMessage content={content} msg={msg} contract={contract} />
),
},
];
```
## 📊 判断逻辑流程
```
msgType = 49
解析 content 为 JSON
检测器优先级排序执行:
┌─────────────────────────────────┐
│ 1. 红包检测器 (priority: 95) │ → 包含 nativeurl 红包链接?
│ 2. 转账检测器 (priority: 95) │ → title = "微信转账"
│ 3. 文章检测器 (priority: 92) ⭐ │ → type="link" 或 (有title+url且无contentXml)
│ 4. 小程序检测器 (priority: 91)⭐│ → 有contentXml 或 <weappinfo> 或 gh_@app
│ 5. 文件检测器 (priority: 75) │ → type="file" 或 URL是文件扩展名
└─────────────────────────────────┘
匹配成功 → 使用对应的 nodeFunc 渲染
未匹配 → 使用 UnknownMessage 兜底
```
## 🎯 检测优先级说明
| 优先级 | 类型 | 原因 |
|--------|------|------|
| 95 | 红包/转账 | 最高优先级,避免被误判为其他类型 |
| **92** | **文章** | 高于小程序,因为文章结构更简单明确 |
| **91** | **小程序** | 低于文章,避免误判文章为小程序 |
| 85 | 视频 | 中等优先级 |
| 80 | 图片 | 中等优先级 |
| 75 | 文件 | 较低优先级 |
| 70 | 表情包 | 最低优先级 |
## 🔧 关键判断条件
### 文章检测条件(满足任一即可)
1. **主要条件**(最可靠):
```typescript
parsedJson.type === "link"
```
2. **备用条件**(字段组合):
```typescript
parsedJson.title &&
parsedJson.url &&
parsedJson.url.startsWith("http") &&
!parsedJson.contentXml &&
!content.includes("<weappinfo>")
```
### 小程序检测条件(满足任一即可)
1. **主要条件**(最可靠):
```typescript
parsedJson.contentXml
```
2. **备用条件1**XML标签:
```typescript
content.includes("<weappinfo>")
```
3. **备用条件2**(小程序账号格式):
```typescript
content.includes("gh_") || content.includes("@app")
```
4. **备用条件3**(截断标识):
```typescript
content.startsWith("[该消息内容过长已截断]")
```
## 🎨 UI 渲染效果
### 文章消息
```
┌──────────────────────────────┐
│ [封面图] │
│ │
├──────────────────────────────┤
│ 新晋打工皇帝周受资 │
│ │
│ 打工人的江湖里,有皇帝之称的 │
│ 唐骏,也有皇后之名的吴士宏... │
│ │
│ 🔗 点击查看文章 │
└──────────────────────────────┘
```
### 小程序消息
```
┌──────────────────────────────┐
│ [小程序缩略图] │
│ │
│ 八达通充值 Octopus Reloading │
│ │
│ [小程序图标] 小程序 │
└──────────────────────────────┘
```
## 📝 使用示例
### 控制台调试日志
```javascript
// 文章消息
console.log("✅ 🔍 推导出类型: 文章 (msgType=49)");
// 小程序消息
console.log("✅ 🔍 推导出类型: 小程序 (msgType=49)");
```
### 实际运行效果
当收到 `msgType=49` 的消息时:
1. `useMessageTypeParser` Hook 检测到 `msgType=49`
2. 在 `MESSAGE_TYPE_MAP` 中未找到直接配置
3. 进入 `SPECIAL_TYPE_DETECTORS` 检测流程
4. 按优先级依次执行检测器
5. **文章检测器**优先级92先执行
- 如果 `content` 包含 `type: "link"` → 渲染 `ArticleMessage`
6. **小程序检测器**优先级91后执行
- 如果 `content` 包含 `contentXml` → 渲染 `SmallProgramMessage`
## ✅ 测试清单
- [x] 文章消息正确识别并使用 `ArticleMessage` 渲染
- [x] 小程序消息正确识别并使用 `SmallProgramMessage` 渲染
- [x] 文件、红包、转账等其他 msgType=49 子类型不受影响
- [x] 无 linter 错误
- [x] 控制台有正确的调试日志
- [x] UI 渲染效果符合预期
## 🎉 总结
### 核心改进
1. **组件拆分**
- ✅ 创建独立的 `ArticleMessage.tsx` 组件
- ✅ `SmallProgramMessage` 专注处理小程序
2. **检测逻辑**
- ✅ 文章检测器优先级92先于小程序优先级91
- ✅ 多种检测条件,容错性强
- ✅ 负向检测(排除非文章特征)
3. **代码质量**
- ✅ 职责分离,组件更专注
- ✅ 配置化管理,易于维护
- ✅ 调试信息完善
### 优势
| 项目 | 旧方式 | 新方式 |
|------|--------|--------|
| **组件数量** | 1个组件处理所有 | 文章+小程序 分离 |
| **判断位置** | 组件内部 | 配置检测器 |
| **扩展性** | 需修改组件 | 只需添加检测器 |
| **可读性** | 逻辑混杂 | 清晰明确 |
**现在 msgType=49 的每种子类型都有专属的处理逻辑了!** 🎊

View File

@@ -0,0 +1,159 @@
# msgType=49 小程序消息真实案例测试
## 测试案例
### 案例 1八达通充值小程序Type 2
#### 原始数据
```
[该消息内容过长已截断]{"contentXml":"<?xml version=\"1.0\"?>\n<msg>\n\t<appmsg appid=\"\" sdkver=\"0\">\n\t\t<title>八达通充值 Octopus Reloading</title>\n\t\t<sourcedisplayname>八达通充值 Octopus Reloading</sourcedisplayname>\n\t\t<weappinfo>\n\t\t\t<type>2</type>\n\t\t\t<weappiconurl><![CDATA[http://wx.qlogo.cn/mmhead/K6CEv0Hv9Dd1oxclTbYft9ddwMMXMWbiaetYd5WXtiaBtRQW7JN8e2nZkKwF8pDXNianpxOYnDE1Fs/96]]></weappiconurl>\n\t\t</weappinfo>\n\t</appmsg>\n</msg>","type":"miniprogram"}
```
#### 关键信息提取
| 字段 | XML 标签 | 提取值 | 说明 |
|------|----------|--------|------|
| 小程序标题 | `<title>` | 八达通充值 Octopus Reloading | 显示在卡片上的标题 |
| 小程序名称 | `<sourcedisplayname>` | 八达通充值 Octopus Reloading | 显示在卡片底部 |
| 小程序类型 | `<weappinfo><type>` | 2 | Type 2 样式(大图展示) |
| 封面图 | `<weappiconurl>` | http://wx.qlogo.cn/mmhead/K6CEv0Hv9Dd1oxclTbYft9ddwMMXMWbiaetYd5WXtiaBtRQW7JN8e2nZkKwF8pDXNianpxOYnDE1Fs/96 | 小程序图标 |
#### 处理流程
1. **前缀处理**
```typescript
// 检测并去掉 [该消息内容过长已截断] 前缀
const truncatedPrefix = "[该消息内容过长已截断]";
if (trimmedContent.startsWith(truncatedPrefix)) {
trimmedContent = trimmedContent.substring(truncatedPrefix.length);
}
```
2. **JSON 解析**
```typescript
const messageData = JSON.parse(trimmedContent);
// messageData = { contentXml: "<?xml version...", type: "miniprogram" }
```
3. **完整解析尝试**
```typescript
try {
// 尝试使用 parseWeappMsgStr 完整解析
const parsedData = parseWeappMsgStr(trimmedContent);
} catch (parseError) {
// 如果失败(例如缺少必要字段),使用 fallback
}
```
4. **Fallback 提取**
```typescript
// 使用正则表达式从 XML 中提取关键信息
const fallbackInfo = extractMiniProgramInfoFromTruncatedXml(messageData.contentXml);
// 提取结果:
// {
// title: "八达通充值 Octopus Reloading",
// appName: "八达通充值 Octopus Reloading",
// miniProgramType: 2,
// previewImage: "http://wx.qlogo.cn/mmhead/..."
// }
```
5. **渲染小程序卡片**
```tsx
// 根据 miniProgramType = 2渲染 Type 2 样式
<div className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}>
<div className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}>
<div className={styles.miniProgramAppTop}>八达通充值 Octopus Reloading</div>
<div className={styles.miniProgramTitle}>八达通充值 Octopus Reloading</div>
<div className={styles.miniProgramImageArea}>
<img src="http://wx.qlogo.cn/mmhead/..." alt="小程序图片" />
</div>
<div className={styles.miniProgramContent}>
<div className={styles.miniProgramIdentifier}>小程序</div>
</div>
</div>
</div>
```
## 正则表达式说明
### 1. 提取 title
```javascript
/<title><!\[CDATA\[(.*?)\]\]><\/title>/i // 支持 CDATA 格式
/<title>([^<]+)<\/title>/i // 支持普通格式
```
**示例匹配:**
- `<title>八达通充值 Octopus Reloading</title>` ✅
- `<title><![CDATA[八达通充值]]></title>` ✅
- `<title />` ❌ 过滤空标签
### 2. 提取 sourcedisplayname
```javascript
/<sourcedisplayname><!\[CDATA\[(.*?)\]\]><\/sourcedisplayname>/i
/<sourcedisplayname>([^<]+)<\/sourcedisplayname>/i
```
**示例匹配:**
- `<sourcedisplayname>八达通充值 Octopus Reloading</sourcedisplayname>` ✅
### 3. 提取 weappinfo.type
```javascript
/<weappinfo>[\s\S]*?<type><!\[CDATA\[(.*?)\]\]><\/type>/i
/<weappinfo>[\s\S]*?<type>([^<]+)<\/type>/i
```
**示例匹配:**
- `<weappinfo>...<type>2</type>...</weappinfo>` ✅
- `[\s\S]*?` 匹配任意字符(包括换行)
### 4. 提取 weappiconurl
```javascript
/<weappiconurl><!\[CDATA\[(.*?)\]\]><\/weappiconurl>/i
/<weappiconurl>([^<]+)<\/weappiconurl>/i
```
**示例匹配:**
- `<weappiconurl><![CDATA[http://wx.qlogo.cn/...]]></weappiconurl>` ✅
- 自动清理 CDATA 标记、引号和 `&amp;`
## 调试日志
当消息被正确处理时,控制台会输出:
```
✅ 提取到小程序标题: 八达通充值 Octopus Reloading
✅ 提取到小程序名称 (sourcedisplayname): 八达通充值 Octopus Reloading
✅ 提取到小程序类型: 2
✅ 提取到小程序封面: http://wx.qlogo.cn/mmhead/K6CEv0Hv9Dd1oxclTbYft9ddwMMXMWbiaetYd5WXtiaBtRQW7JN8e2nZkKwF8pDXNianpxOYnDE1Fs/96
✅ XML 信息提取成功: {title: "八达通充值 Octopus Reloading", appName: "八达通充值 Octopus Reloading", miniProgramType: 2, previewImage: "http://..."}
```
## 容错能力
### ✅ 支持的情况
1. **完整 XML** - 使用 `parseWeappMsgStr` 完整解析
2. **残缺 XML** - 使用正则提取关键字段
3. **带前缀** - 自动去除 `[该消息内容过长已截断]`
4. **CDATA 格式** - 支持 `<![CDATA[...]]>` 包裹的内容
5. **空标签** - 自动过滤 `<title />` 等空标签
6. **转义字符** - 自动处理 `&amp;` 等 HTML 实体
### ❌ 不支持的情况
1. **关键字段缺失** - 如果 `title` 和 `sourcedisplayname` 都不存在,返回 null
2. **无效 URL** - 如果 `weappiconurl` 不包含 "http",不提取
3. **非小程序消息** - 如果没有 `<weappinfo>` 标签,可能无法识别类型
## 文件位置
- **组件文件**: `src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage/index.tsx`
- **提取函数**: `extractMiniProgramInfoFromTruncatedXml`(第 11-117 行)
- **调用位置**:
- `contentXml` 检测路径(第 194 行)
- `type === "miniprogram"` 兼容路径(第 346 行)
## 相关文档
- [消息类型配置指南](./消息类型配置指南.md)
- [msgType49拆分-文章vs小程序](./msgType49拆分-文章vs小程序.md)

View File

@@ -0,0 +1,145 @@
# 消息类型配置 - 快速参考
## 📍 核心配置位置
**文件**: `src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/messageTypeConfig.tsx`
## 🚀 添加新类型3步
### 1. 简单类型(直接添加)
```typescript
export const MESSAGE_TYPE_MAP: Record<number, MessageTypeConfig> = {
// ... 现有配置
// ⭐ 添加你的新类型
12345: {
type: "新类型名称",
nodeFunc: ({ content, parsedJson }) => (
<div>{content}</div>
),
},
}
```
### 2. 复杂类型(创建组件)
```bash
# 创建组件文件
src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/YourMessage.tsx
```
```typescript
// YourMessage.tsx
export const YourMessage: React.FC<Props> = ({ content }) => {
return <div>{content}</div>;
};
// messageTypeConfig.tsx 中引入
import { YourMessage } from "./YourMessage";
12345: {
type: "复杂类型",
nodeFunc: (props) => <YourMessage {...props} />,
}
```
### 3. 未知 msgType内容推导
```typescript
// 添加到 SPECIAL_TYPE_DETECTORS 数组
export const SPECIAL_TYPE_DETECTORS = [
{
name: "新类型",
priority: 95,
detector: (content, json) => {
return json && json.yourField === "value";
},
nodeFunc: ({ content }) => <div>{content}</div>,
},
];
```
## 📋 可用属性
```typescript
nodeFunc: ({
content, // 原始内容字符串
parsedJson, // 解析后的 JSON如果是 JSON
msg, // 完整消息对象
contract, // 联系人/群聊对象
parseEmojiText, // 表情解析函数
isEmojiUrl, // 表情URL判断函数
}) => React.ReactNode
```
## 🎯 常见场景
### 场景1: 知道 msgType
```typescript
888: {
type: "游戏",
nodeFunc: ({ parsedJson }) => (
<div>游戏: {parsedJson.gameName}</div>
),
}
```
### 场景2: 不知道 msgType通过 JSON 推导
```typescript
{
name: "游戏消息",
priority: 90,
detector: (_, json) => json && json.type === "game",
nodeFunc: ({ parsedJson }) => (
<div>游戏: {parsedJson.gameName}</div>
),
}
```
### 场景3: 通过内容关键词推导
```typescript
{
name: "特殊消息",
priority: 85,
detector: (content) => content.includes("[特殊标记]"),
nodeFunc: ({ content }) => <div>{content}</div>,
}
```
## 🔍 调试
打开控制台查看:
- `✅ 使用 msgType=1 (文本)` - 找到配置
- `🔍 推导出类型: 红包` - 通过 detector 推导
- `⚠️ 未识别的消息类型` - 需要添加配置
## 📝 实例示例
给你一个新类型实例:
```json
{
"msgType": 888,
"content": "{\"type\":\"card\",\"title\":\"名片\",\"avatar\":\"https://...\"}"
}
```
**添加配置**:
```typescript
888: {
type: "名片",
nodeFunc: ({ parsedJson }) => (
<div style={{ display: "flex", gap: "8px" }}>
<img src={parsedJson.avatar} style={{ width: "48px" }} />
<div>{parsedJson.title}</div>
</div>
),
}
```
完成!✨

View File

@@ -0,0 +1,146 @@
# 消息类型配置迁移完成 ✅
## 📦 迁移内容
### 从
```
src/utils/messageTypes/
├── TextMessage.tsx
├── ImageMessage.tsx
├── EmojiMessage.tsx
├── UnknownMessage.tsx
└── messageTypeConfig.tsx
```
### 迁移到
```
src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/
└── messageTypes/
├── TextMessage.tsx
├── ImageMessage.tsx
├── EmojiMessage.tsx
├── UnknownMessage.tsx
└── messageTypeConfig.tsx ⭐ 核心配置文件
```
## 🎯 就近原则优势
1. **更好的组织结构**
- 消息类型配置与 MessageRecord 组件在同一目录
- 相关文件就近放置,便于维护
2. **更清晰的依赖关系**
- 样式文件路径更短:`../com.module.scss`
- 组件引用更直接:`../components/AudioMessage`
3. **更易于理解**
- 查看 MessageRecord 组件时,可以直接看到所有消息类型配置
- 新人更容易理解代码结构
## 🔄 变更内容
### 1. 文件路径更新
**样式文件引用**
```typescript
// 旧路径
import styles from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/com.module.scss";
// 新路径(相对路径)
import styles from "../com.module.scss";
```
**组件引用**
```typescript
// 旧路径
import AudioMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/AudioMessage/AudioMessage";
// 新路径(相对路径)
import AudioMessage from "../components/AudioMessage/AudioMessage";
```
### 2. Hook 导入路径更新
**`useMessageTypeParser.tsx`**
```typescript
// 旧导入
import {
MESSAGE_TYPE_MAP,
SPECIAL_TYPE_DETECTORS,
UNKNOWN_MESSAGE_CONFIG,
} from "@/utils/messageTypes/messageTypeConfig";
// 新导入
import {
MESSAGE_TYPE_MAP,
SPECIAL_TYPE_DETECTORS,
UNKNOWN_MESSAGE_CONFIG,
} from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/messageTypeConfig";
```
### 3. 删除的文件
已删除旧位置的文件:
- `src/utils/messageTypes/TextMessage.tsx`
- `src/utils/messageTypes/ImageMessage.tsx`
- `src/utils/messageTypes/EmojiMessage.tsx`
- `src/utils/messageTypes/UnknownMessage.tsx`
- `src/utils/messageTypes/messageTypeConfig.tsx`
## 📝 文档更新
已更新以下文档中的路径说明:
-`docs/消息类型配置指南.md`
-`docs/消息类型快速参考.md`
## 🎉 迁移结果
- ✅ 无 linter 错误
- ✅ 所有路径已更新
- ✅ 文档已同步更新
- ✅ 旧文件已清理
## 📍 新的文件位置
**核心配置文件**:
```
src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/messageTypeConfig.tsx
```
**添加新类型时**,直接在这个文件中修改 `MESSAGE_TYPE_MAP` 对象即可!
## 🚀 使用方式(无变化)
使用方式完全不变:
```typescript
// 在组件中使用
import { useMessageTypeParser } from "@/hooks/weChat/useMessageTypeParser";
const { parseMessageContent } = useMessageTypeParser(contract);
// 渲染消息
{parseMessageContent(msg.content, msg, msg.msgType)}
```
## 📚 目录结构对比
### 迁移前
```
src/
├── utils/
│ └── messageTypes/ ❌ 与使用位置距离较远
└── pages/
└── pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/
└── index.tsx (使用 messageTypes
```
### 迁移后
```
src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/
├── messageTypes/ ✅ 就近原则
│ └── messageTypeConfig.tsx
└── index.tsx (使用 messageTypes
```
迁移完成!🎊

View File

@@ -0,0 +1,376 @@
# 消息类型配置系统
## 📁 文件结构
```
src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/
├── messageTypes/ # 📌 消息类型配置目录
│ ├── TextMessage.tsx # 文本消息组件
│ ├── ImageMessage.tsx # 图片消息组件
│ ├── EmojiMessage.tsx # 表情包消息组件
│ ├── UnknownMessage.tsx # 未知类型消息组件
│ └── messageTypeConfig.tsx # 📌 核心配置文件
├── components/ # 其他消息组件
│ ├── AudioMessage/
│ ├── VideoMessage/
│ ├── SmallProgramMessage/
│ └── ...
└── index.tsx # MessageRecord 主组件
src/hooks/weChat/
└── useMessageTypeParser.tsx # 新版解析 Hook
```
## 🎯 核心配置对象
`MessageRecord/messageTypes/messageTypeConfig.tsx` 中,所有消息类型都通过一个对象配置:
```typescript
export const MESSAGE_TYPE_MAP: Record<number, MessageTypeConfig> = {
1: {
type: "文本",
nodeFunc: ({ content, parseEmojiText }) => (
<TextMessage content={content} parseEmojiText={parseEmojiText} />
),
},
3: {
type: "图片",
nodeFunc: ({ content }) => <ImageMessage content={content} />,
detector: (content) => /^https?:\/\/.*\.(jpg|jpeg|png|gif)$/i.test(content),
priority: 80,
},
// ... 更多类型
}
```
### 配置接口说明
```typescript
interface MessageTypeConfig {
type: string; // 类型名称(用于调试)
nodeFunc: (props) => React.ReactNode; // 渲染函数
detector?: (content, json) => boolean; // 内容检测器(可选)
priority?: number; // 优先级(可选)
}
```
## 🔧 添加新消息类型
### 方式1: 已知 msgType推荐
当你知道服务器返回的 `msgType` 时,直接在配置对象中添加:
```typescript
// 在 MessageRecord/messageTypes/messageTypeConfig.tsx 中添加
export const MESSAGE_TYPE_MAP: Record<number, MessageTypeConfig> = {
// ... 现有配置
/**
* msgType = 12345: 新的自定义类型
*/
12345: {
type: "自定义类型",
nodeFunc: ({ content, parsedJson }) => {
// 直接在这里写渲染逻辑(简单情况)
return <div style={{ color: "red" }}>{content}</div>;
},
},
}
```
### 方式2: 创建独立组件(推荐用于复杂逻辑)
**步骤1**: 创建组件文件 `src/utils/messageTypes/CustomMessage.tsx`
```typescript
import React from "react";
interface CustomMessageProps {
content: string;
customData: any;
}
export const CustomMessage: React.FC<CustomMessageProps> = ({ content, customData }) => {
return (
<div style={{ border: "1px solid blue", padding: "8px" }}>
<div>自定义消息</div>
<div>{content}</div>
<div>额外数据: {JSON.stringify(customData)}</div>
</div>
);
};
```
**步骤2**: 在配置中引入
```typescript
// messageTypeConfig.tsx
import { CustomMessage } from "./CustomMessage";
export const MESSAGE_TYPE_MAP: Record<number, MessageTypeConfig> = {
// ... 现有配置
12345: {
type: "自定义类型",
nodeFunc: ({ content, parsedJson }) => (
<CustomMessage content={content} customData={parsedJson} />
),
},
}
```
### 方式3: 未知 msgType通过内容推导
当服务器返回的 `msgType` 不准确或未知时,使用 `detector`
```typescript
// 添加到 SPECIAL_TYPE_DETECTORS 数组
export const SPECIAL_TYPE_DETECTORS = [
// ... 现有检测器
/**
* 特殊类型:通过内容判断
*/
{
name: "特殊消息",
priority: 100, // 高优先级,先检测
detector: (content, parsedJson) => {
// 检测规则1: JSON 包含特定字段
if (parsedJson && parsedJson.specialType === "custom") {
return true;
}
// 检测规则2: 内容包含特定关键词
if (content.includes("[特殊标记]")) {
return true;
}
return false;
},
nodeFunc: ({ content, parsedJson }) => (
<div style={{ background: "yellow" }}>
特殊消息: {content}
</div>
),
},
];
```
## 📝 完整示例
假设你遇到一个新类型,实例数据如下:
```json
{
"msgType": 888,
"content": "{\"type\":\"game\",\"gameName\":\"王者荣耀\",\"score\":100}"
}
```
### 添加步骤
**1. 创建组件** `src/utils/messageTypes/GameMessage.tsx`
```typescript
import React from "react";
interface GameMessageProps {
gameName: string;
score: number;
}
export const GameMessage: React.FC<GameMessageProps> = ({ gameName, score }) => {
return (
<div style={{
border: "2px solid #1890ff",
borderRadius: "8px",
padding: "12px",
maxWidth: "200px"
}}>
<div style={{ fontSize: "16px", fontWeight: "bold" }}>🎮 游戏消息</div>
<div style={{ marginTop: "8px" }}>游戏: {gameName}</div>
<div>分数: {score}</div>
</div>
);
};
```
**2. 在配置中注册**
```typescript
// messageTypeConfig.tsx
import { GameMessage } from "./GameMessage";
export const MESSAGE_TYPE_MAP: Record<number, MessageTypeConfig> = {
// ... 现有配置
/**
* msgType = 888: 游戏消息
*/
888: {
type: "游戏",
nodeFunc: ({ content, parsedJson }) => {
// 如果解析失败,显示错误
if (!parsedJson || parsedJson.type !== "game") {
return <div>[游戏消息格式错误]</div>;
}
return (
<GameMessage
gameName={parsedJson.gameName}
score={parsedJson.score}
/>
);
},
// 可选:添加检测器,用于 msgType 不准确时
detector: (content, parsedJson) => {
return parsedJson && parsedJson.type === "game";
},
priority: 90,
},
}
```
**3. 完成!** 现在系统会自动识别和渲染该类型
## 🔍 调试技巧
### 查看消息类型识别日志
在浏览器控制台可以看到:
```
✅ 使用 msgType=1 (文本)
🔍 推导出类型: 红包 (msgType=49)
⚠️ 未识别的消息类型,使用兜底处理 (msgType=999)
```
### 测试新类型
1. 发送一条新类型的消息
2. 查看控制台日志,确认 msgType 和内容格式
3. 根据日志信息添加配置
4. 刷新页面,验证渲染效果
## 🎨 最佳实践
### 1. 简单类型直接写 nodeFunc
```typescript
1: {
type: "文本",
nodeFunc: ({ content }) => <div>{content}</div>,
}
```
### 2. 复杂类型创建独立组件
```typescript
// 独立组件文件
export const ComplexMessage: React.FC<Props> = (props) => {
// 复杂逻辑
return <div>...</div>;
};
// 配置中引用
888: {
type: "复杂类型",
nodeFunc: (props) => <ComplexMessage {...props} />,
}
```
### 3. 使用 detector 处理多种情况
```typescript
888: {
type: "多态类型",
detector: (content, json) => {
// 情况1: 通过 JSON 判断
if (json && json.typeFlag === "special") return true;
// 情况2: 通过内容判断
if (content.startsWith("特殊前缀:")) return true;
// 情况3: 通过正则判断
if (/特殊模式/.test(content)) return true;
return false;
},
nodeFunc: ({ content, parsedJson }) => {
// 根据不同情况渲染
if (parsedJson) {
return <JsonBasedRender data={parsedJson} />;
}
return <TextBasedRender content={content} />;
},
priority: 95, // 高优先级
}
```
## 📊 性能优势
1. **对象映射查找**: O(1) 时间复杂度
2. **JSON 只解析一次**: 解析结果在 `parsedJson` 中复用
3. **按需加载**: 只有用到的组件才会导入
4. **优先级控制**: detector 冲突时按优先级选择
## 🔄 迁移指南
从旧版 `useMessageParser` 迁移到新版:
```typescript
// 旧版
import { useMessageParser } from "@/hooks/weChat/useMessageParser";
// 新版
import { useMessageTypeParser } from "@/hooks/weChat/useMessageTypeParser";
// 使用方式完全相同
const { parseMessageContent } = useMessageTypeParser(contract);
```
## 📚 进阶用法
### 动态注册(运行时添加)
```typescript
// 在任何地方动态添加新类型
import { MESSAGE_TYPE_MAP } from "@/utils/messageTypes/messageTypeConfig";
MESSAGE_TYPE_MAP[99999] = {
type: "动态类型",
nodeFunc: ({ content }) => <div>动态: {content}</div>,
};
```
### 条件渲染
```typescript
888: {
type: "条件渲染",
nodeFunc: ({ content, parsedJson, msg }) => {
// 根据消息发送者决定样式
if (msg.isSend) {
return <div style={{ background: "blue" }}>{content}</div>;
}
return <div style={{ background: "gray" }}>{content}</div>;
},
}
```
## 🎯 总结
- **配置文件**: `messageTypeConfig.tsx` - 所有类型都在这里
- **添加新类型**: 直接在 `MESSAGE_TYPE_MAP` 对象中添加键值对
- **独立组件**: 复杂逻辑创建独立的 `.tsx` 文件
- **内容推导**: 使用 `SPECIAL_TYPE_DETECTORS` 数组
- **调试友好**: 控制台有详细日志
遇到新类型时,只需:
1. 查看控制台日志,获取 msgType 和 content
2. 在配置对象中添加新的键值对
3. 刷新页面验证
就这么简单!🎉

View File

@@ -0,0 +1,135 @@
# 消息类型配置系统 - 使用状态
## ✅ 当前使用情况
### 已启用新配置系统
**MessageRecord 组件** (`src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx`)
```typescript
// ✅ 使用新的对象映射配置
import { useMessageTypeParser } from "@/hooks/weChat/useMessageTypeParser";
const { parseMessageContent, parseEmojiText, isEmojiUrl } = useMessageTypeParser(contract);
```
## 📊 新旧对比
| 项目 | 旧系统 (useMessageParser) | 新系统 (useMessageTypeParser) |
|------|---------------------------|-------------------------------|
| **配置方式** | switch-case 分散在多处 | 对象映射集中配置 |
| **配置位置** | Hook 内部 | `messageTypeConfig.tsx` |
| **添加类型** | 修改 switch-case | 添加对象键值对 |
| **代码行数** | ~450 行 | ~300 行配置 + ~150 行 Hook |
| **易维护性** | ❌ 分散难维护 | ✅ 集中易维护 |
| **扩展性** | ❌ 需修改源码 | ✅ 添加配置即可 |
| **调试性** | ❌ 难追踪 | ✅ 有详细日志 |
## 🎯 新系统核心文件
### 1. 配置文件
```
src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/messageTypeConfig.tsx
```
包含:
- `MESSAGE_TYPE_MAP` - 消息类型映射对象
- `SPECIAL_TYPE_DETECTORS` - 内容推导检测器
- `UNKNOWN_MESSAGE_CONFIG` - 未知类型兜底
### 2. Hook 文件
```
src/hooks/weChat/useMessageTypeParser.tsx
```
功能:
- JSON 解析(缓存结果)
- 类型查找O(1) 复杂度)
- 内容推导(按优先级)
- 调试日志
### 3. 组件文件
```
src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/
├── TextMessage.tsx # 文本消息
├── ImageMessage.tsx # 图片消息
├── EmojiMessage.tsx # 表情包
└── UnknownMessage.tsx # 未知类型兜底
```
## 🔧 添加新类型(超简单)
只需在 `messageTypeConfig.tsx` 中添加:
```typescript
export const MESSAGE_TYPE_MAP: Record<number, MessageTypeConfig> = {
// 现有配置...
// ⭐ 添加你的新类型
888: {
type: "游戏消息",
nodeFunc: ({ content, parsedJson }) => {
return (
<div style={{ border: "2px solid blue", padding: "8px" }}>
🎮 游戏: {parsedJson.gameName}
</div>
);
},
// 可选:添加内容检测器
detector: (content, json) => json && json.type === "game",
priority: 90,
},
}
```
完成!无需修改其他任何代码。
## 📝 控制台调试信息
运行时可以在控制台看到:
```
✅ 使用 msgType=1 (文本)
✅ 使用 msgType=3 (图片)
✅ 使用 msgType=43 (视频)
🔍 推导出类型: 红包 (msgType=49)
🔍 推导出类型: 转账 (msgType=49)
⚠️ 未识别的消息类型,使用兜底处理 (msgType=888)
```
当遇到未识别的类型时,你会立即看到警告,然后就可以去配置文件添加。
## 🎉 优势总结
### 1. 开发效率提升
- 添加新类型只需 1 分钟
- 无需理解复杂的 switch-case 逻辑
- 配置和组件分离,职责清晰
### 2. 代码质量提升
- 集中管理,减少重复代码
- 类型安全TypeScript 类型提示
- 易于测试和调试
### 3. 团队协作友好
- 新人一看就懂配置格式
- 多人同时添加类型不冲突
- 文档完善,有示例代码
## 📚 相关文档
- 📖 [消息类型配置指南](./消息类型配置指南.md) - 详细教程
- 📄 [消息类型快速参考](./消息类型快速参考.md) - 速查手册
- 📝 [消息类型迁移记录](./消息类型迁移记录.md) - 迁移说明
## 🚀 后续优化建议
1.**已完成**: 迁移到 MessageRecord 目录(就近原则)
2.**已完成**: 切换到新的 Hook
3. 🔜 **建议**: 添加单元测试
4. 🔜 **建议**: 添加性能监控
5. 🔜 **建议**: 支持消息类型热更新
---
**当前状态**: ✅ 已完全启用新配置系统
**下次遇到新的 msgType 时**,直接打开 `messageTypeConfig.tsx`,添加一个对象配置即可!

View File

@@ -0,0 +1,298 @@
# 系统消息集成到配置系统
## 📋 背景说明
之前系统消息(`msgType: 10000, -10001, 570425393, 90000`)是单独处理的,与用户消息分开渲染。现在已将系统消息**集成到新的对象映射配置系统**中,实现统一管理。
## 🎯 消息分类
### 1. 系统消息(显示在中间区域)
- `10000` - 系统消息(如:时间戳、入群通知等)
- `-10001` - 系统消息
- `570425393` - 系统消息JSON格式
- `90000` - 系统消息JSON格式
### 2. 用户消息(左右气泡显示)
- `1` - 文本消息
- `3` - 图片消息
- `34` - 语音消息
- `43` - 视频消息
- `47` - 表情包
- `48` - 定位消息
- `49` - 小程序/文章/文件
- `10002` - 系统推荐备注消息
- 更多...
## ✅ 完成的修改
### 1. 配置文件更新
**文件**: `src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/messageTypes/messageTypeConfig.tsx`
#### 新增字段
```typescript
export interface MessageTypeConfig {
type: string;
nodeFunc: (props: MessageTypeNodeProps) => React.ReactNode;
detector?: (content: string, parsedJson: any) => boolean;
priority?: number;
isSystemMessage?: boolean; // ⭐ 新增:标记是否为系统消息
}
```
#### 新增系统消息配置
```typescript
export const MESSAGE_TYPE_MAP: Record<number, MessageTypeConfig> = {
// ==================== 系统消息(显示在中间区域) ====================
/**
* msgType = 10000: 系统消息
*/
10000: {
type: "系统消息",
isSystemMessage: true,
nodeFunc: ({ content }) => (
<div className={styles.messageTime}>
{parseSystemMessage(content)}
</div>
),
},
/**
* msgType = -10001: 系统消息
*/
[-10001]: {
type: "系统消息",
isSystemMessage: true,
nodeFunc: ({ content }) => (
<div className={styles.messageTime}>
{parseSystemMessage(content)}
</div>
),
},
/**
* msgType = 570425393: 系统消息JSON格式
*/
570425393: {
type: "系统消息(JSON)",
isSystemMessage: true,
nodeFunc: ({ content, parsedJson }) => {
let displayContent = content;
if (parsedJson && typeof parsedJson === "object" && parsedJson.content) {
displayContent = parsedJson.content;
}
return <div className={styles.messageTime}>{displayContent}</div>;
},
},
/**
* msgType = 90000: 系统消息JSON格式
*/
90000: {
type: "系统消息(JSON)",
isSystemMessage: true,
nodeFunc: ({ content, parsedJson }) => {
let displayContent = content;
if (parsedJson && typeof parsedJson === "object" && parsedJson.content) {
displayContent = parsedJson.content;
}
return <div className={styles.messageTime}>{displayContent}</div>;
},
},
// ==================== 用户消息 ====================
// ... 其他用户消息配置
}
```
### 2. 渲染逻辑简化
#### Before旧代码
```typescript
// ❌ 旧方式:系统消息单独处理
{group.messages
.filter(v => [10000, -10001].includes(v.msgType))
.map(msg => {
const parsedText = parseSystemMessage(msg.content);
return (
<div className={styles.messageTime}>
{parsedText}
</div>
);
})}
{group.messages
.filter(v => [570425393, 90000].includes(v.msgType))
.map(msg => {
let displayContent = msg.content;
try {
const parsedContent = JSON.parse(msg.content);
if (parsedContent?.content) {
displayContent = parsedContent.content;
}
} catch {}
return <div className={styles.messageTime}>{displayContent}</div>;
})}
<div className={styles.messageTime}>{group.time}</div>
{group.messages
.filter(v => ![10000, 570425393, 90000, -10001].includes(v.msgType))
.map(msg => (
<MessageItem ... />
))}
```
#### After新代码
```typescript
// ✅ 新方式:统一使用配置系统
<div className={styles.messageTime}>{group.time}</div>
{group.messages.map(msg => {
if (!msg) return null;
// 使用新的配置系统渲染消息
const renderedContent = parseMessageContent(msg?.content, msg, msg?.msgType);
// 如果是系统消息,直接渲染(已经包含了样式)
if ([10000, -10001, 570425393, 90000].includes(msg.msgType)) {
return (
<React.Fragment key={`system-${msg.id}`}>
{renderedContent}
</React.Fragment>
);
}
// 用户消息,使用 MessageItem 组件
return <MessageItem key={msg.id} msg={msg} ... />;
})}
```
### 3. 文件修改清单
| 文件 | 修改内容 | 代码行数变化 |
|------|---------|------------|
| `messageTypeConfig.tsx` | 新增系统消息配置、`isSystemMessage` 字段 | +58 行 |
| `MessageRecord/index.tsx` | 简化渲染逻辑,移除重复的系统消息处理 | -45 行 |
| `VirtualizedMessageList.tsx` | 同步更新虚拟滚动的渲染逻辑 | -43 行 |
**总计**: 减少了约 30 行代码,逻辑更清晰!
## 🎨 渲染效果
### 系统消息(中间显示)
```
昨天 10:33
南务4将此好友从 wz_04(商务4)转接给wz_05(游戏)。
昨天 10:57
```
### 用户消息(左右气泡)
```
[头像] 客户昵称
你好,有什么可以帮您的?
[头像] 客服昵称
我需要咨询一下产品
```
## 📊 优势对比
| 对比项 | 旧方式 | 新方式 |
|-------|--------|--------|
| **配置位置** | 分散在 `index.tsx` 中 | 集中在 `messageTypeConfig.tsx` |
| **代码复用** | 系统消息和用户消息分别处理 | 统一使用 `parseMessageContent` |
| **扩展性** | 需要修改多处代码 | 只需在配置中添加 |
| **维护性** | 逻辑分散,难维护 | 配置集中,易维护 |
| **一致性** | 处理方式不统一 | 所有消息统一处理 |
| **调试性** | 需要查看多个地方 | 集中查看配置文件 |
## 🚀 添加新的系统消息类型
现在添加新的系统消息类型非常简单:
```typescript
// messageTypeConfig.tsx
export const MESSAGE_TYPE_MAP: Record<number, MessageTypeConfig> = {
// ... 现有配置
// ⭐ 添加新的系统消息类型
999999: {
type: "新系统消息",
isSystemMessage: true, // 标记为系统消息
nodeFunc: ({ content, parsedJson }) => (
<div className={styles.messageTime}>
🎉 {parsedJson?.text || content}
</div>
),
},
}
```
完成!无需修改 `index.tsx``VirtualizedMessageList.tsx`
## 🔍 判断逻辑
在渲染时,通过以下方式判断是否为系统消息:
```typescript
// 方式1: 通过 msgType 硬编码判断(当前使用)
if ([10000, -10001, 570425393, 90000].includes(msg.msgType)) {
// 系统消息,直接渲染
}
// 方式2: 通过配置的 isSystemMessage 字段判断(推荐未来优化)
const config = MESSAGE_TYPE_MAP[msg.msgType];
if (config?.isSystemMessage) {
// 系统消息,直接渲染
}
```
## 💡 未来优化建议
1. **使用 `isSystemMessage` 字段判断**
- 当前仍使用硬编码的 `msgType` 数组判断
- 未来可改为读取配置中的 `isSystemMessage` 字段
- 好处:更灵活,添加新系统消息时无需修改判断逻辑
2. **系统消息样式统一**
- 当前所有系统消息都使用 `styles.messageTime`
- 未来可根据不同类型使用不同样式
- 例如:入群通知、退群通知可以有不同的图标和颜色
3. **系统消息分组优化**
- 当前系统消息和用户消息混合在一起
- 未来可考虑在 `useMessageGrouping` 中预先分组
- 提升渲染性能
## ✅ 测试清单
- [x] 系统消息正确显示在中间区域
- [x] 用户消息正确显示为左右气泡
- [x] 时间标签正确显示
- [x] 虚拟滚动模式下系统消息正常
- [x] 无 linter 错误
- [x] 控制台无警告
- [x] 配置文件可读性好
## 🎉 总结
系统消息已成功集成到新的对象映射配置系统中!现在所有消息类型(系统消息和用户消息)都通过统一的配置管理,代码更简洁、更易维护、更易扩展。
**核心变化**
- ✅ 所有消息类型统一在 `messageTypeConfig.tsx` 配置
- ✅ 系统消息用 `isSystemMessage: true` 标记
- ✅ 渲染逻辑简化,减少重复代码
- ✅ 虚拟滚动和普通渲染逻辑一致
**下次添加新消息类型**,只需在配置文件中添加一个对象即可!🎊

148
msgManage_new_logic.ts Normal file
View File

@@ -0,0 +1,148 @@
// 新的消息处理逻辑 - 需要替换到 src/store/module/websocket/msgManage.ts 的第 150-335 行
// 更新新架构的SessionStore增量更新索引和缓存
try {
const userId = useCustomerStore.getState().currentCustomer?.userId || 0;
if (userId > 0) {
// 1. 先检查联系人是否存在于本地数据库
console.log("🔍 [新消息] 检查联系人是否存在:", {
sessionId,
type,
userId,
});
const existingContact = await ContactManager.getContactByIdAndType(
userId,
sessionId,
type,
);
// 2. 如果联系人不存在,先请求 API 补齐数据
if (!existingContact) {
console.log("⚠️ [新消息] 联系人不存在,先请求 API 补齐数据:", {
sessionId,
type,
});
try {
let detailResult: any = null;
if (type === "friend") {
detailResult = await getWechatFriendDetail({
id: sessionId,
});
} else {
detailResult = await getWechatChatroomDetail({
id: sessionId,
});
}
const detail = detailResult?.detail;
if (detail) {
console.log("✅ [新消息] 成功获取详情,创建联系人:", {
id: detail.id,
nickname: detail.nickname,
avatar: detail.avatar || detail.chatroomAvatar,
});
// 创建联系人数据
const newContact: any = {
serverId: `${type}_${sessionId}_${wechatAccountId}`,
userId,
id: sessionId,
type,
wechatAccountId: detail.wechatAccountId || wechatAccountId,
nickname: detail.nickname || "",
conRemark: detail.conRemark || "",
avatar:
type === "group"
? detail.chatroomAvatar || ""
: detail.avatar || "",
lastUpdateTime: new Date().toISOString(),
sortKey: "",
searchKey: (
detail.conRemark ||
detail.nickname ||
""
).toLowerCase(),
};
// 添加类型特定字段
if (type === "group") {
Object.assign(newContact, {
chatroomId: detail.chatroomId || "",
chatroomOwner: detail.chatroomOwner || "",
selfDisplayName:
detail.selfDisplyName || detail.selfDisplayName || "",
notice: detail.notice || "",
});
} else {
Object.assign(newContact, {
wechatFriendId: detail.id,
wechatId: detail.wechatId || "",
alias: detail.alias || "",
gender: detail.gender,
region: detail.region || "",
signature: detail.signature || "",
phone: detail.phone || "",
quanPin: detail.quanPin || "",
groupId: detail.groupId,
});
}
// 添加到联系人数据库
await ContactManager.addContact(newContact);
console.log("✅ [新消息] 联系人已添加到数据库");
} else {
console.warn("❌ [新消息] API 返回空数据,无法创建联系人");
}
} catch (error) {
console.error("❌ [新消息] 请求 API 补齐数据失败:", error);
}
} else {
console.log("✅ [新消息] 联系人已存在:", {
id: existingContact.id,
nickname: existingContact.nickname,
avatar: existingContact.avatar ? "有" : "无",
});
}
// 3. 从数据库获取或创建会话信息
const updatedSession = await Promise.race([
MessageManager.getSessionByContactId(userId, sessionId, type),
new Promise<null>(resolve => setTimeout(() => resolve(null), 5000)), // 5秒超时
]);
if (updatedSession) {
const messageStore = useMessageStore.getState();
// 增量更新索引
messageStore.addSession(updatedSession);
// 失效缓存,下次切换账号时会重新计算
messageStore.invalidateCache(wechatAccountId);
messageStore.invalidateCache(0); // 也失效"全部"的缓存
// 更新会话列表缓存(不阻塞主流程)
const cacheKey = `sessions_${wechatAccountId}`;
sessionListCache
.get<ChatSession[]>(cacheKey)
.then(cachedSessions => {
if (cachedSessions) {
// 更新缓存中的会话
const index = cachedSessions.findIndex(
s => s.id === updatedSession.id && s.type === updatedSession.type,
);
if (index >= 0) {
cachedSessions[index] = updatedSession;
} else {
cachedSessions.push(updatedSession);
}
return sessionListCache.set(cacheKey, cachedSessions);
}
})
.catch(error => {
console.error("更新会话缓存失败:", error);
});
}
}
} catch (error) {
console.error("更新SessionStore失败:", error);
}

View File

@@ -6,6 +6,14 @@ import axios, {
} from "axios";
import { Toast } from "antd-mobile";
import { useUserStore } from "@/store/module/user";
// 导出类型定义供外部使用
export type {
ApiResponse,
ApiDetailResponse,
ApiPageResponse,
ApiListResponse,
} from "./types";
const { token } = useUserStore.getState();
const DEFAULT_DEBOUNCE_GAP = 0; // 设置为 0 禁用防抖
const debounceMap = new Map<string, number>();
@@ -109,14 +117,40 @@ instance.interceptors.response.use(
},
);
export function request(
/**
* 统一请求函数(带类型约束,泛型参数可选)
*
* @template T 返回数据类型,默认为 any可选保持向后兼容
* @param url 请求地址
* @param data 请求数据
* @param method HTTP 方法
* @param config 请求配置
* @param debounceGap 防抖间隔(已禁用)
* @returns Promise<T> 返回指定类型的数据
*
* @example
* // 不指定类型(向后兼容,返回 any
* const result = await request('/api/user/info');
*
* // 指定返回类型(推荐)
* const result = await request<UserInfo>('/api/user/info');
*
* // 列表接口(返回 list 字段)
* const list = await request<User[]>('/api/users', {}, 'GET');
* // 实际返回: { list: User[], total: number } 或 User[]
*
* // 详情接口(返回 detail 字段)
* const detail = await request<ApiDetailResponse<UserDetail>>('/api/user/detail', { id: 1 });
* // 实际返回: { detail: UserDetail } 或 UserDetail
*/
export function request<T = any>(
url: string,
data?: any,
method: Method = "GET",
// 允许通过 config.debounce 控制是否开启截流,默认开启
config?: AxiosRequestConfig & { debounce?: boolean },
debounceGap?: number,
): Promise<any> {
): Promise<T> {
const gap =
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;

View File

@@ -6,6 +6,9 @@ import axios, {
} from "axios";
import { Toast } from "antd-mobile";
import { useUserStore } from "@/store/module/user";
// 导出类型定义供外部使用
export type { ApiResponse, ApiDetailResponse, ApiPageResponse, ApiListResponse } from "./types";
const DEFAULT_DEBOUNCE_GAP = 0; // 设置为 0 禁用防抖
const debounceMap = new Map<string, number>();
@@ -79,13 +82,31 @@ instance.interceptors.response.use(
},
);
export function request(
/**
* 统一请求函数request2带类型约束泛型参数可选
*
* @template T 返回数据类型,默认为 any可选保持向后兼容
* @param url 请求地址
* @param data 请求数据
* @param method HTTP 方法
* @param config 请求配置
* @param debounceGap 防抖间隔(已禁用)
* @returns Promise<T> 返回指定类型的数据
*
* @example
* // 不指定类型(向后兼容,返回 any
* const result = await request('/api/user/info');
*
* // 指定返回类型(推荐)
* const result = await request<UserInfo>('/api/user/info');
*/
export function request<T = any>(
url: string,
data?: any,
method: Method = "GET",
config?: RequestConfig,
debounceGap?: number,
): Promise<any> {
): Promise<T> {
const gap =
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;

View File

@@ -37,6 +37,24 @@
flex: 1;
overflow-y: auto;
padding: 8px 0;
// 滚动条样式优化
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
&:hover {
background-color: rgba(0, 0, 0, 0.3);
}
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
}
.friendItem {
@@ -151,3 +169,63 @@
color: #999;
font-size: 14px;
}
.loadingMore {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
color: #999;
font-size: 14px;
span {
display: flex;
align-items: center;
gap: 8px;
&::before {
content: '';
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid #1890ff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
}
}
.noMore {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
color: #999;
font-size: 12px;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background-color: #e8e8e8;
}
&::before {
margin-right: 12px;
}
&::after {
margin-left: 12px;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -43,6 +43,7 @@ interface TwoColumnSelectionProps {
enableDeviceFilter?: boolean;
dataSource?: FriendSelectionItem[];
onLoadMore?: () => void; // 加载更多回调
onSearch?: (keyword: string) => void; // 搜索回调
hasMore?: boolean; // 是否有更多数据
loading?: boolean; // 是否正在加载
}
@@ -56,6 +57,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
enableDeviceFilter = true,
dataSource,
onLoadMore,
onSearch,
hasMore = false,
loading = false,
}) => {
@@ -65,10 +67,16 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
);
const [searchQuery, setSearchQuery] = useState("");
const [isLoading, setIsLoading] = useState(false);
const listRef = React.useRef<HTMLDivElement>(null); // 列表容器引用
// 使用 useMemo 缓存过滤结果,避免每次渲染都重新计算
const filteredFriends = useMemo(() => {
const sourceData = dataSource || rawFriends;
// 如果提供了 onSearch 回调,不在前端进行过滤,由父组件通过 API 搜索
if (onSearch) {
return sourceData;
}
if (!searchQuery.trim()) {
return sourceData;
}
@@ -79,7 +87,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
item.name?.toLowerCase().includes(query) ||
item.nickname?.toLowerCase().includes(query),
);
}, [dataSource, rawFriends, searchQuery]);
}, [dataSource, rawFriends, searchQuery, onSearch]);
// 好友列表直接使用过滤后的结果
const friends = filteredFriends;
@@ -149,6 +157,13 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
// 防抖搜索处理
const handleSearch = useCallback(
(value: string) => {
// 如果提供了 onSearch 回调,使用父组件的搜索逻辑
if (onSearch) {
onSearch(value);
return;
}
// 否则使用原有逻辑
if (!dataSource) {
const timer = setTimeout(() => {
fetchFriends(1, value);
@@ -156,7 +171,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
return () => clearTimeout(timer);
}
},
[dataSource, fetchFriends],
[dataSource, fetchFriends, onSearch],
);
// 选择好友 - 使用 useCallback 优化性能
@@ -189,6 +204,31 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
onCancel();
}, [onCancel]);
// 滚动到底部自动加载更多
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollTop = target.scrollTop;
const scrollHeight = target.scrollHeight;
const clientHeight = target.clientHeight;
// 距离底部 100px 时触发加载
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
if (
distanceToBottom < 100 &&
hasMore &&
!loading &&
!isLoading &&
onLoadMore
) {
console.log("🔄 [瀑布流] 触发自动加载更多");
onLoadMore();
}
},
[hasMore, loading, isLoading, onLoadMore],
);
return (
<Modal
title={title}
@@ -222,12 +262,17 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
/>
</div>
<div className={styles.friendList}>
<div
className={styles.friendList}
ref={listRef}
onScroll={handleScroll}
>
{isLoading && !loading ? (
<div className={styles.loading}>...</div>
) : friends.length > 0 ? (
// 使用 React.memo 优化列表项渲染
friends.map(friend => {
<>
{/* 使用 React.memo 优化列表项渲染 */}
{friends.map(friend => {
const isSelected = selectedFriendsMap.has(friend.id);
return (
<FriendListItem
@@ -237,7 +282,20 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
onSelect={handleSelectFriend}
/>
);
})
})}
{/* 加载更多指示器 */}
{loading && (
<div className={styles.loadingMore}>
<span>...</span>
</div>
)}
{/* 没有更多数据提示 */}
{!hasMore && friends.length > 0 && (
<div className={styles.noMore}></div>
)}
</>
) : (
<div className={styles.empty}>
{searchQuery
@@ -245,15 +303,6 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
: "暂无好友"}
</div>
)}
{/* 使用外部传入的加载更多 */}
{hasMore && (
<div className={styles.loadMoreWrapper}>
<Button type="link" onClick={onLoadMore} loading={loading}>
</Button>
</div>
)}
</div>
</div>

View File

@@ -120,8 +120,10 @@ const tryParseContentJson = (content: string): Record<string, any> | null => {
};
/**
* 消息解析 Hook
* 消息解析 Hook(旧版本,保留向后兼容)
* 提取消息解析逻辑,使用 useCallback 优化性能
*
* @deprecated 请使用 useMessageTypeParser 替代
*/
export const useMessageParser = (contract: ContractData | weChatGroup) => {
// 判断是否为表情包URL的工具函数

View File

@@ -108,10 +108,18 @@ export function getGroupList(params: { prevId: number; count: number }) {
"GET",
);
}
interface getResponse {
friendId: number;
wechatId: string;
nickname: string;
avatar: string;
isAdmin: boolean;
isDeleted: boolean;
deletedDate: string;
}
//获取群成员
export function getGroupMembers(params: { id: number }) {
return request2(
return request2<getResponse[]>(
"/api/WechatChatroom/listMembersByWechatChatroomId",
params,
"GET",

View File

@@ -315,7 +315,7 @@ const PublishSchedule = forwardRef<PublishScheduleRef>((props, ref) => {
<ClockCircleOutlined className={styles.detailIcon} />
<span className={styles.detailLabel}>:</span>
<span className={styles.detailValue}>
{formatTime(post.sendTime)}
{post.sendTime}
</span>
</div>
<div className={styles.detailItem}>

View File

@@ -170,9 +170,19 @@ export function getChatroomMessages(params: {
return request2("/api/ChatroomMessage/SearchMessage", params, "GET");
}
//获取群成员
interface getResponse {
friendId: number;
wechatId: string;
nickname: string;
avatar: string;
isAdmin: boolean;
isDeleted: boolean;
deletedDate: string;
}
//获取群成员
export function getGroupMembers(params: { id: number }) {
return request2(
return request2<getResponse[]>(
"/api/WechatChatroom/listMembersByWechatChatroomId",
params,
"GET",

View File

@@ -78,8 +78,9 @@ const ToContract: React.FC<ToContractProps> = ({
// 调用转接接口:区分好友 / 群聊
if (currentContact) {
const isGroup =
"chatroomId" in currentContact && !!currentContact.chatroomId;
console.log("当前选中的是群还是好友?", currentContact);
const isGroup = currentContact.type === "group";
if (isGroup) {
// 群聊转移:使用 WechatChatroomAllot

View File

@@ -1,6 +1,6 @@
// 朋友圈相关的API接口
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import request from "@/api/request";
import request2 from "@/api/request2";
// 朋友圈请求参数接口
export interface FetchMomentParams {
friendMessageId: number;
@@ -32,13 +32,10 @@ export const fetchVoiceToTextApi = async (params: VoiceToTextParams) => {
};
export const getChatroomMemberList = async (params: { groupId: number }) => {
return request(
"/v1/chatroom/getMemberList",
return request2(
"/api/WechatChatroom/listMembersByWechatChatroomId",
{
groupId: params.groupId,
keyword: "",
limit: 500,
page: 1,
id: params.groupId,
},
"GET",
);

View File

@@ -1,153 +1,144 @@
import React from "react";
import { parseWeappMsgStr } from "@/utils/common";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import styles from "./SmallProgramMessage.module.scss";
const FILE_MESSAGE_TYPE = "file";
interface FileMessageData {
type: string;
/**
* 从截断的 XML 中提取小程序关键信息
* 重点匹配特征:
* 1. title标签<title>八达通充值 Octopus Reloading</title>
* 2. 封面链接:<![CDATA[http://wx.qlogo.cn/mmhead/...]]>
*/
const extractMiniProgramInfo = (xmlContent: string): {
title?: string;
fileName?: string;
filename?: string;
url?: string;
isDownloading?: boolean;
fileext?: string;
size?: number | string;
[key: string]: any;
}
appName?: string;
miniProgramType?: number;
previewImage?: string;
} | null => {
const isJsonLike = (value: string) => {
const trimmed = value.trim();
return trimmed.startsWith("{") && trimmed.endsWith("}");
};
const extractFileInfoFromXml = (source: string): FileMessageData | null => {
if (typeof source !== "string") {
return null;
}
const trimmed = source.trim();
if (!trimmed) {
if (!xmlContent || typeof xmlContent !== "string") {
return null;
}
try {
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: {
title?: string;
appName?: string;
miniProgramType?: number;
previewImage?: string;
} = {};
const result: FileMessageData = { type: FILE_MESSAGE_TYPE };
const titleText = titleNode?.textContent?.trim();
if (titleText) {
result.title = titleText;
}
// ⭐ 提取 title重点特征匹配支持截断的XML
// 匹配模式:<title>内容</title> 或 <title>内容(截断)
const titleMatch =
// 完整格式:<title>内容</title>
xmlContent.match(/<title>([^<]+)<\/title>/i) ||
// CDATA格式<title><![CDATA[内容]]></title>
xmlContent.match(/<title><!\[CDATA\[([^\]]+)\]\]><\/title>/i) ||
// 截断格式:<title>内容(后面可能没有闭合标签)
xmlContent.match(/<title>([^<\n\r]+?)(?:\s*<|$)/i);
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;
if (titleMatch?.[1]) {
const title = titleMatch[1].trim();
// 过滤空值和无效值
if (title && title !== "/" && title.length > 0) {
result.title = title;
}
}
// 提取 sourcedisplayname小程序显示名称支持截断
const sourcedisplaynameMatch =
xmlContent.match(/<sourcedisplayname>([^<]+)<\/sourcedisplayname>/i) ||
xmlContent.match(/<sourcedisplayname><!\[CDATA\[([^\]]+)\]\]><\/sourcedisplayname>/i) ||
xmlContent.match(/<sourcedisplayname>([^<\n\r]+?)(?:\s*<|$)/i);
if (sourcedisplaynameMatch?.[1]) {
const appName = sourcedisplaynameMatch[1].trim();
if (appName && appName !== "/" && appName.length > 0) {
result.appName = appName;
}
}
// 提取 appname备用支持截断
if (!result.appName) {
const appnameMatch =
xmlContent.match(/<appname>([^<]+)<\/appname>/i) ||
xmlContent.match(/<appname><!\[CDATA\[([^\]]+)\]\]><\/appname>/i) ||
xmlContent.match(/<appname>([^<\n\r]+?)(?:\s*<|$)/i);
if (appnameMatch?.[1]) {
const appName = appnameMatch[1].trim();
if (appName && appName !== "/" && appName.length > 0) {
result.appName = appName;
}
}
}
// 提取 weappinfo.type小程序类型1 或 2
const weappinfoTypeMatch =
xmlContent.match(/<weappinfo>[\s\S]*?<type>([^<]+)<\/type>/i) ||
xmlContent.match(/<weappinfo>[\s\S]*?<type><!\[CDATA\[([^\]]+)\]\]><\/type>/i) ||
xmlContent.match(/<type>([^<\n\r]+?)(?:\s*<|$)/i);
if (weappinfoTypeMatch?.[1]) {
const typeNum = parseInt(weappinfoTypeMatch[1].trim());
if (!Number.isNaN(typeNum) && (typeNum === 1 || typeNum === 2)) {
result.miniProgramType = typeNum;
}
}
// ⭐ 提取封面链接重点特征匹配http://wx.qlogo.cn/mmhead/
// 优先匹配完整的 weappiconurl 标签
let previewImageUrl: string | undefined;
const weappiconurlMatch =
xmlContent.match(/<weappiconurl><!\[CDATA\[([^\]]+)\]\]><\/weappiconurl>/i) ||
xmlContent.match(/<weappiconurl>([^<]+)<\/weappiconurl>/i);
if (weappiconurlMatch?.[1]) {
previewImageUrl = weappiconurlMatch[1].trim();
} else {
// ⭐ 如果标签不完整直接搜索包含特征字符的CDATA块
// 匹配模式:<![CDATA[http://wx.qlogo.cn/mmhead/...]]>
const cdataMatch = xmlContent.match(
/<!\[CDATA\[(https?:\/\/wx\.qlogo\.cn\/mmhead\/[^\]]+)\]\]>/i
);
if (cdataMatch?.[1]) {
previewImageUrl = cdataMatch[1];
} else {
// 更宽松的匹配直接搜索包含特征字符的URL
const urlMatch = xmlContent.match(
/(https?:\/\/wx\.qlogo\.cn\/mmhead\/[^\s<"']+)/i
);
if (urlMatch?.[1]) {
previewImageUrl = urlMatch[1];
}
}
}
if (previewImageUrl) {
// 清理URL
let url = previewImageUrl
.replace(/<!\[CDATA\[|\]\]>/g, "")
.replace(/[`"']/g, "")
.replace(/&amp;/g, "&")
.trim();
if (url && url.includes("http")) {
result.previewImage = url;
}
}
// 如果至少提取到了 title 或 appName认为提取成功
if (result.title || result.appName) {
return result;
}
return null;
} catch (error) {
console.warn("extractFileInfoFromXml parse failed:", error);
}
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) {
console.warn("从 XML 提取信息失败:", error);
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;
};
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;
if (messageData && typeof messageData === "object") {
if (messageData.type === FILE_MESSAGE_TYPE) {
return {
type: FILE_MESSAGE_TYPE,
...messageData,
...(meta || {}),
};
}
if (typeof messageData.contentXml === "string") {
const xmlData = extractFileInfoFromXml(messageData.contentXml);
if (xmlData || meta) {
return {
...(xmlData || {}),
...(meta || {}),
type: FILE_MESSAGE_TYPE,
};
}
}
}
if (typeof rawContent === "string") {
const xmlData = extractFileInfoFromXml(rawContent);
if (xmlData || meta) {
return {
...(xmlData || {}),
...(meta || {}),
type: FILE_MESSAGE_TYPE,
};
}
}
if (meta) {
return {
type: FILE_MESSAGE_TYPE,
...meta,
};
}
return null;
};
interface SmallProgramMessageProps {
@@ -156,266 +147,102 @@ interface SmallProgramMessageProps {
contract: ContractData | weChatGroup;
}
/**
* 小程序消息渲染组件
* 处理格式:[该消息内容过长已截断]<?xml version="1.0"?>...
*/
const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
content,
msg,
contract,
}) => {
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("[小程序/文章/文件消息 - 无效内容]");
return renderErrorMessage("[小程序消息 - 无效内容]");
}
try {
const trimmedContent = content.trim();
const isJsonContent = isJsonLike(trimmedContent);
const messageData = isJsonContent ? JSON.parse(trimmedContent) : null;
if (messageData && typeof messageData === "object") {
if (messageData.type === "link") {
const { title, desc, thumbPath, url } = messageData;
return (
<div
className={`${styles.miniProgramMessage} ${styles.articleMessage}`}
>
<div
className={`${styles.miniProgramCard} ${styles.articleCard}`}
onClick={() => window.open(url, "_blank")}
>
<div className={styles.articleTitle}>{title}</div>
<div className={styles.articleContent}>
<div className={styles.articleTextArea}>
{desc && (
<div className={styles.articleDescription}>{desc}</div>
)}
</div>
{thumbPath && (
<div className={styles.articleImageArea}>
<img
src={thumbPath}
alt="文章缩略图"
className={styles.articleImage}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
</div>
)}
</div>
</div>
<div className={styles.miniProgramApp}></div>
</div>
);
}
if (messageData.type === "miniprogram") {
try {
const parsedData = parseWeappMsgStr(trimmedContent);
if (parsedData.appmsg) {
const { appmsg } = parsedData;
const title = appmsg.title || "小程序消息";
const appName =
appmsg.sourcedisplayname || appmsg.appname || "小程序";
const miniProgramType =
appmsg.weappinfo && appmsg.weappinfo.type
? parseInt(appmsg.weappinfo.type)
: 1;
if (miniProgramType === 2) {
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}
>
<div
className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}
>
<div className={styles.miniProgramAppTop}>{appName}</div>
<div className={styles.miniProgramTitle}>{title}</div>
<div className={styles.miniProgramImageArea}>
<img
src={parsedData.previewImage}
alt="小程序图片"
className={styles.miniProgramImage}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
</div>
<div className={styles.miniProgramContent}>
<div className={styles.miniProgramIdentifier}></div>
</div>
</div>
</div>
);
}
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
>
<div className={styles.miniProgramCard}>
<img
src={parsedData.previewImage}
alt="小程序缩略图"
className={styles.miniProgramThumb}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
</div>
</div>
<div className={styles.miniProgramApp}>{appName}</div>
</div>
);
}
} catch (parseError) {
console.error("parseWeappMsgStr解析失败:", parseError);
return renderErrorMessage("[小程序消息 - 解析失败]");
}
}
// 去掉 [该消息内容过长已截断] 前缀
let trimmedContent = content.trim();
const truncatedPrefix = "[该消息内容过长已截断]";
if (trimmedContent.startsWith(truncatedPrefix)) {
trimmedContent = trimmedContent.substring(truncatedPrefix.length).trim();
}
const rawContentForResolve =
messageData && typeof messageData.contentXml === "string"
? messageData.contentXml
: trimmedContent;
const fileMessageData = resolveFileMessageData(
messageData,
msg,
rawContentForResolve,
);
// trimmedContent 直接就是 XML 字符串,不需要 JSON 解析
// 从 XML 中提取信息
const info = extractMiniProgramInfo(trimmedContent);
if (!info) {
return renderErrorMessage("[小程序消息 - 信息提取失败]");
}
if (fileMessageData && fileMessageData.type === FILE_MESSAGE_TYPE) {
const {
url = "",
title,
fileName,
filename,
fileext,
isDownloading = false,
} = fileMessageData;
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] || "📄";
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();
};
const title = info.title || "小程序消息";
const appName = info.appName || "小程序";
const miniProgramType = info.miniProgramType || 1;
const previewImage = info.previewImage || "";
// 根据类型渲染不同的 UI
if (miniProgramType === 2) {
// 类型 2垂直图片布局
return (
<div className={styles.fileMessage}>
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}
>
<div
className={styles.fileCard}
onClick={() => {
if (isUrlAvailable) {
window.open(url, "_blank");
} else if (!isDownloading) {
handleFileDownload();
}
}}
className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}
>
<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 className={styles.miniProgramAppTop}>{appName}</div>
{previewImage && (
<div className={styles.miniProgramImageArea}>
<img
src={previewImage}
alt="小程序图片"
className={styles.miniProgramImage}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
</div>
)}
<div className={styles.miniProgramContent}>
<div className={styles.miniProgramIdentifier}></div>
</div>
</div>
</div>
);
}
return renderErrorMessage("[小程序/文件消息]");
// 类型 1默认横向布局
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
>
<div className={styles.miniProgramCard}>
{previewImage && (
<img
src={previewImage}
alt="小程序缩略图"
className={styles.miniProgramThumb}
onError={e => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
</div>
</div>
<div className={styles.miniProgramApp}>{appName}</div>
</div>
);
} catch (e) {
console.warn("小程序/文件消息解析失败:", e);
return renderErrorMessage("[小程序/文件消息 - 解析失败]");
console.warn("小程序消息解析失败:", e);
return renderErrorMessage("[小程序消息 - 解析失败]");
}
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import {
Modal,
Input,
@@ -17,12 +17,13 @@ import {
TeamOutlined,
} from "@ant-design/icons";
import styles from "./TransmitModal.module.scss";
import { ContactManager } from "@/utils/dbAction";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useContactStore } from "@/store/module/weChat/contacts";
import { useUserStore } from "@/store/module/user";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api";
const TransmitModal: React.FC = () => {
const [searchValue, setSearchValue] = useState("");
const [allContacts, setAllContacts] = useState<
@@ -35,35 +36,112 @@ const TransmitModal: React.FC = () => {
const [page, setPage] = useState(1);
const pageSize = 20;
const { sendCommand } = useWebSocketStore.getState();
const currentUserId = useUserStore(state => state.user?.id) || 0;
const isInitialLoadRef = useRef(false);
// 从 Zustand store 获取更新方法
const openTransmitModal = useContactStore(state => state.openTransmitModal);
const setTransmitModal = useContactStore(state => state.setTransmitModal);
const updateSelectedChatRecords = useWeChatStore(
state => state.updateSelectedChatRecords,
);
const selectedChatRecords = useWeChatStore(
state => state.selectedChatRecords,
);
// 加载联系人数据
const loadContacts = useCallback(async () => {
// 将好友数据转换为 ContractData 格式
const convertFriendToContractData = (friend: any): ContractData & { type: "friend" } => {
return {
id: friend.id,
wechatAccountId: friend.wechatAccountId || 0,
wechatId: friend.wechatId || "",
alias: friend.alias || "",
conRemark: friend.conRemark || "",
nickname: friend.nickname || "",
quanPin: friend.quanPin || "",
avatar: friend.avatar || "",
gender: friend.gender || 0,
region: friend.region || "",
addFrom: friend.addFrom || 0,
phone: friend.phone || "",
labels: friend.labels || [],
signature: friend.signature || "",
accountId: friend.accountId || 0,
extendFields: friend.extendFields || null,
city: friend.city || "",
lastUpdateTime: friend.lastUpdateTime || "",
isPassed: friend.isPassed || false,
tenantId: friend.tenantId || 0,
groupId: friend.groupId || 0,
thirdParty: null,
additionalPicture: friend.additionalPicture || "",
desc: friend.desc || "",
config: friend.config || { unreadCount: 0 },
lastMessageTime: friend.lastMessageTime || 0,
duplicate: friend.duplicate || false,
type: "friend",
} as ContractData & { type: "friend" };
};
// 将群组数据转换为 weChatGroup 格式
const convertGroupToWeChatGroup = (group: any): weChatGroup & { type: "group" } => {
return {
id: group.id,
wechatAccountId: group.wechatAccountId || 0,
tenantId: group.tenantId || 0,
accountId: group.accountId || 0,
chatroomId: group.chatroomId || "",
chatroomOwner: group.chatroomOwner || "",
conRemark: group.conRemark || "",
nickname: group.nickname || "",
chatroomAvatar: group.chatroomAvatar || group.avatar || "",
groupId: group.groupId || 0,
aiType: group.aiType || 0,
config: group.config || { unreadCount: 0 },
labels: group.labels || [],
notice: group.notice || "",
selfDisplyName: group.selfDisplyName || "",
wechatChatroomId: group.id,
type: "group",
} as weChatGroup & { type: "group" };
};
// 加载联系人数据使用API接口
const loadContacts = useCallback(async (keyword?: string) => {
setLoading(true);
try {
// 从统一联系人表加载所有联系人
const allContactsData =
await ContactManager.getUserContacts(currentUserId);
setAllContacts(allContactsData as any);
const params: any = {
page: 1,
limit: 1000, // 获取足够多的数据
};
// 如果有搜索关键词,添加到参数中
if (keyword && keyword.trim()) {
params.keyword = keyword.trim();
}
// 并行请求好友列表和群列表
const [friendResult, groupResult] = await Promise.all([
getContactList(params, { debounceGap: 0 }),
getGroupList(params, { debounceGap: 0 }),
]);
const friendList = friendResult?.list || [];
const groupList = groupResult?.list || [];
// 转换数据格式
const friends = friendList.map(convertFriendToContractData);
const groups = groupList.map(convertGroupToWeChatGroup);
// 合并好友和群列表
const allContactsData = [...friends, ...groups];
setAllContacts(allContactsData);
} catch (err) {
console.error("加载联系人数据失败:", err);
message.error("加载联系人数据失败");
} finally {
setLoading(false);
}
}, [currentUserId]);
}, []);
// 重置状态 - 只在 openTransmitModal 变为 true 时执行
useEffect(() => {
@@ -71,28 +149,38 @@ const TransmitModal: React.FC = () => {
setSearchValue("");
setSelectedWechatFriend([]);
setPage(1);
loadContacts();
isInitialLoadRef.current = true;
loadContacts(); // 初始加载不传keyword
} else {
isInitialLoadRef.current = false;
}
// 注意loadContacts 已经在 useCallback 中稳定,但为了安全,我们只在 openTransmitModal 变化时执行
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openTransmitModal]);
// 过滤联系人 - 支持名称和拼音搜索
const filteredContacts = useMemo(() => {
if (!searchValue.trim()) return allContacts;
// 搜索时调用API只在searchValue变化时触发跳过初始加载
useEffect(() => {
if (openTransmitModal && !isInitialLoadRef.current) {
// 使用防抖,避免频繁请求
const timer = setTimeout(() => {
// 如果searchValue为空不传keyword否则传keyword
const keyword = searchValue.trim() || undefined;
loadContacts(keyword);
setPage(1); // 重置页码
}, 300);
const keyword = searchValue.toLowerCase();
return allContacts.filter(contact => {
const name = (contact.nickname || "").toLowerCase();
const quanPin = (contact as any).quanPin?.toLowerCase?.() || "";
const pinyin = (contact as any).pinyin?.toLowerCase?.() || "";
return (
name.includes(keyword) ||
quanPin.includes(keyword) ||
pinyin.includes(keyword)
);
});
}, [allContacts, searchValue]);
return () => clearTimeout(timer);
}
// 标记初始加载完成
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchValue, openTransmitModal]);
// 直接使用 allContacts因为搜索已经在API层面完成
const filteredContacts = useMemo(() => {
return allContacts;
}, [allContacts]);
const paginatedContacts = useMemo(() => {
const start = (page - 1) * pageSize;
@@ -121,10 +209,11 @@ const TransmitModal: React.FC = () => {
const handleConfirm = () => {
for (const user of selectedWechatFriend) {
for (const record of selectedChatRecords) {
const isGroup = (user as any).type === "group";
const params = {
wechatAccountId: user.wechatAccountId,
wechatChatroomId: user?.chatroomId ? user.id : 0,
wechatFriendId: user?.chatroomId ? 0 : user.id,
wechatChatroomId: isGroup ? user.id : 0,
wechatFriendId: isGroup ? 0 : user.id,
msgSubType: record.msgSubType,
msgType: record.msgType,
content: record.content,

View File

@@ -3,7 +3,6 @@ import { VariableSizeList, ListChildComponentProps } from "react-window";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { MessageGroup } from "@/hooks/weChat/useMessageGrouping";
import { MessageItem } from "../index";
import { parseSystemMessage } from "@/utils/filter";
import styles from "../com.module.scss";
import { addPerformanceBreadcrumb } from "@/utils/sentry";
@@ -42,22 +41,17 @@ interface ItemData {
*/
const estimateGroupHeight = (group: MessageGroup): number => {
let height = 40; // 时间分隔符高度
const messageCount = group.messages.filter(
v => ![10000, 570425393, 90000, -10001].includes(v.msgType),
).length;
// 基础消息项高度(包含间距)
const baseMessageHeight = 80;
// 系统消息高度
const systemMessageHeight = 30;
// 计算系统消息数量
const systemMessageCount = group.messages.filter(v =>
[10000, 570425393, 90000, -10001].includes(v.msgType),
).length;
height += systemMessageCount * systemMessageHeight;
height += messageCount * baseMessageHeight;
// 遍历所有消息,估算高度
group.messages.forEach(msg => {
// 系统消息高度
if ([10000, -10001, 570425393, 90000].includes(msg.msgType)) {
height += 30;
} else {
// 用户消息高度
height += 80;
}
});
return height;
};
@@ -75,68 +69,44 @@ const VirtualizedMessageItem: React.FC<ListChildComponentProps<ItemData>> = ({
return (
<div style={style}>
{/* 时间分隔符 */}
{group.messages
.filter(v => [10000, -10001].includes(v.msgType))
.map(msg => {
const parsedText = parseSystemMessage(msg.content);
return (
<div key={`divider-${msg.id}`} className={styles.messageTime}>
{parsedText}
</div>
);
})}
{/* 其他系统消息 */}
{group.messages
.filter(v => [570425393, 90000].includes(v.msgType))
.map(msg => {
let displayContent = msg.content;
try {
const parsedContent = JSON.parse(msg.content);
if (
parsedContent &&
typeof parsedContent === "object" &&
parsedContent.content
) {
displayContent = parsedContent.content;
}
} catch (error) {
displayContent = msg.content;
}
return (
<div key={`divider-${msg.id}`} className={styles.messageTime}>
{displayContent}
</div>
);
})}
{/* 时间标签 */}
<div className={styles.messageTime}>{group.time}</div>
{/* 消息项 */}
{group.messages
.filter(v => ![10000, 570425393, 90000, -10001].includes(v.msgType))
.map(msg => {
if (!msg) return null;
const isOwn = !!msg.isSend;
{/* 渲染所有消息(包括系统消息和用户消息) */}
{group.messages.map(msg => {
if (!msg) return null;
// 使用新的配置系统渲染消息
const renderedContent = props.parseMessageContent(msg?.content, msg, msg?.msgType);
// 如果是系统消息,直接渲染(已经包含了 styles.messageTime 样式)
if ([10000, -10001, 570425393, 90000].includes(msg.msgType)) {
return (
<MessageItem
key={msg.id}
msg={msg}
contract={props.contract}
isGroup={props.isGroupChat}
showCheckbox={props.showCheckbox}
isSelected={props.isMessageSelected(msg)}
currentCustomerAvatar={props.currentCustomerAvatar || ""}
renderGroupUser={props.renderGroupUser}
clearWechatidInContent={props.clearWechatidInContent}
parseMessageContent={props.parseMessageContent}
onCheckboxChange={props.onCheckboxChange}
onContextMenu={e => props.onContextMenu(e, msg, isOwn)}
/>
<React.Fragment key={`system-${msg.id}`}>
{renderedContent}
</React.Fragment>
);
})}
}
// 用户消息,使用 MessageItem 组件
const isOwn = !!msg.isSend;
return (
<MessageItem
key={msg.id}
msg={msg}
contract={props.contract}
isGroup={props.isGroupChat}
showCheckbox={props.showCheckbox}
isSelected={props.isMessageSelected(msg)}
currentCustomerAvatar={props.currentCustomerAvatar || ""}
renderGroupUser={props.renderGroupUser}
clearWechatidInContent={props.clearWechatidInContent}
parseMessageContent={props.parseMessageContent}
onCheckboxChange={props.onCheckboxChange}
onContextMenu={e => props.onContextMenu(e, msg, isOwn)}
/>
);
})}
</div>
);
};

View File

@@ -1,5 +1,4 @@
import React, {
CSSProperties,
useCallback,
useEffect,
useMemo,
@@ -8,17 +7,8 @@ import React, {
} from "react";
import { Avatar, Checkbox } from "antd";
import { UserOutlined, LoadingOutlined } from "@ant-design/icons";
import AudioMessage from "./components/AudioMessage/AudioMessage";
import SmallProgramMessage from "./components/SmallProgramMessage";
import VideoMessage from "./components/VideoMessage";
import ClickMenu from "./components/ClickMeau";
import LocationMessage from "./components/LocationMessage";
import SystemRecommendRemarkMessage from "./components/SystemRecommendRemarkMessage/index";
import RedPacketMessage from "./components/RedPacketMessage";
import TransferMessage from "./components/TransferMessage";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { formatWechatTime } from "@/utils/common";
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
import { parseSystemMessage } from "@/utils/filter";
import styles from "./com.module.scss";
import {
@@ -26,7 +16,7 @@ import {
useUIStateSelectors,
} from "@/hooks/weChat/useWeChatSelectors";
import { useWeChatActions } from "@/hooks/weChat/useWeChatSelectors";
import { useMessageParser } from "@/hooks/weChat/useMessageParser";
import { useMessageTypeParser } from "@/hooks/weChat/useMessageTypeParser";
import { useMessageGrouping } from "@/hooks/weChat/useMessageGrouping";
import { Profiler } from "@sentry/react";
import { addPerformanceBreadcrumb } from "@/utils/sentry";
@@ -41,128 +31,19 @@ import {
} from "./api";
import TransmitModal from "./components/TransmitModal";
const IMAGE_EXT_REGEX = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i;
const FILE_EXT_REGEX = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i;
const DEFAULT_IMAGE_STYLE: CSSProperties = {
maxWidth: "200px",
maxHeight: "200px",
borderRadius: "8px",
};
const EMOJI_IMAGE_STYLE: CSSProperties = {
maxWidth: "120px",
maxHeight: "120px",
};
type ImageContentOptions = {
src: string;
alt: string;
fallbackText: string;
style?: CSSProperties;
wrapperClassName?: string;
withBubble?: boolean;
onClick?: () => void;
};
const openInNewTab = (url: string) => window.open(url, "_blank");
const handleImageError = (
event: React.SyntheticEvent<HTMLImageElement>,
fallbackText: string,
) => {
const target = event.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">${fallbackText}</div>`;
}
};
const renderImageContent = ({
src,
alt,
fallbackText,
style = DEFAULT_IMAGE_STYLE,
wrapperClassName = styles.imageMessage,
withBubble = false,
onClick,
}: ImageContentOptions) => {
const imageNode = (
<div className={wrapperClassName}>
<img
src={src}
alt={alt}
style={style}
onClick={onClick ?? (() => openInNewTab(src))}
onError={event => handleImageError(event, fallbackText)}
/>
</div>
);
if (withBubble) {
return <div className={styles.messageBubble}>{imageNode}</div>;
}
return imageNode;
};
const renderEmojiContent = (src: string) =>
renderImageContent({
src,
alt: "表情包",
fallbackText: "[表情包加载失败]",
style: EMOJI_IMAGE_STYLE,
wrapperClassName: styles.emojiMessage,
});
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>
);
};
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value);
const isHttpImageUrl = (value: string) =>
isHttpUrl(value) && IMAGE_EXT_REGEX.test(value);
const isFileUrl = (value: string) =>
isHttpUrl(value) && FILE_EXT_REGEX.test(value);
const isLegacyEmojiContent = (content: string) =>
IMAGE_EXT_REGEX.test(content) ||
content.includes("emoji") ||
content.includes("sticker");
const tryParseContentJson = (content: string): Record<string, any> | null => {
try {
return JSON.parse(content);
} catch (error) {
return null;
}
};
interface MessageRecordProps {
contract: ContractData | weChatGroup;
}
// 群成员数据接口(与 API 返回结构一致)
type GroupRenderItem = {
id: number;
identifier: string;
friendId: number;
wechatId: string;
nickname: string;
avatar: string;
groupId: number;
chatroomId?: string;
wechatId?: string;
isAdmin: boolean;
isDeleted: boolean;
deletedDate?: string;
[key: string]: any;
};
interface MessageItemProps {
@@ -367,9 +248,8 @@ const MessageRecordComponent: React.FC<MessageRecordProps> = ({ contract }) => {
const currentContract = useWeChatStore(state => state.currentContract);
// ✅ 使用 useMessageParser Hook提取消息解析逻辑
const { parseMessageContent, parseEmojiText, isEmojiUrl } =
useMessageParser(contract);
// ✅ 使用新的 useMessageTypeParser Hook对象映射配置
const { parseMessageContent } = useMessageTypeParser(contract);
// ✅ 使用 useMessageGrouping Hook消息分组使用 useMemo 缓存)
const groupedMessages = useMessageGrouping(currentMessages);
@@ -385,9 +265,16 @@ const MessageRecordComponent: React.FC<MessageRecordProps> = ({ contract }) => {
}
try {
const res = await getChatroomMemberList({ groupId: contract.id });
setGroupRender(res?.list || []);
const memberList = res?.list || res || [];
console.log("🔍 [群成员] 获取群成员列表:", {
groupId: contract.id,
chatroomId: contract.chatroomId,
memberCount: memberList.length,
sampleMember: memberList[0],
});
setGroupRender(memberList);
} catch (error) {
console.error("获取群成员失败", error);
console.error("❌ [群成员] 获取群成员失败:", error);
setGroupRender([]);
}
};
@@ -397,10 +284,15 @@ const MessageRecordComponent: React.FC<MessageRecordProps> = ({ contract }) => {
const groupMemberMap = useMemo(() => {
const map = new Map<string, GroupRenderItem>();
groupRender.forEach(member => {
if (member?.identifier) {
map.set(member.identifier, member);
if (member?.wechatId) {
map.set(member.wechatId, member);
}
});
console.log("🗺️ [群成员] 构建成员映射表:", {
totalMembers: groupRender.length,
mapSize: map.size,
wechatIds: Array.from(map.keys()).slice(0, 5), // 显示前5个
});
return map;
}, [groupRender]);
@@ -410,14 +302,33 @@ const MessageRecordComponent: React.FC<MessageRecordProps> = ({ contract }) => {
return { avatar: "", nickname: "" };
}
const member = msg.senderWechatId
? groupMemberMap.get(msg.senderWechatId)
// 优先使用 sender.wechatId然后是 senderWechatId
const senderWechatId = msg.sender?.wechatId || msg.senderWechatId;
const member = senderWechatId
? groupMemberMap.get(senderWechatId)
: undefined;
return {
avatar: member?.avatar || msg?.avatar,
nickname: member?.nickname || msg?.senderNickname,
const result = {
avatar: member?.avatar || msg.sender?.avatar || msg?.avatar || "",
nickname:
member?.nickname ||
msg.sender?.nickname ||
msg?.senderNickname ||
"未知",
};
// 仅在找不到成员时输出调试信息
if (!member && senderWechatId) {
console.log("⚠️ [群成员] 未找到匹配的群成员:", {
senderWechatId,
msgId: msg.id,
hasSender: !!msg.sender,
mapSize: groupMemberMap.size,
fallbackNickname: result.nickname,
});
}
return result;
},
[groupMemberMap],
);
@@ -654,17 +565,19 @@ const MessageRecordComponent: React.FC<MessageRecordProps> = ({ contract }) => {
return (
<Profiler
name="MessageRecord"
onRender={(id, phase, actualDuration) => {
// ✅ 使用 Sentry 监控组件渲染性能
if (actualDuration > 100) {
addPerformanceBreadcrumb("MessageRecord 慢渲染", {
duration: actualDuration,
phase,
messageCount: currentMessages.length,
contractId: contract.id,
});
}
}}
{...({
onRender: (id: any, phase: any, actualDuration: any) => {
// ✅ 使用 Sentry 监控组件渲染性能
if (actualDuration > 100) {
addPerformanceBreadcrumb("MessageRecord 慢渲染", {
duration: actualDuration,
phase,
messageCount: currentMessages.length,
contractId: contract.id,
});
}
},
} as any)}
>
<div ref={messagesContainerRef} className={styles.messagesContainer}>
<div
@@ -709,66 +622,43 @@ const MessageRecordComponent: React.FC<MessageRecordProps> = ({ contract }) => {
<>
{groupedMessages.map((group, groupIndex) => (
<React.Fragment key={`group-${groupIndex}`}>
{group.messages
.filter(v => [10000, -10001].includes(v.msgType))
.map(msg => {
// 解析系统消息提取纯文本移除img标签和_wc_custom_link_标签
const parsedText = parseSystemMessage(msg.content);
return (
<div key={`divider-${msg.id}`} className={styles.messageTime}>
{parsedText}
</div>
);
})}
{group.messages
.filter(v => [570425393, 90000].includes(v.msgType))
.map(msg => {
// 解析JSON字符串
let displayContent = msg.content;
try {
const parsedContent = JSON.parse(msg.content);
if (
parsedContent &&
typeof parsedContent === "object" &&
parsedContent.content
) {
displayContent = parsedContent.content;
}
} catch (error) {
// 如果解析失败,使用原始内容
displayContent = msg.content;
}
return (
<div key={`divider-${msg.id}`} className={styles.messageTime}>
{displayContent}
</div>
);
})}
{/* 时间标签 */}
<div className={styles.messageTime}>{group.time}</div>
{group.messages
.filter(
v => ![10000, 570425393, 90000, -10001].includes(v.msgType),
)
.map(msg => {
if (!msg) return null;
{/* 渲染所有消息(包括系统消息和用户消息) */}
{group.messages.map(msg => {
if (!msg) return null;
// 使用新的配置系统渲染消息
const renderedContent = parseMessageContent(msg?.content, msg, msg?.msgType);
// 如果是系统消息,直接渲染(已经包含了 styles.messageTime 样式)
if ([10000, -10001, 570425393, 90000].includes(msg.msgType)) {
return (
<MessageItem
key={msg.id}
msg={msg}
contract={contract}
isGroup={isGroupChat}
showCheckbox={showCheckbox}
isSelected={isMessageSelected(msg)}
currentCustomerAvatar={currentCustomer?.avatar || ""}
renderGroupUser={renderGroupUser}
clearWechatidInContent={clearWechatidInContent}
parseMessageContent={parseMessageContent}
onCheckboxChange={handleCheckboxChange}
onContextMenu={handleContextMenu}
/>
<React.Fragment key={`system-${msg.id}`}>
{renderedContent}
</React.Fragment>
);
})}
}
// 用户消息,使用 MessageItem 组件
return (
<MessageItem
key={msg.id}
msg={msg}
contract={contract}
isGroup={isGroupChat}
showCheckbox={showCheckbox}
isSelected={isMessageSelected(msg)}
currentCustomerAvatar={currentCustomer?.avatar || ""}
renderGroupUser={renderGroupUser}
clearWechatidInContent={clearWechatidInContent}
parseMessageContent={parseMessageContent}
onCheckboxChange={handleCheckboxChange}
onContextMenu={handleContextMenu}
/>
);
})}
</React.Fragment>
))}
<div ref={messagesEndRef} />
@@ -792,13 +682,10 @@ const MessageRecordComponent: React.FC<MessageRecordProps> = ({ contract }) => {
};
// ✅ 使用 React.memo 优化 MessageRecord 组件,避免不必要的重渲染
const MessageRecord = React.memo(
MessageRecordComponent,
(prev, next) => {
// 只有当联系人 ID 变化时才重新渲染
return prev.contract.id === next.contract.id;
},
);
const MessageRecord = React.memo(MessageRecordComponent, (prev, next) => {
// 只有当联系人 ID 变化时才重新渲染
return prev.contract.id === next.contract.id;
});
MessageRecord.displayName = "MessageRecord";

View File

@@ -1,7 +1,11 @@
// 朋友圈相关的API接口
import { useWebSocketStore } from "@/store/module/websocket/websocket";
// 朋友圈请求参数接口
// ==================== 已废弃的 Socket 请求接口 ====================
// 注意:朋友圈数据获取已改为使用 getFriendsCircleData HTTP 接口
// 以下接口仅保留用于向后兼容,新代码请使用 getFriendsCircleData
// 朋友圈请求参数接口(已废弃)
export interface FetchMomentParams {
wechatAccountId: number;
wechatFriendId?: number;
@@ -12,7 +16,11 @@ export interface FetchMomentParams {
seq?: number;
}
// 获取朋友圈数据
/**
* 获取朋友圈数据(已废弃)
* @deprecated 请使用 getFriendsCircleData 接口替代
* 新代码请从 @/pages/pc/ckbox/weChat/components/SidebarMenu/FriendsCicle/api 导入 getFriendsCircleData
*/
export const fetchFriendsCircleData = async (params: FetchMomentParams) => {
const { sendCommand } = useWebSocketStore.getState();
sendCommand("CmdFetchMoment", params);

View File

@@ -11,9 +11,9 @@ import {
CommentItem,
likeListItem,
FriendCardProps,
MomentListProps,
FriendsCircleItem,
} from "@/pages/pc/ckbox/weChat/components/SidebarMenu/FriendsCicle/index.data";
import { MomentListProps } from "../index.data";
import styles from "../index.module.scss";
import {
likeMoment,
@@ -320,7 +320,7 @@ export const MomentList: React.FC<MomentListProps> = ({
<Spin indicator={<LoadingOutlined spin />} /> ...
</div>
) : (
<p className={styles.emptyText}></p>
<p className={styles.emptyText}></p>
)}
</div>
);

View File

@@ -1,69 +1,250 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Collapse } from "antd";
import { ChromeOutlined } from "@ant-design/icons";
import { MomentList } from "./components/friendCard";
import dayjs from "dayjs";
import styles from "./index.module.scss";
import { fetchFriendsCircleData } from "./api";
import { getFriendsCircleData } from "@/pages/pc/ckbox/weChat/components/SidebarMenu/FriendsCicle/api";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useShallow } from "zustand/react/shallow";
import { getWechatFriendDetail } from "@/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/api";
interface FriendsCircleProps {
wechatFriendId?: number;
wechatId?: string; // 直接传入的微信id优先使用
}
const FriendsCircle: React.FC<FriendsCircleProps> = ({ wechatFriendId }) => {
const FriendsCircle: React.FC<FriendsCircleProps> = ({
wechatFriendId,
wechatId,
}) => {
const currentKf = useCkChatStore(state =>
state.kfUserList.find(kf => kf.id === state.kfSelected),
);
// ✅ 使用 useShallow 避免 getSnapshot 警告
const { clearMomentCommon, updateMomentCommonLoading } = useWeChatStore(
useShallow(state => ({
clearMomentCommon: state.clearMomentCommon,
updateMomentCommonLoading: state.updateMomentCommonLoading,
})),
);
const MomentCommon = useWeChatStore(state => state.MomentCommon);
const MomentCommonLoading = useWeChatStore(
state => state.MomentCommonLoading,
);
// 页面重新渲染时重置MomentCommonLoading状态
useEffect(() => {
updateMomentCommonLoading(false);
}, []);
// ✅ 使用本地状态隔离个人资料朋友圈的数据,避免与侧边栏朋友圈数据冲突
const [MomentCommon, setMomentCommon] = useState<any[]>([]);
const [MomentCommonLoading, setMomentCommonLoading] = useState(false);
// 状态管理
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
// 当前页码,用于分页
const currentPageRef = useRef<number>(1);
// 好友的 wechatId缓存
const friendWechatIdRef = useRef<string | undefined>(undefined);
// 加载更多我的朋友圈
const loadMomentData = async (loadMore: boolean = false) => {
updateMomentCommonLoading(true);
// 加载数据;
const requestData = {
cmdType: "CmdFetchMoment",
wechatAccountId: currentKf?.id || 0,
wechatFriendId: wechatFriendId || 0,
createTimeSec: Math.floor(dayjs().subtract(2, "month").valueOf() / 1000),
prevSnsId: loadMore
? Number(MomentCommon[MomentCommon.length - 1]?.snsId) || 0
: 0,
count: 10,
isTimeline: expandedKeys.includes("1"),
seq: Date.now(),
};
await fetchFriendsCircleData(requestData);
// 当 wechatFriendId 或 wechatId 变化时,清空数据并重置缓存
useEffect(() => {
console.log("🔄 [个人资料] 好友信息变化,重置朋友圈数据:", {
wechatFriendId,
wechatId,
});
setMomentCommon([]);
setMomentCommonLoading(false);
friendWechatIdRef.current = wechatId; // 如果直接传入了 wechatId直接使用
currentPageRef.current = 1;
setExpandedKeys([]); // 重置展开状态
}, [wechatFriendId, wechatId]);
// 加载朋友圈数据
const loadMomentData = async (
loadMore: boolean = false,
forceKey?: string,
) => {
// 如果既没有 wechatId 也没有 wechatFriendId无法加载
if (!wechatId && !wechatFriendId) {
console.warn(
"⚠️ wechatId 和 wechatFriendId 都不存在,无法加载好友朋友圈",
);
setMomentCommonLoading(false);
return;
}
// 确定当前场景的 key优先使用传入的 forceKey否则使用 expandedKeys
const currentKey = forceKey || expandedKeys[0];
if (!currentKey) {
// 如果没有展开任何面板,不加载数据
setMomentCommonLoading(false);
return;
}
console.log(
"🔄 [个人资料] 开始加载好友朋友圈数据wechatFriendId:",
wechatFriendId,
"场景key:",
currentKey,
"是否加载更多:",
loadMore,
);
setMomentCommonLoading(true);
try {
// 获取好友的 wechatId
// 优先使用直接传入的 wechatId如果没有则通过 wechatFriendId 获取
if (!friendWechatIdRef.current) {
if (wechatId) {
// 如果直接传入了 wechatId直接使用
friendWechatIdRef.current = wechatId;
console.log(
"✅ [个人资料] 使用直接传入的 wechatId:",
friendWechatIdRef.current,
);
} else if (wechatFriendId) {
// 如果没有直接传入,通过 wechatFriendId 获取
try {
const friendDetail = await getWechatFriendDetail({
id: wechatFriendId,
});
if (friendDetail?.detail?.wechatId) {
friendWechatIdRef.current = friendDetail.detail.wechatId;
console.log(
"✅ [个人资料] 通过 wechatFriendId 获取 wechatId 成功:",
friendWechatIdRef.current,
);
} else {
console.error("❌ 好友详情中没有 wechatId");
setMomentCommonLoading(false);
return;
}
} catch (error) {
console.error("获取好友详情失败:", error);
setMomentCommonLoading(false);
return;
}
} else {
console.error("❌ 无法获取 wechatId");
setMomentCommonLoading(false);
return;
}
}
console.log(
"✅ [个人资料] 使用好友的 wechatId 加载朋友圈:",
friendWechatIdRef.current,
);
// 重置页码(如果不是加载更多)
if (!loadMore) {
currentPageRef.current = 1;
}
// 调用接口获取朋友圈数据(传好友的 wechatId
console.log(friendWechatIdRef);
const result = await getFriendsCircleData({
wechatId: friendWechatIdRef.current,
page: currentPageRef.current,
limit: 10,
});
// 处理返回的数据
const momentList = result?.list || [];
// 转换数据格式:将 API 返回的数据转换为组件内部使用的格式
const transformedList = momentList.map((item: any) => {
// 转换 createTime从 "2026-01-15 13:27:37" 格式转换为时间戳(秒)
let createTimeNumber: number;
if (typeof item.createTime === "string") {
// 处理 "2026-01-15 13:27:37" 格式
// 将 "2026-01-15 13:27:37" 转换为 "2026/01/15 13:27:37" 以便正确解析
const normalizedTime = item.createTime.replace(/-/g, "/");
const date = new Date(normalizedTime);
if (!isNaN(date.getTime())) {
createTimeNumber = Math.floor(date.getTime() / 1000);
} else {
// 如果解析失败,尝试 ISO 格式或直接解析
const fallbackDate = new Date(item.createTime);
createTimeNumber = isNaN(fallbackDate.getTime())
? Math.floor(Date.now() / 1000)
: Math.floor(fallbackDate.getTime() / 1000);
console.warn(
"⚠️ 时间格式解析异常,使用备用解析:",
createTimeNumber,
"原始值:",
item.createTime,
);
}
} else if (typeof item.createTime === "number") {
// 如果已经是数字,确保是秒级时间戳
createTimeNumber =
item.createTime > 1000000000000
? Math.floor(item.createTime / 1000)
: item.createTime;
} else {
// 默认值
createTimeNumber = Math.floor(Date.now() / 1000);
}
// 构建组件内部使用的数据结构
// 注意:组件期望 momentEntity 中包含 content 和 resUrls
return {
snsId: item.snsId,
type: item.type,
commentList: item.commentList || [],
likeList: item.likeList || [],
createTime: createTimeNumber, // number 类型
momentEntity: {
content: item.content || "",
createTime: createTimeNumber, // number 类型
lat: parseFloat(item.momentEntity?.lat || "0"),
lng: parseFloat(item.momentEntity?.lng || "0"),
location: item.momentEntity?.location || "",
objectType: item.type,
picSize: item.momentEntity?.picSize || 0,
resUrls: item.resUrls || [],
snsId: item.snsId,
urls: item.resUrls || [],
userName: item.momentEntity?.userName || "",
},
};
});
if (loadMore) {
// 加载更多:追加数据
setMomentCommon(prev => [...prev, ...transformedList]);
} else {
// 首次加载:替换数据
setMomentCommon(transformedList);
}
// 如果返回的数据少于 limit说明没有更多数据了
if (momentList.length < 10) {
console.log("📄 [个人资料] 已加载全部朋友圈数据");
} else {
// 增加页码,准备下次加载更多
currentPageRef.current += 1;
}
} catch (error) {
console.error("[个人资料] 加载朋友圈数据失败:", error);
} finally {
setMomentCommonLoading(false);
}
};
// 处理折叠面板展开/收起
const handleCollapseChange = (keys: string | string[]) => {
const keyArray = Array.isArray(keys) ? keys : [keys];
const previousKey = expandedKeys[0];
const newKey = keyArray[0];
console.log("📂 折叠面板变化:", {
previousKey,
newKey,
keysLength: keys.length,
});
setExpandedKeys(keyArray);
if (!MomentCommonLoading && keys.length > 0) {
clearMomentCommon();
loadMomentData(false);
// 当展开面板时keys.length > 0加载数据
// 注意:这里直接传入 newKey避免使用还未更新的 expandedKeys
if (keys.length > 0 && newKey) {
console.log("✅ [个人资料] 展开面板准备加载数据场景key:", newKey);
setMomentCommon([]); // 清空本地数据
currentPageRef.current = 1; // 重置页码
// 传入 newKey 作为 forceKey确保使用最新的 key
loadMomentData(false, newKey);
} else {
console.log("❌ [个人资料] 收起面板,不加载数据");
}
};

View File

@@ -33,6 +33,7 @@ import TwoColumnMemberSelection from "@/components/MemberSelection/TwoColumnMemb
import { FriendSelectionItem } from "@/components/FriendSelection/data";
import DetailValue from "./components/detailValue";
import { getFriendInfo, FriendDetailResponse, updateFriendInfo } from "./api";
import { getContactList } from "@/pages/pc/ckbox/weChat/api";
import styles from "./Person.module.scss";
interface PersonProps {
contract: ContractData | weChatGroup;
@@ -747,45 +748,41 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
const [currentContactPage, setCurrentContactPage] = useState(1);
const [contactPageSize] = useState(10);
const [isLoadingContacts, setIsLoadingContacts] = useState(false);
const [searchKeyword, setSearchKeyword] = useState(""); // 搜索关键词
const [searchTimer, setSearchTimer] = useState<ReturnType<
typeof setTimeout
> | null>(null);
// 从数据库获取联系人数据的通用函数
const fetchContacts = async (page = 1) => {
// 从好友列表 API 获取联系人数据的通用函数
const fetchContacts = async (page = 1, keyword = "") => {
try {
const { databaseManager, initializeDatabaseFromPersistedUser } =
await import("@/utils/db");
const params: any = {
page,
limit: contactPageSize,
wechatAccountId: contract.wechatAccountId,
};
// 检查数据库初始化状态
if (!databaseManager.isInitialized()) {
await initializeDatabaseFromPersistedUser();
// 如果有搜索关键词,添加到参数中
if (keyword && keyword.trim()) {
params.keyword = keyword.trim();
}
// 获取当前用户ID
const userId = (kfSelectedUser as any)?.userId || 0;
const storeUserId = databaseManager.getCurrentUserId();
const effectiveUserId = storeUserId || userId;
console.log("📋 [添加群成员] 从 API 获取好友列表:", params);
if (!effectiveUserId) {
messageApi.error("无法获取用户信息,请尝试重新登录");
return [];
}
// 调用好友列表 API 接口
const result = await getContactList(params, { debounceGap: 0 });
// 查询联系人数据
const allContacts = await contactUnifiedService.findWhereMultiple([
{ field: "userId", operator: "equals", value: effectiveUserId },
{
field: "wechatAccountId",
operator: "equals",
value: contract.wechatAccountId,
},
{ field: "type", operator: "equals", value: "friend" },
]);
const friendList = result?.list || [];
console.log("✅ [添加群成员] 获取到好友列表:", {
page,
keyword,
count: friendList.length,
total: result?.total || 0,
});
// 手动分页
const startIndex = (page - 1) * contactPageSize;
const endIndex = startIndex + contactPageSize;
return allContacts.slice(startIndex, endIndex);
return friendList;
} catch (error) {
console.error("获取联系人数据失败:", error);
console.error("❌ [添加群成员] 获取联系人数据失败:", error);
messageApi.error("获取联系人数据失败");
return [];
}
@@ -794,7 +791,9 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
const addMember = async () => {
try {
setIsLoadingContacts(true);
const pagedContacts = await fetchContacts(currentContactPage);
setSearchKeyword(""); // 重置搜索关键词
setCurrentContactPage(1); // 重置页码
const pagedContacts = await fetchContacts(1, "");
// 转换为选择器需要的数据格式
const friendSelectionData = pagedContacts.map(item => ({
id: item.id || item.serverId,
@@ -810,7 +809,7 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
// 如果没有联系人数据,显示提示
if (friendSelectionData.length === 0) {
messageApi.info("未找到可添加的联系人,可能需要先同步联系人数据");
messageApi.info("未找到可添加的联系人");
}
} catch (error) {
console.error("获取联系人列表失败:", error);
@@ -827,8 +826,8 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
setIsLoadingContacts(true);
const nextPage = currentContactPage + 1;
setCurrentContactPage(nextPage);
// 使用通用函数获取下一页联系人数据
const pagedContacts = await fetchContacts(nextPage);
// 使用通用函数获取下一页联系人数据,传入当前搜索关键词
const pagedContacts = await fetchContacts(nextPage, searchKeyword);
// 转换数据格式
const newFriendSelectionData = pagedContacts.map(item => ({
id: item.id || item.serverId,
@@ -854,7 +853,12 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
return uniqueList;
});
messageApi.success(`已加载${pagedContacts.length}条联系人数据`);
// 成功加载,只在控制台输出日志
if (pagedContacts.length > 0) {
console.log(
`✅ [加载更多] 已加载 ${pagedContacts.length} 条联系人数据`,
);
}
} catch (error) {
console.error("加载更多联系人失败:", error);
messageApi.error("加载更多联系人失败");
@@ -862,6 +866,53 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
setIsLoadingContacts(false);
}
};
// 处理搜索
const handleSearchContacts = async (keyword: string) => {
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer);
}
setSearchKeyword(keyword);
// 设置新的防抖定时器
const timer = setTimeout(async () => {
try {
setIsLoadingContacts(true);
setCurrentContactPage(1); // 重置页码
console.log("🔍 [搜索好友] 关键词:", keyword);
const pagedContacts = await fetchContacts(1, keyword);
const friendSelectionData = pagedContacts.map(item => ({
id: item.id || item.serverId,
wechatId: item.wechatId,
nickname: item.nickname,
avatar: item.avatar || "",
conRemark: item.conRemark,
name: item.conRemark || item.nickname,
}));
setContractList(friendSelectionData);
// 只有搜索无结果时才提示
if (keyword && friendSelectionData.length === 0) {
messageApi.info(`未找到包含"${keyword}"的好友`);
} else if (keyword && friendSelectionData.length > 0) {
console.log(
`✅ [搜索成功] 找到 ${friendSelectionData.length} 条匹配结果`,
);
}
} catch (error) {
console.error("搜索好友失败:", error);
messageApi.error("搜索好友失败");
} finally {
setIsLoadingContacts(false);
}
}, 300); // 300ms 防抖
setSearchTimer(timer);
};
// 不再需要加载状态和错误状态的渲染,始终显示缓存数据
return (
@@ -1502,6 +1553,8 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
onCancel={() => {
setIsFriendSelectionVisible(false);
setCurrentContactPage(1); // 重置页码
setSearchKeyword(""); // 重置搜索关键词
if (searchTimer) clearTimeout(searchTimer); // 清除定时器
}}
onConfirm={(selectedIds, selectedItems) => {
handleAddMember(
@@ -1509,10 +1562,12 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
selectedItems,
);
setCurrentContactPage(1); // 重置页码
setSearchKeyword(""); // 重置搜索关键词
}}
dataSource={contractList}
title="添加群成员"
onLoadMore={loadMoreContacts}
onSearch={handleSearchContacts} // 传入搜索处理函数
hasMore={true} // 强制设置为true确保显示加载更多按钮
loading={isLoadingContacts}
/>

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,8 @@ import {
PictureOutlined,
PlayCircleOutlined,
SearchOutlined,
LinkOutlined,
QuestionCircleOutlined,
} from "@ant-design/icons";
import {
QuickWordsItem,
@@ -75,6 +77,8 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
const [addModalVisible, setAddModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [groupModalVisible, setGroupModalVisible] = useState(false);
const [previewModalVisible, setPreviewModalVisible] = useState(false);
const [previewReply, setPreviewReply] = useState<QuickWordsReply | null>(null);
const [editingItem, setEditingItem] = useState<QuickWordsReply | null>(null);
const [editingGroup, setEditingGroup] = useState<QuickWordsItem | null>(null);
const [isAddingGroup, setIsAddingGroup] = useState(false);
@@ -90,13 +94,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;
@@ -130,6 +150,11 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
};
const previewAndConfirmSend = (reply: QuickWordsReply) => {
setPreviewReply(reply);
setPreviewModalVisible(true);
};
const renderPreviewContent = (reply: QuickWordsReply) => {
let previewNode: React.ReactNode = null;
if (reply.msgType === MessageType.IMAGE) {
previewNode = (
@@ -141,45 +166,167 @@ 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,
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({
title: "确认发送该快捷语?",
content: previewNode,
okText: "发送",
cancelText: "取消",
onOk: () => {
sendQuickReplyNow(reply);
message.success("已发送");
},
});
return previewNode;
};
const handleConfirmSend = () => {
if (previewReply) {
sendQuickReplyNow(previewReply);
message.success("已发送");
setPreviewModalVisible(false);
setPreviewReply(null);
}
};
// 获取快捷语数据
@@ -326,10 +473,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 +505,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 +582,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 +742,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}
@@ -612,6 +772,28 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
setIsAddingGroup(false);
}}
/>
{/* 预览确认发送模态窗 */}
<Modal
title={
<div style={{ textAlign: "center", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: 8 }}>
<QuestionCircleOutlined style={{ fontSize: 18, color: "#1890ff" }} />
<span></span>
</div>
}
open={previewModalVisible}
onOk={handleConfirmSend}
onCancel={() => {
setPreviewModalVisible(false);
setPreviewReply(null);
}}
okText="发送"
cancelText="取消"
width={520}
centered
>
{previewReply && renderPreviewContent(previewReply)}
</Modal>
</Layout>
);
};

View File

@@ -45,9 +45,16 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
baseItems.push({
key: "moments",
label: "朋友圈",
children: <FriendsCircle wechatFriendId={currentContract.id} />,
children: (
<FriendsCircle
wechatFriendId={currentContract.id}
wechatId={(currentContract as ContractData).wechatId}
/>
),
});
}
console.log(currentContract);
return baseItems;
}, [currentContract, isGroup]);

View File

@@ -105,45 +105,98 @@
flex-direction: column;
align-items: center;
padding: 10px 0;
}
.skeletonItem {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px 0;
position: relative;
margin-bottom: 10px;
}
.skeletonAvatar {
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
.skeletonIndicator {
position: absolute;
bottom: 10px;
right: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
.skeletonItem {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px 0;
position: relative;
margin-bottom: 10px;
}
100% {
background-position: -200% 0;
.skeletonAvatar {
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
.skeletonIndicator {
position: absolute;
bottom: 10px;
right: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
}
}
// Tooltip 样式
:global {
.customerTooltip {
.ant-tooltip-inner {
background-color: rgba(0, 0, 0, 0.85);
padding: 12px 16px;
border-radius: 6px;
box-shadow:
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
.ant-tooltip-arrow {
&::before {
background-color: rgba(0, 0, 0, 0.85);
}
}
}
}
.tooltipContent {
min-width: 200px;
.tooltipItem {
display: flex;
align-items: center;
line-height: 1.4;
margin-bottom: 6px;
&:last-child {
margin-bottom: 0;
}
.tooltipLabel {
color: rgba(255, 255, 255, 0.65);
font-size: 12px;
min-width: 70px;
flex-shrink: 0;
line-height: 1.4;
}
.tooltipValue {
color: #ffffff;
font-size: 13px;
font-weight: 500;
word-break: break-all;
flex: 1;
line-height: 1.4;
}
}
}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Avatar, Badge } from "antd";
import { Avatar, Badge, Tooltip } from "antd";
import styles from "./com.module.scss";
import {
useCustomerStore,
@@ -17,15 +17,22 @@ const CustomerList: React.FC = () => {
getCustomerList()
.then(res => {
updateCustomerList(res);
// 如果当前没有选中的客服,自动选择第一个
// 默认选中"全部"currentCustomer 为 null 表示显示所有)
const current = useCustomerStore.getState().currentCustomer;
if (!current && res.length > 0) {
console.log("🔄 自动选择第一个账号:", res[0]);
updateCurrentCustomer(res[0]);
if (current === undefined) {
console.log("🔄 默认选中全部账号");
updateCurrentCustomer(null);
}
setLoading(false);
})
.catch(() => {
.catch(error => {
console.error("❌ 获取客服列表失败:", error);
// 即使加载失败,也设置为 null表示"全部"),避免阻塞会话列表
const current = useCustomerStore.getState().currentCustomer;
if (current === undefined) {
console.log("🔄 加载失败,降级为全部账号");
updateCurrentCustomer(null);
}
setLoading(false);
});
}, []);
@@ -74,6 +81,40 @@ const CustomerList: React.FC = () => {
</div>
);
// 渲染客服信息提示内容
const renderTooltipContent = (customer: any) => {
const deviceName = customer.deviceExtra?.memo || customer.deviceExtra?.market_name || "未知设备";
const deviceModel = customer.deviceExtra?.market_name || "未知型号";
const wechatAlias = customer.alias || "未设置";
const wechatNickname = customer.nickname || "未知昵称";
const battery = customer.deviceExtra?.battery !== undefined ? `${customer.deviceExtra.battery}%` : "未知";
return (
<div className={styles.tooltipContent}>
<div className={styles.tooltipItem}>
<span className={styles.tooltipLabel}></span>
<span className={styles.tooltipValue}>{deviceName}</span>
</div>
<div className={styles.tooltipItem}>
<span className={styles.tooltipLabel}></span>
<span className={styles.tooltipValue}>{deviceModel}</span>
</div>
<div className={styles.tooltipItem}>
<span className={styles.tooltipLabel}></span>
<span className={styles.tooltipValue}>{battery}</span>
</div>
<div className={styles.tooltipItem}>
<span className={styles.tooltipLabel}></span>
<span className={styles.tooltipValue}>{wechatAlias}</span>
</div>
<div className={styles.tooltipItem}>
<span className={styles.tooltipLabel}></span>
<span className={styles.tooltipValue}>{wechatNickname}</span>
</div>
</div>
);
};
return (
<div className={styles.customerList}>
<div className={styles.userListHeader}>
@@ -85,7 +126,7 @@ const CustomerList: React.FC = () => {
) : (
<>
<div
className={styles.userItem}
className={`${styles.userItem} ${currentCustomer === null ? styles.active : ""}`}
onClick={() => handleUserSelect(0)}
>
<Badge
@@ -106,6 +147,12 @@ const CustomerList: React.FC = () => {
count={getUnreadCount(customer.id)}
overflowCount={99}
className={styles.messageBadge}
>
<Tooltip
title={renderTooltipContent(customer)}
placement="right"
mouseEnterDelay={0.3}
classNames={{ root: styles.customerTooltip }}
>
<div className={styles.avatarWrapper}>
<Avatar
@@ -118,7 +165,7 @@ const CustomerList: React.FC = () => {
: undefined,
}}
>
{!customer.avatar && customer.name.charAt(0)}
{!customer.avatar && customer.nickname?.charAt(0)}
</Avatar>
{customer.isOnline && (
<span
@@ -126,6 +173,7 @@ const CustomerList: React.FC = () => {
/>
)}
</div>
</Tooltip>
</Badge>
</div>
))}

View File

@@ -41,6 +41,22 @@ const AddFriends: React.FC<AddFriendsProps> = ({ visible, onCancel }) => {
return /^1[3-9]\d{9}$/.test(value.trim());
};
// 过滤中文字符,只允许英文、数字和常见符号
const filterChinese = (value: string): string => {
// 只保留英文、数字、下划线、连字符、点号等常见符号
return value.replace(/[^\x00-\x7F]/g, "");
};
// 处理输入变化,过滤中文字符
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const filteredValue = filterChinese(value);
if (value !== filteredValue) {
message.warning("不能输入中文字符");
}
setSearchValue(filteredValue);
};
// 处理添加好友
const handleAddFriend = async () => {
if (!searchValue.trim()) {
@@ -95,7 +111,7 @@ const AddFriends: React.FC<AddFriendsProps> = ({ visible, onCancel }) => {
placeholder="请输入微信号/手机号"
prefix={<SearchOutlined />}
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
onChange={handleInputChange}
onPressEnter={handleAddFriend}
disabled={loading}
allowClear

View File

@@ -1,7 +1,13 @@
// 朋友圈相关的API接口
import request from "@/api/request";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { MoneyCollectFilled } from "@ant-design/icons";
// 朋友圈请求参数接口
// ==================== 已废弃的 Socket 请求接口 ====================
// 注意:朋友圈数据获取已改为使用 getFriendsCircleData HTTP 接口
// 以下接口仅保留用于向后兼容,新代码请使用 getFriendsCircleData
// 朋友圈请求参数接口(已废弃)
export interface FetchMomentParams {
wechatAccountId: number;
wechatFriendId?: number;
@@ -12,7 +18,10 @@ export interface FetchMomentParams {
seq?: number;
}
// 获取朋友圈数据
/**
* 获取朋友圈数据(已废弃)
* @deprecated 请使用 getFriendsCircleData 接口替代
*/
export const fetchFriendsCircleData = async (params: FetchMomentParams) => {
const { sendCommand } = useWebSocketStore.getState();
sendCommand("CmdFetchMoment", params);
@@ -104,3 +113,113 @@ export const cancelCommentMoment = async (params: {
sendCommand("CmdMomentCancelInteract", requestData);
};
// ==================== HTTP 接口(推荐使用) ====================
/**
* 获取朋友圈数据请求参数
*/
export interface GetFriendsCircleDataParams {
/** 微信号的id例如wxid_480es52qsj2812
* - 不传:返回所有朋友圈(朋友圈广场)
* - 传当前账号的 wechatId返回我的朋友圈
* - 传好友的 wechatId返回好友朋友圈
*/
wechatId?: string;
/** 页码,从 1 开始 */
page: number;
/** 每页数量 */
limit: number;
}
/**
* 朋友圈实体数据类型API 返回格式)
*/
export interface MomentEntity {
lat: string;
lng: string;
location: string;
picSize: number;
userName: string;
}
/**
* 朋友圈数据项API 返回格式)
* 实际数据结构示例:
* {
* "id": 44671,
* "snsId": "-3611869511354674694",
* "type": 1,
* "content": "...",
* "commentList": [],
* "likeList": [],
* "resUrls": [...],
* "createTime": "2026-01-15 13:27:37",
* "momentEntity": {
* "lat": "0.000000",
* "lng": "0.000000",
* "location": "",
* "picSize": 0,
* "userName": "wxid_68z14kxrxsho22"
* }
* }
*/
export interface FriendsCircleItem {
id: number;
snsId: string;
type: number;
content: string;
commentList: any[];
likeList: any[];
resUrls: string[];
createTime: string; // 格式:"2026-01-15 13:27:37"
momentEntity: MomentEntity;
[key: string]: any;
}
/**
* 获取朋友圈数据响应
*/
export interface GetFriendsCircleDataResponse {
list: FriendsCircleItem[];
}
/**
* 获取朋友圈数据
*
* @param params 请求参数
* @param params.wechatId 微信号ID可选
* - 不传:返回所有朋友圈(朋友圈广场)
* - 传当前账号的 wechatId返回我的朋友圈
* - 传好友的 wechatId返回好友朋友圈
* @param params.page 页码,从 1 开始
* @param params.limit 每页数量
* @returns Promise<GetFriendsCircleDataResponse> 朋友圈数据列表
*
* @example
* // 朋友圈广场(不传 wechatId
* const result = await getFriendsCircleData({ page: 1, limit: 10 });
*
* // 我的朋友圈(传当前账号的 wechatId
* const result = await getFriendsCircleData({
* wechatId: currentCustomer.wechatId,
* page: 1,
* limit: 10
* });
*
* // 好友朋友圈(传好友的 wechatId
* const result = await getFriendsCircleData({
* wechatId: friendWechatId,
* page: 1,
* limit: 10
* });
*/
export const getFriendsCircleData = async (
params: GetFriendsCircleDataParams,
) => {
return request<GetFriendsCircleDataResponse>(
"/v1/wechats/moments",
params,
"GET",
);
};

View File

@@ -1,13 +1,13 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Collapse } from "antd";
import { ChromeOutlined } from "@ant-design/icons";
import { MomentList } from "./components/friendCard";
import dayjs from "dayjs";
import styles from "./index.module.scss";
import { fetchFriendsCircleData } from "./api";
import { getFriendsCircleData, FriendsCircleItem } from "./api";
import { useCustomerStore } from "@/store/module/weChat/customer";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { getWechatFriendDetail } from "../MessageList/api";
interface FriendsCircleProps {
wechatFriendId?: number;
@@ -15,7 +15,12 @@ interface FriendsCircleProps {
const FriendsCircle: React.FC<FriendsCircleProps> = ({ wechatFriendId }) => {
const currentCustomer = useCustomerStore(state => state.currentCustomer);
const { clearMomentCommon, updateMomentCommonLoading } = useWeChatStore();
const {
clearMomentCommon,
updateMomentCommonLoading,
addMomentCommon,
updateMomentCommon,
} = useWeChatStore();
const MomentCommon = useWeChatStore(state => state.MomentCommon);
const MomentCommonLoading = useWeChatStore(
state => state.MomentCommonLoading,
@@ -28,33 +33,200 @@ const FriendsCircle: React.FC<FriendsCircleProps> = ({ wechatFriendId }) => {
// 状态管理
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
// 当前页码,用于分页
const currentPageRef = useRef<number>(1);
// 当前场景的 wechatId用于好友朋友圈
const friendWechatIdRef = useRef<string | undefined>(undefined);
// 加载朋友圈数据
const loadMomentData = async (
loadMore: boolean = false,
forceKey?: string,
) => {
if (!currentCustomer) {
updateMomentCommonLoading(false);
return;
}
// 确定当前场景的 key优先使用传入的 forceKey否则使用 expandedKeys
const currentKey = forceKey || expandedKeys[0];
if (!currentKey) {
// 如果没有展开任何面板,不加载数据
updateMomentCommonLoading(false);
return;
}
console.log(
"🔄 开始加载朋友圈数据场景key:",
currentKey,
"是否加载更多:",
loadMore,
);
// 加载更多我的朋友圈
const loadMomentData = async (loadMore: boolean = false) => {
updateMomentCommonLoading(true);
// 加载数据;
const requestData = {
cmdType: "CmdFetchMoment",
wechatAccountId: currentCustomer?.id || 0,
wechatFriendId: wechatFriendId || 0,
createTimeSec: Math.floor(dayjs().subtract(2, "month").valueOf() / 1000),
prevSnsId: loadMore
? Number(MomentCommon[MomentCommon.length - 1]?.snsId) || 0
: 0,
count: 10,
isTimeline: expandedKeys.includes("1"),
seq: Date.now(),
};
await fetchFriendsCircleData(requestData);
try {
let targetWechatId: string | undefined = undefined;
// 根据不同的场景设置 wechatId
if (currentKey === "1") {
// 我的朋友圈传当前选中客服的微信id
targetWechatId = currentCustomer?.wechatId;
if (!targetWechatId) {
console.warn("⚠️ 当前客服的 wechatId 不存在,无法加载我的朋友圈");
updateMomentCommonLoading(false);
return;
}
console.log("📱 加载我的朋友圈使用客服微信id:", targetWechatId);
} else if (currentKey === "2") {
// 朋友圈广场:不传 wechatId
targetWechatId = undefined;
console.log("🌐 加载朋友圈广场(不传 wechatId");
} else if (currentKey === "3" && wechatFriendId) {
// 好友朋友圈:传好友的 wechatId
// 如果还没有获取过好友的 wechatId先获取
if (!friendWechatIdRef.current) {
try {
const friendDetail = await getWechatFriendDetail({
id: wechatFriendId,
});
if (friendDetail?.detail?.wechatId) {
friendWechatIdRef.current = friendDetail.detail.wechatId;
}
} catch (error) {
console.error("获取好友详情失败:", error);
updateMomentCommonLoading(false);
return;
}
}
targetWechatId = friendWechatIdRef.current;
}
// 重置页码(如果不是加载更多)
if (!loadMore) {
currentPageRef.current = 1;
}
// 调用接口获取朋友圈数据
const result = await getFriendsCircleData({
wechatId: targetWechatId,
page: currentPageRef.current,
limit: 10,
});
// 处理返回的数据
const momentList = result?.list || [];
// 转换数据格式:将 API 返回的数据转换为组件内部使用的格式
const transformedList = momentList.map((item: FriendsCircleItem) => {
// 转换 createTime从 "2026-01-15 13:27:37" 格式转换为时间戳(秒)
let createTimeNumber: number;
if (typeof item.createTime === "string") {
// 处理 "2026-01-15 13:27:37" 格式
// 将 "2026-01-15 13:27:37" 转换为 "2026/01/15 13:27:37" 以便正确解析
const normalizedTime = item.createTime.replace(/-/g, "/");
const date = new Date(normalizedTime);
if (!isNaN(date.getTime())) {
createTimeNumber = Math.floor(date.getTime() / 1000);
} else {
// 如果解析失败,尝试 ISO 格式或直接解析
const fallbackDate = new Date(item.createTime);
createTimeNumber = isNaN(fallbackDate.getTime())
? Math.floor(Date.now() / 1000)
: Math.floor(fallbackDate.getTime() / 1000);
console.warn(
"⚠️ 时间格式解析异常,使用备用解析:",
createTimeNumber,
"原始值:",
item.createTime,
);
}
} else if (typeof item.createTime === "number") {
// 如果已经是数字,确保是秒级时间戳
createTimeNumber =
item.createTime > 1000000000000
? Math.floor(item.createTime / 1000)
: item.createTime;
} else {
// 默认值
createTimeNumber = Math.floor(Date.now() / 1000);
}
// 构建组件内部使用的数据结构
// 注意:组件期望 momentEntity 中包含 content 和 resUrls
return {
snsId: item.snsId,
type: item.type,
commentList: item.commentList || [],
likeList: item.likeList || [],
createTime: createTimeNumber, // number 类型
momentEntity: {
content: item.content || "",
createTime: createTimeNumber, // number 类型
lat: parseFloat(item.momentEntity?.lat || "0"),
lng: parseFloat(item.momentEntity?.lng || "0"),
location: item.momentEntity?.location || "",
objectType: item.type,
picSize: item.momentEntity?.picSize || 0,
resUrls: item.resUrls || [],
snsId: item.snsId,
urls: item.resUrls || [],
userName: item.momentEntity?.userName || "",
},
};
});
if (loadMore) {
// 加载更多:追加数据
addMomentCommon(transformedList);
} else {
// 首次加载:替换数据
updateMomentCommon(transformedList);
}
// 如果返回的数据少于 limit说明没有更多数据了
if (momentList.length < 10) {
// 可以在这里设置一个标记,表示没有更多数据
} else {
// 增加页码,准备下次加载更多
currentPageRef.current += 1;
}
} catch (error) {
console.error("加载朋友圈数据失败:", error);
} finally {
updateMomentCommonLoading(false);
}
};
// 处理折叠面板展开/收起
const handleCollapseChange = (keys: string | string[]) => {
const keyArray = Array.isArray(keys) ? keys : [keys];
const previousKey = expandedKeys[0];
const newKey = keyArray[0];
console.log("📂 折叠面板变化:", {
previousKey,
newKey,
keysLength: keys.length,
});
setExpandedKeys(keyArray);
if (!MomentCommonLoading && keys.length > 0) {
// 如果切换了场景,重置好友 wechatId 缓存
if (previousKey !== newKey && newKey === "3") {
friendWechatIdRef.current = undefined;
}
// 当展开面板时keys.length > 0加载数据
// 注意:这里直接传入 newKey避免使用还未更新的 expandedKeys
if (keys.length > 0 && newKey) {
console.log("✅ 展开面板准备加载数据场景key:", newKey);
clearMomentCommon();
loadMomentData(false);
currentPageRef.current = 1; // 重置页码
// 传入 newKey 作为 forceKey确保使用最新的 key
loadMomentData(false, newKey);
} else {
console.log("❌ 收起面板,不加载数据");
}
};

View File

@@ -1,16 +1,74 @@
import request from "@/api/request";
import request2 from "@/api/request2";
//群、好友聊天记录列表
export function getMessageList(params: { page: number; limit: number }) {
return request("/v1/kefu/message/list", params, "GET");
import type { ApiResponse, ApiDetailResponse } from "@/api/types";
// ==================== 类型定义 ====================
/**
* 消息列表项类型
*/
export interface MessageListItem {
id: number;
dataType: "friend" | "group";
wechatAccountId: number;
nickname: string;
avatar?: string;
chatroomAvatar?: string;
conRemark?: string;
content?: string;
lastUpdateTime?: string;
config?: {
chat?: boolean;
unreadCount?: number;
top?: number;
};
[key: string]: any;
}
// 获取联系人列表
export const getContactList = (params: { prevId: string; count: number }) => {
return request("/api/wechatFriend/list", params, "GET");
};
/**
* 好友详情类型
*/
export interface WechatFriendDetail {
id: number;
wechatAccountId: number;
nickname: string;
avatar?: string;
conRemark?: string;
wechatId?: string;
alias?: string;
gender?: number;
region?: string;
signature?: string;
phone?: string;
quanPin?: string;
groupId?: number;
[key: string]: any;
}
export interface dataProcessingPost {
/**
* 群聊详情类型
*/
export interface WechatChatroomDetail {
id: number;
wechatAccountId: number;
nickname?: string;
name?: string;
chatroomName?: string;
chatroomAvatar?: string;
conRemark?: string;
chatroomId?: string;
chatroomOwner?: string;
selfDisplyName?: string;
selfDisplayName?: string;
notice?: string;
memberCount?: number;
[key: string]: any;
}
/**
* 数据处理请求参数
*/
export interface DataProcessingPost {
/**
* CmdModifyFriendLabel专属
*/
@@ -31,18 +89,49 @@ export interface dataProcessingPost {
[property: string]: any;
}
export const dataProcessing = (params: dataProcessingPost) => {
// ==================== API 函数 ====================
/**
* 群、好友聊天记录列表
* @returns Promise<MessageListItem[]> 返回消息列表(可能是数组或 {list: MessageListItem[]}
*/
export function getMessageList(params: { page: number; limit: number }): Promise<MessageListItem[] | ApiResponse<MessageListItem[]>> {
return request<MessageListItem[] | ApiResponse<MessageListItem[]>>("/v1/kefu/message/list", params, "GET");
}
/**
* 获取联系人列表
*/
export const getContactList = (params: { prevId: string; count: number }): Promise<any> => {
return request("/api/wechatFriend/list", params, "GET");
};
/**
* 数据处理接口
*/
export const dataProcessing = (params: DataProcessingPost): Promise<any> => {
return request("/v1/kefu/dataProcessing", params, "POST");
};
export const getWechatFriendDetail = (params: { id: number }) => {
return request("/v1/kefu/wechatFriend/detail", params, "GET");
/**
* 获取好友详情
* @returns Promise<ApiDetailResponse<WechatFriendDetail>> 返回详情响应(包含 detail 字段)
*/
export const getWechatFriendDetail = (params: { id: number }): Promise<ApiDetailResponse<WechatFriendDetail>> => {
return request<ApiDetailResponse<WechatFriendDetail>>("/v1/kefu/wechatFriend/detail", params, "GET");
};
export const getWechatChatroomDetail = (params: { id: number }) => {
return request("/v1/kefu/wechatChatroom/detail", params, "GET");
/**
* 获取群聊详情
* @returns Promise<ApiDetailResponse<WechatChatroomDetail>> 返回详情响应(包含 detail 字段)
*/
export const getWechatChatroomDetail = (params: { id: number }): Promise<ApiDetailResponse<WechatChatroomDetail>> => {
return request<ApiDetailResponse<WechatChatroomDetail>>("/v1/kefu/wechatChatroom/detail", params, "GET");
};
//更新配置
export function updateConfig(params) {
/**
* 更新配置
*/
export function updateConfig(params: any): Promise<any> {
return request2("/api/WechatFriend/updateConfig", params, "PUT");
}

View File

@@ -26,7 +26,7 @@ import { useUserStore } from "@storeModule/user";
import { MessageManager } from "@/utils/dbAction/message";
import { ContactManager } from "@/utils/dbAction/contact";
import { formatWechatTime } from "@/utils/common";
import { messageFilter } from "@/utils/filter";
import { formatMessagePreview } from "@/utils/messagePreview";
import { ChatSession } from "@/utils/db";
import { VirtualSessionList } from "@/components/VirtualSessionList";
interface MessageListProps {}
@@ -68,7 +68,7 @@ const SessionItem: React.FC<SessionItemProps> = React.memo(
</div>
</div>
<div className={styles.messageContent}>
{messageFilter(session.content)}
{formatMessagePreview(session.content)}
</div>
</div>
</div>
@@ -105,7 +105,6 @@ const MessageList: React.FC<MessageListProps> = () => {
// 使用新架构的sessions作为主要数据源保留filteredSessions作为fallback
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]);
const [syncing, setSyncing] = useState(false); // 同步状态
const hasEnrichedRef = useRef(false); // 是否已做过未知联系人补充
const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用
// 决定使用哪个数据源优先使用新架构的sessions否则使用本地filteredSessions
@@ -114,7 +113,17 @@ const MessageList: React.FC<MessageListProps> = () => {
// 调试日志:检查会话列表状态
useEffect(() => {
if (displaySessions.length === 0) {
// 只在以下情况才警告:
// 1. 会话列表为空
// 2. 已经完成过至少一次加载
// 3. currentCustomer 不是 undefined已加载客服列表
// 4. 不在同步中
if (
displaySessions.length === 0 &&
hasLoadedOnce &&
currentCustomer !== undefined &&
!syncing
) {
console.warn("⚠️ 会话列表为空,调试信息:", {
storeSessionsLength: storeSessions.length,
filteredSessionsLength: filteredSessions.length,
@@ -393,132 +402,8 @@ const MessageList: React.FC<MessageListProps> = () => {
};
}, [contextMenu.visible]);
// ==================== 数据加载 & 未知联系人补充 ====================
// 同步完成后,检查是否存在"未知联系人"或缺失头像/昵称的会话,并异步补充详情
const enrichUnknownContacts = async () => {
if (!currentUserId) return;
if (hasEnrichedRef.current) return; // 避免重复执行
// 只在会话有数据时执行使用displaySessions
const sessionsToCheck =
displaySessions.length > 0 ? displaySessions : filteredSessions;
if (!sessionsToCheck || sessionsToCheck.length === 0) return;
const needEnrich = sessionsToCheck.filter(s => {
const noName = !s.conRemark && !s.nickname && !s.wechatId;
const isUnknownNickname = s.nickname === "未知联系人";
const noAvatar = !s.avatar;
return noName || isUnknownNickname || noAvatar;
});
if (needEnrich.length === 0) {
hasEnrichedRef.current = true;
return;
}
hasEnrichedRef.current = true;
// 逐个异步拉取详情,失败不打断整体流程
for (const session of needEnrich) {
try {
let detailResult: any = null;
if (session.type === "friend") {
detailResult = await getWechatFriendDetail({ id: session.id });
} else {
detailResult = await getWechatChatroomDetail({ id: session.id });
}
const detail = detailResult?.detail;
if (!detail) continue;
// 更新会话列表 UI
setSessionState(prev =>
prev.map(s =>
s.id === session.id && s.type === session.type
? {
...s,
avatar:
session.type === "group"
? detail.chatroomAvatar || s.avatar
: detail.avatar || s.avatar,
nickname: detail.nickname || s.nickname,
conRemark: detail.conRemark || s.conRemark,
wechatId: detail.wechatId || s.wechatId,
}
: s,
),
);
// 同步到会话数据库
await MessageManager.updateSession({
userId: currentUserId,
id: session.id,
type: session.type,
avatar:
session.type === "group"
? detail.chatroomAvatar || session.avatar
: detail.avatar || session.avatar,
nickname: detail.nickname || session.nickname,
conRemark: detail.conRemark || session.conRemark,
wechatId: detail.wechatId || session.wechatId,
});
// 同步到联系人数据库(方便后续搜索、其它页面使用)
const contactBase: any = {
serverId: `${session.type}_${session.id}`,
userId: currentUserId,
id: session.id,
type: session.type,
wechatAccountId: detail.wechatAccountId,
nickname: detail.nickname || "",
conRemark: detail.conRemark || "",
avatar:
session.type === "group"
? detail.chatroomAvatar || ""
: detail.avatar || "",
lastUpdateTime: new Date().toISOString(),
sortKey: "",
searchKey: (detail.conRemark || detail.nickname || "").toLowerCase(),
};
if (session.type === "group") {
Object.assign(contactBase, {
chatroomId: detail.chatroomId,
chatroomOwner: detail.chatroomOwner,
selfDisplayName: detail.selfDisplyName,
notice: detail.notice,
});
} else {
Object.assign(contactBase, {
wechatFriendId: detail.id,
wechatId: detail.wechatId,
alias: detail.alias,
gender: detail.gender,
region: detail.region,
signature: detail.signature,
phone: detail.phone,
quanPin: detail.quanPin,
groupId: detail.groupId,
});
}
// 使用 upsert 逻辑:如果已存在就更新,不存在则新增
const existContact = await ContactManager.getContactByIdAndType(
currentUserId,
session.id,
session.type,
);
if (existContact) {
await ContactManager.updateContact(contactBase);
} else {
await ContactManager.addContact(contactBase);
}
} catch (error) {
console.error("补拉未知联系人详情失败:", error, session);
}
}
};
// ==================== 数据加载 ====================
// 注意:未知联系人的数据补齐已在 msgManage.ts 中统一处理,无需重复
// 与服务器同步数据优化版逐页同步立即更新UI
const syncWithServer = async () => {
@@ -534,62 +419,44 @@ const MessageList: React.FC<MessageListProps> = () => {
let page = 1;
const limit = 500;
let hasMore = true;
let totalProcessed = 0;
let successCount = 0;
let failCount = 0;
// 分页获取会话列表,每页成功后立即同步
// 📦 第一阶段:累积所有服务器数据
const allServerSessions = {
friends: [] as any[],
groups: [] as any[],
};
console.log("📡 [阶段1] 开始分页获取所有会话数据...");
while (hasMore) {
try {
console.log(`📡 请求第 ${page} 页会话列表...`, { page, limit });
let result: any;
try {
result = await getMessageList({
page,
limit,
});
page,
limit,
});
// ⭐ 关键修复:处理数据结构,提取实际的列表数据
// ⭐ 处理数据结构,提取实际的列表数据
let actualData = result;
if (result && typeof result === "object" && "list" in result) {
// 如果返回的是 {list: [...]} 结构,提取 list
actualData = result.list;
console.log(`📥 第 ${page} 页API响应对象包装:`, {
type: "object with list",
listIsArray: Array.isArray(actualData),
listLength: actualData?.length,
firstItem: actualData?.[0],
fullResult: result,
});
} else {
console.log(`📥 第 ${page} 页API响应直接数组:`, {
type: typeof result,
isArray: Array.isArray(result),
length: result?.length,
firstItem: result?.[0],
rawResult: result,
});
}
// 使用处理后的数据
result = actualData;
} catch (apiError: any) {
console.error(`❌ 第 ${page} 页API请求失败:`, {
error: apiError,
message: apiError?.message,
response: apiError?.response,
status: apiError?.response?.status,
});
console.error(`❌ 第 ${page} 页API请求失败:`, apiError);
throw apiError;
}
if (!result || !Array.isArray(result) || result.length === 0) {
console.log(`✅ 第 ${page} 页无数据,同步完成`);
console.log(`✅ 第 ${page} 页无数据,停止获取`);
hasMore = false;
break;
}
// 立即处理这一页的数据
// 分类并累积到内存
const friends = result.filter(
(msg: any) => msg.dataType === "friend" || !msg.chatroomId,
);
@@ -600,53 +467,16 @@ const MessageList: React.FC<MessageListProps> = () => {
chatroomAvatar: msg.chatroomAvatar || msg.avatar || "",
}));
// 立即同步这一页到数据库会触发UI更新
// 分页同步时跳过删除检查,避免误删其他页的会话
console.log(`💾 同步第 ${page} 页到数据库:`, {
friends: friends.length,
groups: groups.length,
total: result.length,
allServerSessions.friends.push(...friends);
allServerSessions.groups.push(...groups);
console.log(`✅ 第 ${page} 页获取完成:`, {
本页好友: friends.length,
本页群聊: groups.length,
累计好友: allServerSessions.friends.length,
累计群聊: allServerSessions.groups.length,
});
await MessageManager.syncSessions(
currentUserId,
{
friends,
groups,
},
{ skipDelete: true },
);
// 同步后立即从数据库读取并更新UI
const updatedSessions =
await MessageManager.getUserSessions(currentUserId);
console.log(
`✅ 第 ${page} 页同步完成,数据库现有会话数:`,
updatedSessions.length,
);
// 立即更新UI
if (updatedSessions.length > 0) {
setSessionState(updatedSessions);
// 同步到新架构的SessionStore
if (updatedSessions.length > 100) {
setAllSessions(updatedSessions);
} else {
buildIndexes(updatedSessions);
}
// 确保切换账号以显示数据
const accountId = currentCustomer?.id || 0;
if (accountId !== selectedAccountId) {
switchAccount(accountId);
} else {
// 即使账号ID相同也重新切换一次以确保数据正确显示
switchAccount(accountId);
}
}
totalProcessed += result.length;
successCount++;
// 判断是否还有下一页
if (result.length < limit) {
hasMore = false;
@@ -654,33 +484,57 @@ const MessageList: React.FC<MessageListProps> = () => {
page++;
}
} catch (error) {
// 忽略单页失败,继续处理下一页
console.error(`❌ 第${page}页同步失败:`, error);
failCount++;
// 如果连续失败太多,停止同步
if (failCount >= 3) {
console.warn("⚠️ 连续失败次数过多,停止同步");
break;
}
// 继续下一页
page++;
if (page > 100) {
// 防止无限循环
console.warn("⚠️ 页数超过100停止同步");
hasMore = false;
}
console.error(`❌ 第${page}页获取失败:`, error);
// 获取失败则停止,避免数据不完整导致误删
throw error;
}
}
const serverTotal =
allServerSessions.friends.length + allServerSessions.groups.length;
console.log(`📊 [阶段1] 完成,共获取 ${serverTotal} 条会话数据`);
// 获取本地数据进行对比
const localSessions = await MessageManager.getUserSessions(currentUserId);
console.log(
`✅ 会话同步完成: 成功${successCount}页, 失败${failCount}页, 共处理${totalProcessed}数据`,
`📊 [安全检查] 本地: ${localSessions.length} 条, 服务器: ${serverTotal} `,
);
// 同步完成后再次从数据库读取并更新UI确保显示最新数据
// ⚠️ 安全检查:防止误删
if (serverTotal === 0 && localSessions.length > 50) {
console.warn("⚠️ [安全检查失败] 服务器返回空数据,但本地有大量数据");
console.warn("⚠️ 可能是 API 异常,跳过本次同步以防止误删");
console.warn(
`⚠️ 本地: ${localSessions.length} 条, 服务器: ${serverTotal}`,
);
// 使用本地数据更新 UI
if (localSessions.length > 0) {
setSessionState(localSessions);
buildIndexes(localSessions);
switchAccount(currentCustomer?.id || 0);
}
return;
}
// 📝 第二阶段:执行完整同步(包含删除)
console.log("🔄 [阶段2] 执行完整同步,清理本地多余数据...");
const syncResult = await MessageManager.syncSessions(
currentUserId,
allServerSessions,
{ skipDelete: false }, // ✅ 不跳过删除,以 API 为准
);
console.log("✅ [阶段2] 会话列表同步完成:", {
新增: syncResult?.added || 0,
更新: syncResult?.updated || 0,
删除: syncResult?.deleted || 0, // ✅ 显示删除数量
服务器总数: serverTotal,
});
// 🎨 第三阶段:更新 UI
const finalSessions = await MessageManager.getUserSessions(currentUserId);
console.log(`📊 最终数据库会话数:`, finalSessions.length);
console.log(`📊 [阶段3] 最终数据库会话数:`, finalSessions.length);
if (finalSessions.length > 0) {
setSessionState(finalSessions);
@@ -693,13 +547,10 @@ const MessageList: React.FC<MessageListProps> = () => {
// 确保切换账号以显示数据
const accountId = currentCustomer?.id || 0;
switchAccount(accountId);
console.log(`✅ UI已更新显示会话数:`, finalSessions.length);
console.log(` [阶段3] UI已更新显示会话数:`, finalSessions.length);
} else {
console.warn("⚠️ 同步完成但数据库为空可能API返回空数据");
console.warn("⚠️ 同步完成但数据库为空");
}
// 同步完成后,异步补充未知联系人信息
enrichUnknownContacts();
} catch (error) {
console.error("❌ 同步服务器数据失败:", error);
// 即使同步失败,也尝试从数据库读取已有数据
@@ -1009,18 +860,20 @@ const MessageList: React.FC<MessageListProps> = () => {
filteredSessions,
]);
// 渲染完毕后自动点击第一个聊天记录
// 渲染完毕后自动点击第一个聊天记录(仅首次加载时)
useEffect(() => {
// 只在以下条件满足时自动点击:
// 1. 有过滤后的会话列表
// 2. 当前没有选中的联系人
// 3. 还没有自动点击过
// 4. 不在搜索状态(避免搜索时自动切换)
// 5. 已经完成过至少一次数据加载(避免在新消息到达时自动打开)
if (
displaySessions.length > 0 &&
!currentContract &&
!autoClickRef.current &&
!searchKeyword?.trim()
!searchKeyword?.trim() &&
hasLoadedOnce // 新增:只在首次加载完成后触发,不在新消息到达时触发
) {
// 延迟一点时间确保DOM已渲染
const timer = setTimeout(() => {
@@ -1034,226 +887,11 @@ const MessageList: React.FC<MessageListProps> = () => {
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [displaySessions, currentContract, searchKeyword]);
}, [displaySessions.length, currentContract, searchKeyword, hasLoadedOnce]); // 改用 displaySessions.length 而非整个数组,避免数组引用变化触发
// ==================== WebSocket消息处理 ====================
// 监听WebSocket消息更新静默更新模式
useEffect(() => {
const handleNewMessage = async (event: CustomEvent) => {
const { message: msgData, sessionId, type } = event.detail;
// 从联系人表查询完整信息确保头像、wechatAccountId等字段完整
const contact = await ContactManager.getContactByIdAndType(
currentUserId,
sessionId,
type,
);
// 检查会话是否存在
const existingSession = await MessageManager.getSessionByContactId(
currentUserId,
sessionId,
type,
);
if (existingSession) {
// 已存在的会话:更新消息内容、未读数,同时更新联系人信息(头像、昵称等)
const updateData: any = {
content: msgData.content,
lastUpdateTime: new Date().toISOString(),
config: {
...existingSession.config,
unreadCount: (existingSession.config?.unreadCount || 0) + 1,
},
};
// 如果查到了联系人信息,同步更新头像、昵称等字段
if (contact) {
updateData.avatar = contact.avatar;
updateData.wechatAccountId = contact.wechatAccountId;
updateData.nickname = contact.nickname;
updateData.conRemark = contact.conRemark;
updateData.content = msgData.content;
}
// 更新到数据库
await MessageManager.updateSession({
userId: currentUserId,
id: sessionId,
type,
...updateData,
});
} else {
// 新会话:从联系人表构建完整会话
if (contact) {
// 使用完整联系人信息构建会话
const newSession = MessageManager.buildSessionFromContact(
contact as any,
currentUserId,
);
// 更新会话内容和未读数
newSession.content = msgData.content;
newSession.lastUpdateTime = new Date().toISOString();
newSession.config.unreadCount = 1;
// 添加到数据库
await MessageManager.addSession(newSession);
} else {
// 联系人表中不存在,从接口获取详细信息
console.warn(
`联系人表中未找到 ID: ${sessionId}, 类型: ${type},从接口获取详细信息`,
);
try {
// 请求接口获取联系人/群组详情
let detailResult: any = null;
if (type === "friend") {
detailResult = await getWechatFriendDetail({ id: sessionId });
} else {
detailResult = await getWechatChatroomDetail({ id: sessionId });
}
if (detailResult?.detail) {
const contactDetail = detailResult.detail;
// 构建联系人数据并存入联系人数据库
const newContact = {
serverId: `${type}_${sessionId}`,
userId: currentUserId,
id: sessionId,
type,
wechatAccountId: contactDetail.wechatAccountId,
nickname: contactDetail.nickname || "",
conRemark: contactDetail.conRemark || "",
avatar:
type === "group"
? contactDetail.chatroomAvatar || ""
: contactDetail.avatar || "",
lastUpdateTime: new Date().toISOString(),
sortKey: "",
searchKey: (
contactDetail.conRemark ||
contactDetail.nickname ||
""
).toLowerCase(),
};
// 添加群组特有字段
if (type === "group") {
Object.assign(newContact, {
chatroomId: contactDetail.chatroomId,
chatroomOwner: contactDetail.chatroomOwner,
selfDisplayName: contactDetail.selfDisplyName,
notice: contactDetail.notice,
});
} else {
// 添加好友特有字段
Object.assign(newContact, {
wechatFriendId: contactDetail.id,
wechatId: contactDetail.wechatId,
alias: contactDetail.alias,
gender: contactDetail.gender,
region: contactDetail.region,
signature: contactDetail.signature,
phone: contactDetail.phone,
quanPin: contactDetail.quanPin,
groupId: contactDetail.groupId,
});
}
// 存入联系人数据库
await ContactManager.addContact(newContact as any);
console.log("✅ 新联系人已存入数据库:", newContact);
// 使用完整联系人信息构建会话
const newSession = MessageManager.buildSessionFromContact(
contactDetail as any,
currentUserId,
);
// 更新会话内容和未读数
newSession.content = msgData.content;
newSession.lastUpdateTime = new Date().toISOString();
newSession.config.unreadCount = 1;
// 添加到会话数据库
await MessageManager.addSession(newSession);
console.log("✅ 新会话已创建:", newSession);
} else {
// 接口也没有返回数据,使用最基础的兜底方案
console.error("接口未返回联系人详情,使用基础数据创建会话");
const newSession: ChatSession = {
serverId: `${type}_${sessionId}`,
userId: currentUserId,
id: sessionId,
type,
wechatAccountId: msgData.wechatAccountId || 0,
nickname: msgData.nickname || "未知联系人",
conRemark: msgData.conRemark || "",
avatar:
type === "group"
? msgData.chatroomAvatar || ""
: msgData.avatar || "",
content: msgData.content,
lastUpdateTime: new Date().toISOString(),
config: {
unreadCount: 1,
top: 0,
},
sortKey: "",
phone: msgData.phone || "",
region: msgData.region || "",
};
await MessageManager.addSession(newSession);
}
} catch (error) {
console.error("获取联系人详情失败:", error);
// 失败时使用消息数据创建简化会话
const newSession: ChatSession = {
serverId: `${type}_${sessionId}`,
userId: currentUserId,
id: sessionId,
type,
wechatAccountId: msgData.wechatAccountId || 0,
nickname: msgData.nickname || "未知联系人",
conRemark: msgData.conRemark || "",
avatar:
type === "group"
? msgData.chatroomAvatar || ""
: msgData.avatar || "",
content: msgData.content,
lastUpdateTime: new Date().toISOString(),
config: {
unreadCount: 1,
top: 0,
},
sortKey: "",
phone: msgData.phone || "",
region: msgData.region || "",
};
await MessageManager.addSession(newSession);
}
}
}
// MessageManager 的回调会自动把最新数据发给 Store
};
window.addEventListener(
"chatMessageReceived",
handleNewMessage as EventListener,
);
return () => {
window.removeEventListener(
"chatMessageReceived",
handleNewMessage as EventListener,
);
};
}, [currentUserId]);
// 注意:新消息的数据补齐和会话创建已在 msgManage.ts 中统一处理
// MessageManager.onSessionsUpdate 监听会自动更新 UI无需重复处理
// ==================== 会话操作 ====================

View File

@@ -24,7 +24,7 @@ import { useWeChatStore } from "@weChatStore/weChat";
import { useUserStore } from "@storeModule/user";
import { useContactStore } from "@weChatStore/contacts";
import { formatWechatTime } from "@/utils/common";
import { messageFilter } from "@/utils/filter";
import { formatMessagePreview } from "@/utils/messagePreview";
import { UserOutlined, TeamOutlined } from "@ant-design/icons";
import { Avatar, Badge } from "antd";
@@ -61,7 +61,7 @@ const SessionItem: React.FC<{
</div>
</div>
<div className={styles.messageContent}>
{messageFilter(session.content)}
{formatMessagePreview(session.content)}
</div>
</div>
</div>

View File

@@ -1,29 +1,105 @@
export interface Customer {
id: number;
tenantId: number;
wechatId: string;
nickname: string;
alias: string;
avatar: string;
gender: number;
region: string;
signature: string;
bindQQ: string;
bindEmail: string;
bindMobile: string;
createTime: string;
currentDeviceId: number;
isDeleted: boolean;
deleteTime: string;
groupId: number;
// 设备额外信息接口
export interface DeviceExtra {
l: boolean;
ip: string;
sn: string;
Address: string;
address: string;
battery: number;
product: string;
location: string;
sim0Iccid: string;
sim1Iccid: string;
romVersion: string;
sdkVersion: number;
market_name: string;
moduleVersion: string;
smsAppVersion: string;
"com.bhp.dialer": string;
phoneAppVersion: string;
"com.bhp.contacts": string;
"com.bhp.recorder": string;
imei: string;
memo: string;
wechatVersion: string;
labels: string[];
lastUpdateTime: string;
isOnline?: boolean;
momentsMax: number;
momentsNum: number;
[key: string]: any;
[key: string]: any; // 允许额外字段
}
// 客服账号接口
export interface Customer {
// 基础信息
id: number; // 客服账号ID
tenantId: number; // 租户ID
wechatId: string; // 微信ID
nickname: string; // 昵称
alias: string; // 别名/微信号
avatar: string; // 头像URL
gender: number; // 性别 (0=未知, 1=男, 2=女)
region: string; // 地区
signature: string; // 个性签名
wechatGroupName?: string; // 微信群名称
// 绑定信息
bindQQ: string; // 绑定的QQ
bindEmail: string; // 绑定的邮箱
bindMobile: string; // 绑定的手机号
// 设备相关
deviceAccountId: number; // 设备账号ID
currentDeviceId: number; // 当前设备ID
deviceExtra?: DeviceExtra; // 设备额外信息
// 状态信息
keFuAlive: number; // 客服在线状态 (0=离线, 1=在线)
deviceAlive: number; // 设备在线状态 (0=离线, 1=在线)
wechatAlive: number; // 微信在线状态 (0=离线, 1=在线)
wechatAliveTime: number; // 微信在线时间戳
status: number; // 账号状态
isDeleted: number; // 是否删除 (0=否, 1=是)
deleteTime: number | string; // 删除时间
// 统计信息
totalFriend: number; // 总好友数
maleFriend: number; // 男性好友数
femaleFriend: number; // 女性好友数
unknowFriend: number; // 未知性别好友数
yesterdayMsgCount: number; // 昨日消息数
sevenDayMsgCount: number; // 7天消息数
thirtyDayMsgCount: number; // 30天消息数
// 健康分数
healthScore: number; // 健康分数
baseScore: number; // 基础分数
dynamicScore: number; // 动态分数
scoreUpdateTime: string | null; // 分数更新时间
// 频繁使用相关
lastFrequentTime: string | null; // 最后频繁使用时间
frequentCount: number; // 频繁使用次数
lastNoFrequentTime: string | null; // 最后非频繁时间
consecutiveNoFrequentDays: number; // 连续非频繁天数
// 分组和标签
groupId: number; // 分组ID
labels: string[]; // 标签列表
memo: string; // 备注
// 朋友圈
momentsMax: number; // 朋友圈最大数量
momentsNum: number; // 朋友圈数量
// 时间信息
createTime: string; // 创建时间
updateTime?: string; // 更新时间
lastUpdateTime?: string; // 最后更新时间(兼容旧字段)
// 版本和修改信息
wechatVersion: string; // 微信版本
isModifiedAlias: number; // 是否修改了别名 (0=否, 1=是)
// 前端扩展字段
isOnline?: boolean; // 是否在线(前端计算)
[key: string]: any; // 允许额外字段
}
//Store State

View File

@@ -6,15 +6,20 @@ import { Messages } from "./msg.data";
import { db } from "@/utils/db";
import { Modal } from "antd";
import { useCustomerStore, updateCustomerList } from "../weChat/customer";
import { useUserStore } from "../user";
import { dataProcessing, asyncMessageStatus } from "@/api/ai";
import { useContactStoreNew } from "../weChat/contacts.new";
import { useMessageStore } from "../weChat/message";
import { Contact, ChatSession } from "@/utils/db";
import { Contact, ChatSession, contactUnifiedService } from "@/utils/db";
import { MessageManager } from "@/utils/dbAction/message";
import { ContactManager } from "@/utils/dbAction/contact";
import { groupContactsCache, sessionListCache } from "@/utils/cache";
import { GroupContactData } from "../weChat/contacts.data";
import { performanceMonitor } from "@/utils/performance";
import {
getWechatFriendDetail,
getWechatChatroomDetail,
} from "@/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/api";
// 消息处理器类型定义
type MessageHandler = (message: WebSocketMessage) => void;
@@ -145,10 +150,212 @@ const messageHandlers: Record<string, MessageHandler> = {
// 更新新架构的SessionStore增量更新索引和缓存
try {
const userId =
useCustomerStore.getState().currentCustomer?.userId || 0;
const userId = useUserStore.getState().user?.id || 0;
if (userId > 0) {
// 从数据库获取更新后的会话信息(带超时保护)
// 1. 先检查联系人是否存在于本地数据库
console.log("🔍 [新消息] 检查联系人是否存在:", {
sessionId,
type,
userId,
});
const existingContact =
await ContactManager.getContactByIdAndType(
userId,
sessionId,
type,
);
// 2. 如果联系人不存在,先请求 API 补齐数据,直接构建完整会话
if (!existingContact) {
console.log("⚠️ 延迟 1.5 秒后再执行逻辑,避免频繁处理", {
sessionId,
type,
});
// 延迟 2 秒后再执行逻辑,避免频繁处理
await new Promise(resolve => setTimeout(resolve, 2000));
try {
let detailResult: any = null;
if (type === "friend") {
detailResult = await getWechatFriendDetail({
id: sessionId,
});
detailResult = detailResult?.detail;
} else {
detailResult = await getWechatChatroomDetail({
id: sessionId,
});
}
const detail = detailResult;
if (detail) {
console.log(
"✅ [新消息] 成功获取详情,构建完整会话数据:",
{
id: detail.id,
nickname: detail.nickname,
avatar: detail.avatar || detail.chatroomAvatar,
},
);
// 构建完整的会话数据
const newSession: ChatSession = {
serverId: `${type}_${sessionId}`,
userId,
id: sessionId,
type,
wechatAccountId:
detail.wechatAccountId || wechatAccountId,
nickname: detail.nickname || "",
conRemark: detail.conRemark || "",
avatar:
type === "group"
? detail.chatroomAvatar || ""
: detail.avatar || "",
content: msgData.content || "",
lastUpdateTime: new Date().toISOString(),
aiType: 0,
phone: detail.phone || "",
region: detail.region || "",
config: {
unreadCount: 1, // 新消息未读
top: 0, // 不置顶
},
sortKey: "", // 会自动生成
};
// 添加类型特定字段
if (type === "group") {
Object.assign(newSession, {
chatroomId: detail.chatroomId || "",
chatroomOwner: detail.chatroomOwner || "",
selfDisplayName:
detail.selfDisplyName ||
detail.selfDisplayName ||
"",
notice: detail.notice || "",
});
} else {
Object.assign(newSession, {
wechatFriendId: detail.id,
wechatId: detail.wechatId || "",
alias: detail.alias || "",
gender: detail.gender,
signature: detail.signature || "",
quanPin: detail.quanPin || "",
groupId: detail.groupId,
});
}
// 先检查会话是否已存在
const existingSession =
await MessageManager.getSessionByContactId(
userId,
sessionId,
type,
);
if (existingSession) {
// 会话已存在,只更新消息内容和未读数
console.log(" [新消息] 会话已存在,更新内容");
await MessageManager.updateSession({
userId,
id: sessionId,
type,
content: msgData.content || "",
lastUpdateTime: new Date().toISOString(),
config: {
...existingSession.config,
unreadCount:
(existingSession.config.unreadCount || 0) + 1,
},
// 补齐可能缺失的联系人信息
avatar: existingSession.avatar || newSession.avatar,
nickname:
existingSession.nickname || newSession.nickname,
conRemark:
existingSession.conRemark || newSession.conRemark,
wechatId:
existingSession.wechatId || newSession.wechatId,
});
} else {
// 会话不存在,创建新会话
console.log(" [新消息] 会话不存在,创建新会话");
await MessageManager.createSession(userId, newSession);
}
// 然后创建联系人(异步,不影响会话显示)
const newContact: any = {
serverId: `${type}_${sessionId}_${wechatAccountId}`,
userId,
id: sessionId,
type,
wechatAccountId:
detail.wechatAccountId || wechatAccountId,
nickname: detail.nickname || "",
conRemark: detail.conRemark || "",
avatar:
type === "group"
? detail.chatroomAvatar || ""
: detail.avatar || "",
lastUpdateTime: new Date().toISOString(),
sortKey: "",
searchKey: (
detail.conRemark ||
detail.nickname ||
""
).toLowerCase(),
};
// 添加类型特定字段
if (type === "group") {
Object.assign(newContact, {
chatroomId: detail.chatroomId || "",
chatroomOwner: detail.chatroomOwner || "",
selfDisplayName:
detail.selfDisplyName ||
detail.selfDisplayName ||
"",
notice: detail.notice || "",
});
} else {
Object.assign(newContact, {
wechatFriendId: detail.id,
wechatId: detail.wechatId || "",
alias: detail.alias || "",
gender: detail.gender,
region: detail.region || "",
signature: detail.signature || "",
phone: detail.phone || "",
quanPin: detail.quanPin || "",
groupId: detail.groupId,
});
}
// 异步添加联系人(不阻塞会话显示)
ContactManager.addContact(newContact)
.then(() => {
console.log("✅ [新消息] 联系人已添加到数据库");
})
.catch(error => {
console.error("❌ [新消息] 添加联系人失败:", error);
});
} else {
console.warn("❌ [新消息] API 返回空数据,无法创建会话");
}
} catch (error) {
console.error("❌ [新消息] 请求 API 补齐数据失败:", error);
}
} else {
console.log("✅ [新消息] 联系人已存在:", {
id: existingContact.id,
nickname: existingContact.nickname,
avatar: existingContact.avatar ? "有" : "无",
});
}
// 3. 从数据库获取最新的会话信息
const updatedSession = await Promise.race([
MessageManager.getSessionByContactId(userId, sessionId, type),
new Promise<null>(resolve =>
@@ -191,7 +398,6 @@ const messageHandlers: Record<string, MessageHandler> = {
}
} catch (error) {
console.error("更新SessionStore失败:", error);
// 即使更新失败,也发送事件通知(降级处理)
}
// 发送自定义事件通知MessageList组件
@@ -233,8 +439,7 @@ const messageHandlers: Record<string, MessageHandler> = {
async () => {
try {
const contactStore = useContactStoreNew.getState();
const userId =
useCustomerStore.getState().currentCustomer?.userId || 0;
const userId = useUserStore.getState().user?.id || 0;
if (!userId) {
console.warn("CmdFriendInfoChanged: 用户未登录");

View File

@@ -120,21 +120,28 @@ export class ContactManager {
}
/**
* 同步联系人数据
* 同步联系人数据(以 API 为准,自动删除本地多余数据)
*/
static async syncContacts(
userId: number,
serverContacts: any[],
): Promise<void> {
): Promise<{ added: number; updated: number; deleted: number }> {
try {
// 获取本地联系人
// 1. 获取本地联系人
const localContacts = await this.getUserContacts(userId);
const localContactMap = new Map(localContacts.map(c => [c.serverId, c]));
// 处理服务器联系人
// 2. 创建服务器联系人映射
const serverContactMap = new Map(
serverContacts.map(c => [c.serverId, c])
);
// 3. 计算差异
const contactsToAdd: Contact[] = [];
const contactsToUpdate: Contact[] = [];
const contactsToDelete: string[] = [];
// 检查新增和更新
for (const serverContact of serverContacts) {
const localContact = localContactMap.get(serverContact.serverId);
@@ -159,7 +166,34 @@ export class ContactManager {
}
}
// 执行数据库操作
// ✅ 检查需要删除的联系人(本地有但服务器没有)
for (const localContact of localContacts) {
if (!serverContactMap.has(localContact.serverId)) {
contactsToDelete.push(localContact.serverId);
}
}
// ⚠️ 安全检查:防止误删
const serverTotal = serverContacts.length;
const localTotal = localContacts.length;
if (serverTotal === 0 && localTotal > 50) {
console.warn("⚠️ [联系人同步] 安全检查失败: 服务器返回空数据,但本地有大量数据");
console.warn(`⚠️ 本地: ${localTotal} 条, 服务器: ${serverTotal}`);
console.warn("⚠️ 可能是 API 异常,跳过本次同步以防止误删");
return { added: 0, updated: 0, deleted: 0 };
}
// 警告大量删除
if (contactsToDelete.length > 0) {
const deleteRatio = contactsToDelete.length / localTotal;
if (deleteRatio > 0.3) {
console.warn(`⚠️ [联系人同步] 本次将删除 ${(deleteRatio * 100).toFixed(1)}% 的联系人数据`);
console.warn(`⚠️ 删除: ${contactsToDelete.length} 条, 本地总数: ${localTotal}`);
}
}
// 4. 执行数据库操作
if (contactsToAdd.length > 0) {
await this.addContacts(contactsToAdd);
}
@@ -170,11 +204,38 @@ export class ContactManager {
}
}
// ✅ 执行删除操作
if (contactsToDelete.length > 0) {
console.log(
`🗑️ [联系人同步] 检测到 ${contactsToDelete.length} 个本地联系人在服务器不存在,准备删除`,
);
let deletedCount = 0;
for (const serverId of contactsToDelete) {
try {
await contactUnifiedService.delete(serverId);
deletedCount++;
} catch (error) {
console.error(`❌ [联系人同步] 删除失败: ${serverId}`, error);
}
}
console.log(`✅ [联系人同步] 实际删除: ${deletedCount}`);
}
const result = {
added: contactsToAdd.length,
updated: contactsToUpdate.length,
deleted: contactsToDelete.length,
};
console.log(
`同步联系人完成: 新增${contactsToAdd.length}个, 更新${contactsToUpdate.length}`,
`✅ [联系人同步] 完成: 新增${result.added}个, 更新${result.updated}个, 删除${result.deleted}`,
);
return result;
} catch (error) {
console.error("同步联系人失败:", error);
console.error("❌ [联系人同步] 失败:", error);
throw error;
}
}

View File

@@ -23,11 +23,7 @@ class PerformanceMonitor {
/**
* 测量函数执行时间
*/
measure<T>(
name: string,
fn: () => T,
metadata?: Record<string, any>,
): T {
measure<T>(name: string, fn: () => T, metadata?: Record<string, any>): T {
const start = performance.now();
try {
const result = fn();
@@ -83,15 +79,6 @@ class PerformanceMonitor {
if (this.results.length > this.maxResults) {
this.results.shift();
}
// 开发环境下输出到控制台
if (import.meta.env.DEV) {
const color = duration > 100 ? "🔴" : duration > 50 ? "🟡" : "🟢";
console.log(
`${color} [Performance] ${name}: ${duration.toFixed(2)}ms`,
metadata || "",
);
}
}
/**

View File

@@ -0,0 +1,123 @@
import React from "react";
interface ArticleMessageProps {
content: string;
}
/**
* 文章/链接消息组件
* msgType = 49 且 content.type === "link"
*/
export const ArticleMessage: React.FC<ArticleMessageProps> = ({ content }) => {
try {
const articleData = typeof content === "string" ? JSON.parse(content) : content;
const { title, desc, thumbPath, url } = articleData;
return (
<div
style={{
maxWidth: "300px",
border: "1px solid #e8e8e8",
borderRadius: "8px",
overflow: "hidden",
backgroundColor: "#fff",
cursor: "pointer",
}}
onClick={() => {
if (url) {
window.open(url, "_blank");
}
}}
>
{/* 封面图 */}
{thumbPath && (
<img
src={thumbPath}
alt="文章封面"
style={{
width: "100%",
height: "auto",
maxHeight: "150px",
objectFit: "cover",
display: "block",
}}
onError={(event) => {
const target = event.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
{/* 文章信息 */}
<div style={{ padding: "12px" }}>
{/* 标题 */}
{title && (
<div
style={{
fontWeight: 600,
fontSize: "14px",
marginBottom: "8px",
color: "#333",
lineHeight: "1.4",
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{title}
</div>
)}
{/* 描述 */}
{desc && (
<div
style={{
fontSize: "12px",
color: "#999",
lineHeight: "1.5",
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{desc}
</div>
)}
{/* 链接标识 */}
<div
style={{
marginTop: "8px",
fontSize: "11px",
color: "#1890ff",
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<span>🔗</span>
<span></span>
</div>
</div>
</div>
);
} catch (error) {
console.error("❌ 文章消息解析失败:", error);
return (
<div
style={{
padding: "8px 12px",
border: "1px solid #e8e8e8",
borderRadius: "4px",
color: "#999",
}}
>
[]
</div>
);
}
};

View File

@@ -0,0 +1,35 @@
import React from "react";
import styles from "../com.module.scss";
interface EmojiMessageProps {
content: string;
}
/**
* 表情包消息组件
* msgType = 47
*/
export const EmojiMessage: React.FC<EmojiMessageProps> = ({ content }) => {
const handleImageError = (event: React.SyntheticEvent<HTMLImageElement>) => {
const target = event.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[表情包加载失败]</div>`;
}
};
return (
<div className={styles.emojiMessage}>
<img
src={content}
alt="表情包"
style={{
maxWidth: "120px",
maxHeight: "120px",
}}
onClick={() => window.open(content, "_blank")}
onError={handleImageError}
/>
</div>
);
};

View File

@@ -0,0 +1,38 @@
import React from "react";
import styles from "../com.module.scss";
interface ImageMessageProps {
content: string;
}
/**
* 图片消息组件
* msgType = 3
*/
export const ImageMessage: React.FC<ImageMessageProps> = ({ content }) => {
const handleImageError = (event: React.SyntheticEvent<HTMLImageElement>) => {
const target = event.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[图片加载失败]</div>`;
}
};
return (
<div className={styles.messageBubble}>
<div className={styles.imageMessage}>
<img
src={content}
alt="图片消息"
style={{
maxWidth: "200px",
maxHeight: "200px",
borderRadius: "8px",
}}
onClick={() => window.open(content, "_blank")}
onError={handleImageError}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import React from "react";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import SmallProgramMessage from "../components/SmallProgramMessage";
import { ArticleMessage } from "./ArticleMessage";
import { MessageTypeNodeProps } from "./messageTypeConfig";
/**
* msgType=49 复合消息类型渲染器
* 根据 content 内容判断具体类型:文章、小程序、文件等
*/
export const renderMsgType49 = (props: MessageTypeNodeProps): React.ReactNode => {
const { content, msg, contract, parsedJson } = props;
// 1. 检测文章消息type: "link"
if (parsedJson?.type === "link") {
return <ArticleMessage content={content} />;
}
// 2. 检测小程序消息:包含 XML 标签或被截断的内容
// 注意:[该消息内容过长已截断] 说明 JSON 不完整,不要尝试解析,直接用内容特征判断
if (
content.includes("<weappinfo>") ||
content.includes("<?xml") ||
content.includes("contentXml") ||
content.startsWith("[该消息内容过长已截断]")
) {
return <SmallProgramMessage content={content} msg={msg} contract={contract} />;
}
// 3. 兜底:使用 SmallProgramMessage 处理(包含文件等其他类型)
return <SmallProgramMessage content={content} msg={msg} contract={contract} />;
};

View File

@@ -0,0 +1,19 @@
import React from "react";
import styles from "../com.module.scss";
interface TextMessageProps {
content: string;
parseEmojiText: (text: string) => React.ReactNode[];
}
/**
* 文本消息组件
* msgType = 1
*/
export const TextMessage: React.FC<TextMessageProps> = ({ content, parseEmojiText }) => {
return (
<div className={styles.messageBubble}>
<div className={styles.messageText}>{parseEmojiText(content)}</div>
</div>
);
};

View File

@@ -0,0 +1,95 @@
import React from "react";
import styles from "../com.module.scss";
interface UnknownMessageProps {
content: string;
msgType?: number;
parsedJson?: any;
parseEmojiText: (text: string) => React.ReactNode[];
}
/**
* 未知类型消息组件
* 用于兜底处理和通过内容推导类型
*/
export const UnknownMessage: React.FC<UnknownMessageProps> = ({
content,
msgType,
parsedJson,
parseEmojiText,
}) => {
// 尝试识别图片链接
const isImageUrl = /^https?:\/\/.*\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i.test(content);
if (isImageUrl) {
return (
<div className={styles.imageMessage}>
<img
src={content}
alt="图片"
style={{ maxWidth: "200px", maxHeight: "200px", borderRadius: "8px" }}
onClick={() => window.open(content, "_blank")}
onError={(e) => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[图片加载失败]</div>`;
}
}}
/>
</div>
);
}
// 尝试识别文件链接
const isFileUrl = /^https?:\/\/.*\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)(\?.*)?$/i.test(content);
if (isFileUrl) {
const fileName = content.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={() => window.open(content, "_blank")}>
</div>
</div>
</div>
</div>
);
}
// 表情包(旧格式)
const isEmoji = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(content) ||
content.includes("emoji") ||
content.includes("sticker");
if (isEmoji) {
return (
<div className={styles.emojiMessage}>
<img
src={content}
alt="表情包"
style={{ maxWidth: "120px", maxHeight: "120px" }}
onClick={() => window.open(content, "_blank")}
onError={(e) => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[表情包加载失败]</div>`;
}
}}
/>
</div>
);
}
// 默认文本显示
return (
<div className={styles.messageText}>
{msgType && <span style={{ color: "#999", fontSize: "12px" }}>[{msgType}] </span>}
{parseEmojiText(content)}
</div>
);
};

View File

@@ -0,0 +1,325 @@
import React from "react";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import AudioMessage from "../components/AudioMessage/AudioMessage";
import SmallProgramMessage from "../components/SmallProgramMessage";
import VideoMessage from "../components/VideoMessage";
import LocationMessage from "../components/LocationMessage";
import SystemRecommendRemarkMessage from "../components/SystemRecommendRemarkMessage/index";
import RedPacketMessage from "../components/RedPacketMessage";
import TransferMessage from "../components/TransferMessage";
import { TextMessage } from "./TextMessage";
import { ImageMessage } from "./ImageMessage";
import { EmojiMessage } from "./EmojiMessage";
import { UnknownMessage } from "./UnknownMessage";
import { ArticleMessage } from "./ArticleMessage";
import { renderMsgType49 } from "./MsgType49Renderer";
import { parseSystemMessage } from "@/utils/filter";
import styles from "../com.module.scss";
/**
* 消息类型配置接口
*/
export interface MessageTypeConfig {
/** 类型名称 */
type: string;
/** 渲染函数 */
nodeFunc: (props: MessageTypeNodeProps) => React.ReactNode;
/** 内容检测器(用于推导未知 msgType */
detector?: (content: string, parsedJson: any) => boolean;
/** 优先级detector 冲突时使用,数值越大优先级越高) */
priority?: number;
/** 是否为系统消息(显示在中间区域) */
isSystemMessage?: boolean;
}
/**
* 消息类型渲染属性
*/
export interface MessageTypeNodeProps {
content: string;
msg: ChatRecord;
contract: ContractData | weChatGroup;
parsedJson?: any; // 已解析的 JSON如果内容是 JSON
parseEmojiText: (text: string) => React.ReactNode[];
isEmojiUrl: (content: string) => boolean;
}
/**
* 消息类型配置对象
*
* 使用方式:
* 1. 根据 msgType 直接查找MESSAGE_TYPE_MAP[msgType]
* 2. 通过 detector 推导:遍历所有配置,找到第一个匹配的
* 3. 添加新类型:直接在这里添加新的键值对
*
* 示例:
* ```typescript
* MESSAGE_TYPE_MAP[12345] = {
* type: "新类型",
* nodeFunc: ({ content }) => <div>{content}</div>,
* detector: (content, json) => json && json.customField === "value",
* priority: 100
* }
* ```
*/
export const MESSAGE_TYPE_MAP: Record<number, MessageTypeConfig> = {
// ==================== 系统消息(显示在中间区域) ====================
/**
* msgType = 10000: 系统消息(如:时间戳、入群通知等)
*/
10000: {
type: "系统消息",
isSystemMessage: true,
nodeFunc: ({ content }) => (
<div className={styles.messageTime}>
{parseSystemMessage(content)}
</div>
),
},
/**
* msgType = -10001: 系统消息
*/
[-10001]: {
type: "系统消息",
isSystemMessage: true,
nodeFunc: ({ content }) => (
<div className={styles.messageTime}>
{parseSystemMessage(content)}
</div>
),
},
/**
* msgType = 570425393: 系统消息JSON格式
*/
570425393: {
type: "系统消息(JSON)",
isSystemMessage: true,
nodeFunc: ({ content, parsedJson }) => {
let displayContent = content;
if (parsedJson && typeof parsedJson === "object" && parsedJson.content) {
displayContent = parsedJson.content;
}
return <div className={styles.messageTime}>{displayContent}</div>;
},
},
/**
* msgType = 90000: 系统消息JSON格式
*/
90000: {
type: "系统消息(JSON)",
isSystemMessage: true,
nodeFunc: ({ content, parsedJson }) => {
let displayContent = content;
if (parsedJson && typeof parsedJson === "object" && parsedJson.content) {
displayContent = parsedJson.content;
}
return <div className={styles.messageTime}>{displayContent}</div>;
},
},
// ==================== 用户消息 ====================
/**
* msgType = 1: 文本消息
*/
1: {
type: "文本",
nodeFunc: ({ content, parseEmojiText }) => (
<TextMessage content={content} parseEmojiText={parseEmojiText} />
),
},
/**
* msgType = 3: 图片消息
*/
3: {
type: "图片",
nodeFunc: ({ content }) => <ImageMessage content={content} />,
detector: (content) => /^https?:\/\/.*\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i.test(content),
priority: 80,
},
/**
* msgType = 34: 语音消息
*/
34: {
type: "语音",
nodeFunc: ({ content, msg }) => (
<AudioMessage audioUrl={content} msgId={String(msg.id)} />
),
},
/**
* msgType = 43: 视频消息
*/
43: {
type: "视频",
nodeFunc: ({ content, msg, contract }) => (
<VideoMessage content={content} msg={msg} contract={contract} />
),
detector: (_, parsedJson) =>
parsedJson &&
parsedJson.previewImage &&
(parsedJson.tencentUrl || parsedJson.videoUrl),
priority: 85,
},
/**
* msgType = 47: 表情包
*/
47: {
type: "表情包",
nodeFunc: ({ content, isEmojiUrl }) => {
if (isEmojiUrl(content)) {
return <EmojiMessage content={content} />;
}
return <div>[]</div>;
},
detector: (content) =>
content.includes("emoji") ||
content.includes("sticker") ||
content.includes("expression") ||
/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(content),
priority: 70,
},
/**
* msgType = 48: 定位消息
*/
48: {
type: "定位",
nodeFunc: ({ content }) => <LocationMessage content={content} />,
},
/**
* msgType = 49: 小程序/文章/文件(复合类型)
* 根据 content 内容动态判断具体类型
*/
49: {
type: "小程序/文章/文件",
nodeFunc: (props) => renderMsgType49(props),
},
/**
* msgType = 10002: 系统推荐备注消息
*/
10002: {
type: "系统推荐备注",
nodeFunc: ({ content }) => <SystemRecommendRemarkMessage content={content} />,
},
};
/**
* 特殊类型检测器列表
* 用于通过内容推导消息类型(当 msgType 未知或不准确时)
* 按优先级排序,优先级高的先检测
*
* 注意msgType=49 已在 MESSAGE_TYPE_MAP 中处理,不需要在此添加检测器
*/
export const SPECIAL_TYPE_DETECTORS: Array<{
name: string;
detector: (content: string, parsedJson: any) => boolean;
nodeFunc: (props: MessageTypeNodeProps) => React.ReactNode;
priority: number;
}> = [
/**
* 红包消息(优先级最高)
* msgType 通常为 49但通过内容特征识别
*/
{
name: "红包",
priority: 95,
detector: (_, parsedJson) =>
parsedJson &&
parsedJson.nativeurl &&
typeof parsedJson.nativeurl === "string" &&
parsedJson.nativeurl.includes(
"wxpay://c2cbizmessagehandler/hongbao/receivehongbao",
),
nodeFunc: ({ content, msg, contract }) => (
<RedPacketMessage content={content} msg={msg} contract={contract} />
),
},
/**
* 转账消息
* msgType 通常为 49但通过内容特征识别
*/
{
name: "转账",
priority: 95,
detector: (_, parsedJson) =>
parsedJson &&
(parsedJson.title === "微信转账" ||
(parsedJson.transferid && parsedJson.feedesc)),
nodeFunc: ({ content, msg, contract }) => (
<TransferMessage content={content} msg={msg} contract={contract} />
),
},
/**
* 视频消息msgType=43的补充检测
*/
{
name: "视频",
priority: 85,
detector: (_, parsedJson) =>
parsedJson &&
parsedJson.previewImage &&
(parsedJson.tencentUrl || parsedJson.videoUrl),
nodeFunc: ({ content, msg, contract }) => (
<VideoMessage content={content} msg={msg} contract={contract} />
),
},
/**
* 图片消息msgType=3的补充检测
*/
{
name: "图片",
priority: 80,
detector: (content) => /^https?:\/\/.*\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i.test(content),
nodeFunc: ({ content }) => <ImageMessage content={content} />,
},
/**
* 表情包msgType=47的补充检测
*/
{
name: "表情包",
priority: 70,
detector: (content) =>
content.includes("emoji") ||
content.includes("sticker") ||
content.includes("expression") ||
/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(content),
nodeFunc: ({ content, isEmojiUrl }) => {
if (isEmojiUrl(content)) {
return <EmojiMessage content={content} />;
}
return <div>[]</div>;
},
},
];
// 按优先级排序
SPECIAL_TYPE_DETECTORS.sort((a, b) => b.priority - a.priority);
/**
* 未知类型兜底处理
*/
export const UNKNOWN_MESSAGE_CONFIG: MessageTypeConfig = {
type: "未知",
nodeFunc: ({ content, msg, parsedJson, parseEmojiText }) => (
<UnknownMessage
content={content}
msgType={msg.msgType}
parsedJson={parsedJson}
parseEmojiText={parseEmojiText}
/>
),
};

View File

@@ -1,206 +0,0 @@
# 会话列表不显示问题修复说明
## 🔍 问题分析
会话列表不显示的可能原因:
1. **账号切换过滤问题**`switchAccount` 根据 `currentCustomer?.id` 过滤会话如果账号ID不匹配可能导致过滤后为空
2. **数据未加载**数据库查询失败或API调用失败
3. **索引未构建**:新架构的索引系统未正确构建,导致 `switchAccount` 返回空数组
4. **用户ID无效**`currentUserId` 为 0 或 undefined导致跳过数据加载
## ✅ 已实施的修复
### 1. 添加调试日志
- 当会话列表为空时,自动输出调试信息到控制台
- 包含:`storeSessions` 长度、`filteredSessions` 长度、`currentUserId``currentCustomerId``selectedAccountId` 等关键状态
### 2. 改进账号切换逻辑
- 当切换账号后结果为空时,自动尝试显示全部会话(`accountId = 0`
- 确保数据加载后立即触发账号切换
### 3. 优化空状态显示
- 区分"同步中"和"暂无数据"两种状态
- 显示更友好的提示信息
- 当数据为空时,提供"刷新会话列表"按钮
### 4. 数据加载优化
- 从数据库加载数据后,立即同步到新架构的 SessionStore
- 确保构建索引和切换账号逻辑正确执行
## 🛠️ 排查步骤
如果会话列表仍然不显示,请按以下步骤排查:
### 步骤 1检查控制台日志
打开浏览器开发者工具F12查看 Console 标签页:
1. 查找以 `⚠️` 开头的警告信息
2. 查看是否有 `✅ 从数据库加载会话列表` 的日志
3. 检查是否有错误信息(红色)
### 步骤 2检查用户登录状态
在控制台执行:
```javascript
// 检查用户ID
console.log('用户ID:', window.__USER_STORE__?.getState?.()?.user?.id);
// 或者直接查看 localStorage
console.log('用户信息:', localStorage.getItem('user-store'));
```
### 步骤 3检查数据库数据
在控制台执行:
```javascript
// 需要先导入相关模块
import { databaseManager } from '@/utils/db';
// 获取当前用户ID
const userId = JSON.parse(localStorage.getItem('user-store') || '{}')?.state?.user?.id;
if (userId) {
const db = await databaseManager.ensureDatabase(userId);
const sessions = await db.chatSessions.where('userId').equals(userId).toArray();
console.log('数据库中的会话数量:', sessions.length);
console.log('会话数据:', sessions);
}
```
### 步骤 4检查账号选择
在控制台执行:
```javascript
// 检查当前选中的账号
console.log('当前账号:', window.__CUSTOMER_STORE__?.getState?.()?.currentCustomer);
```
### 步骤 5手动触发同步
1. 点击会话列表上方的"同步"按钮
2. 或点击空状态下的"刷新会话列表"按钮
3. 观察控制台是否有同步相关的日志
### 步骤 6检查网络请求
在开发者工具的 Network 标签页中:
1. 查找 `/wechat/message/list` 或类似的API请求
2. 检查请求是否成功(状态码 200
3. 查看响应数据是否包含会话列表
## 🔧 手动修复方法
### 方法 1清除缓存并重新加载
```javascript
// 清除所有持久化数据
localStorage.clear();
sessionStorage.clear();
// 刷新页面
window.location.reload();
```
### 方法 2重置会话列表状态
在控制台执行:
```javascript
// 需要先导入
import { useMessageStore } from '@/store/module/weChat/message';
// 重置状态
useMessageStore.getState().resetLoadState();
useMessageStore.getState().clearSessions();
// 刷新页面
window.location.reload();
```
### 方法 3强制显示全部会话
如果是因为账号过滤导致的问题,可以临时修改代码:
`MessageList/index.tsx` 中,找到账号切换的 useEffect临时修改为
```typescript
// 临时修复:强制显示全部会话
useEffect(() => {
const accountId = 0; // 强制使用全部账号
if (accountId !== selectedAccountId) {
switchAccount(accountId);
}
}, [selectedAccountId, switchAccount]);
```
## 📝 代码修改位置
主要修改文件:`src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx`
### 修改点 1添加调试日志第 111-123 行)
```typescript
// 调试日志:检查会话列表状态
useEffect(() => {
if (displaySessions.length === 0) {
console.warn("⚠️ 会话列表为空,调试信息:", {
storeSessionsLength: storeSessions.length,
filteredSessionsLength: filteredSessions.length,
currentUserId,
currentCustomerId: currentCustomer?.id,
selectedAccountId,
hasLoadedOnce,
syncing,
});
}
}, [displaySessions.length, ...]);
```
### 修改点 2改进账号切换逻辑第 692-700 行)
```typescript
// 同步账号切换到新架构的SessionStore
useEffect(() => {
const accountId = currentCustomer?.id || 0;
if (accountId !== selectedAccountId) {
const result = switchAccount(accountId);
// 如果切换后结果为空,尝试使用全部账号
if (result.length === 0 && accountId !== 0) {
console.warn("⚠️ 切换账号后会话列表为空,尝试显示全部会话");
switchAccount(0);
}
}
}, [currentCustomer, selectedAccountId, switchAccount]);
```
### 修改点 3优化数据加载第 631-640 行)
```typescript
// 有缓存数据立即显示
if (cachedSessions.length > 0) {
console.log("✅ 从数据库加载会话列表:", cachedSessions.length, "条");
setSessionState(cachedSessions);
// ... 构建索引
// 确保切换账号以显示数据
const accountId = currentCustomer?.id || 0;
if (accountId !== selectedAccountId) {
switchAccount(accountId);
}
}
```
### 修改点 4改进空状态显示第 1207-1230 行)
- 添加了更详细的空状态提示
- 添加了"刷新会话列表"按钮
## 🎯 预期效果
修复后,会话列表应该能够:
1. ✅ 正常显示已加载的会话
2. ✅ 在数据为空时显示友好的提示
3. ✅ 提供手动刷新功能
4. ✅ 在控制台输出有用的调试信息
## 📞 如果问题仍然存在
如果按照以上步骤排查后问题仍然存在,请提供以下信息:
1. 浏览器控制台的完整日志(特别是警告和错误)
2. Network 标签页中的 API 请求和响应
3. 当前用户ID和账号ID
4. 数据库中的会话数量(通过步骤 3 获取)
这些信息将有助于进一步诊断问题。
---
*修复时间2024年*
*修复文件:`src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx`*

View File

@@ -1,220 +0,0 @@
# 会话列表不显示问题 - 根本原因和修复
## 🎯 问题根本原因
通过分析日志 `currentCustomerId: undefined`,发现了根本问题:
### 问题 1`currentCustomer` 持久化配置错误 ⭐⭐⭐
**文件**: `src/store/module/weChat/customer.ts`
**错误代码**:
```typescript
{
name: "customer-storage",
partialize: state => ({
customerList: [], // ❌ 总是返回空数组
currentCustomer: null, // ❌ 总是返回 null
}),
}
```
**问题分析**:
- `partialize` 函数用于指定哪些状态需要持久化
- 但代码中直接返回了固定值(空数组和 null而不是实际的状态值
- 导致每次刷新页面后,`currentCustomer``customerList` 都会被重置为空
**修复代码**:
```typescript
{
name: "customer-storage",
partialize: state => ({
customerList: state.customerList, // ✅ 持久化实际的客服列表
currentCustomer: state.currentCustomer, // ✅ 持久化当前选中的客服
}),
}
```
### 问题 2未自动选择默认账号
**文件**: `src/pages/pc/ckbox/weChat/components/CustomerList/index.tsx`
**问题分析**:
- 获取客服列表后,没有自动选择第一个账号
- 导致 `currentCustomer` 始终为 `null`
- 会话列表根据 `currentCustomer?.id || 0` 过滤,但如果账号数据未加载,可能导致显示问题
**修复代码**:
```typescript
getCustomerList()
.then(res => {
updateCustomerList(res);
// 如果当前没有选中的客服,自动选择第一个
const current = useCustomerStore.getState().currentCustomer;
if (!current && res.length > 0) {
console.log("🔄 自动选择第一个账号:", res[0]);
updateCurrentCustomer(res[0]);
}
setLoading(false);
})
```
### 问题 3账号切换逻辑需要优化
**文件**: `src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx`
**问题分析**:
-`currentCustomer``undefined` 时(账号列表未加载),不应该立即切换账号
- 需要等待账号列表加载完成后再切换
**修复代码**:
```typescript
useEffect(() => {
// 当 currentCustomer 为 undefined 时,暂时不切换账号,等待账号列表加载
if (currentCustomer === undefined) {
console.log("⏳ currentCustomer 为 undefined等待账号列表加载...");
return;
}
// 当 currentCustomer 为 null 或有值时,进行账号切换
const accountId = currentCustomer?.id || 0;
console.log("🔄 切换账号:", { currentCustomerId: currentCustomer?.id, accountId, selectedAccountId });
if (accountId !== selectedAccountId) {
const result = switchAccount(accountId);
console.log(`✅ 切换账号完成,会话数:`, result.length);
// 如果切换后结果为空尝试使用全部账号accountId = 0
if (result.length === 0 && accountId !== 0) {
console.warn("⚠️ 切换账号后会话列表为空,尝试显示全部会话");
const allResult = switchAccount(0);
console.log(`✅ 切换到全部账号,会话数:`, allResult.length);
}
}
}, [currentCustomer, selectedAccountId, switchAccount]);
```
## ✅ 修复总结
### 已修复的文件
1. **`src/store/module/weChat/customer.ts`**
- 修复持久化配置,正确保存 `currentCustomer``customerList`
2. **`src/pages/pc/ckbox/weChat/components/CustomerList/index.tsx`**
- 添加自动选择第一个账号的逻辑
3. **`src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx`**
- 优化账号切换逻辑
- 添加详细的同步日志
- 改进错误处理
- 优化空状态显示
## 🔄 修复后的流程
### 1. 首次加载流程
```
用户登录
加载客服列表 (CustomerList)
自动选择第一个账号 (新增)
保存到 currentCustomer (持久化修复)
MessageList 检测到 currentCustomer 变化
切换账号 (switchAccount)
从数据库加载会话
同步服务器数据
显示会话列表
```
### 2. 刷新页面流程
```
页面刷新
从 localStorage 恢复 currentCustomer (持久化修复)
MessageList 使用已保存的 currentCustomer
切换账号 (switchAccount)
从数据库加载会话
显示会话列表 (无需等待 API)
后台同步服务器数据 (更新最新数据)
```
## 📊 预期效果
修复后,应该看到以下日志:
```
✅ 从数据库加载会话列表: X 条
🔄 自动选择第一个账号: {id: 123, ...}
🔄 切换账号: {currentCustomerId: 123, accountId: 123, selectedAccountId: 0}
✅ 切换账号完成,会话数: X
```
## 🧪 测试步骤
### 步骤 1清除缓存测试
1. 打开浏览器控制台
2. 执行:`localStorage.clear(); sessionStorage.clear();`
3. 刷新页面
4. 观察是否自动选择账号并显示会话
### 步骤 2刷新页面测试
1. 正常使用应用,选择一个账号
2. 刷新页面F5
3. 观察是否保持之前选择的账号
4. 观察会话列表是否正常显示
### 步骤 3切换账号测试
1. 点击不同的账号
2. 观察会话列表是否正确切换
3. 刷新页面,观察是否保持当前账号
## 🔍 如果问题仍然存在
如果修复后问题仍然存在,请检查:
### 1. 检查持久化是否生效
在控制台执行:
```javascript
// 检查 localStorage 中的数据
console.log('customer-storage:', localStorage.getItem('customer-storage'));
// 应该看到类似这样的数据:
// {"state":{"customerList":[...],"currentCustomer":{...}},"version":0}
```
### 2. 检查账号列表是否加载
在控制台执行:
```javascript
import { useCustomerStore } from '@/store/module/weChat/customer';
const state = useCustomerStore.getState();
console.log('customerList:', state.customerList);
console.log('currentCustomer:', state.currentCustomer);
```
### 3. 检查 API 是否返回数据
打开 Network 标签页,查看:
- `/v1/kefu/message/list` - 会话列表 API
- 检查响应数据是否为空
## 📝 相关文件
- `src/store/module/weChat/customer.ts` - 客服状态管理(核心修复)
- `src/pages/pc/ckbox/weChat/components/CustomerList/index.tsx` - 客服列表组件
- `src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx` - 会话列表组件
---
*修复时间2024年*
*核心问题:持久化配置错误导致 currentCustomer 始终为 null*

View File

@@ -1,245 +0,0 @@
# 会话列表问题诊断指南
## 🔍 当前问题状态
根据控制台日志,问题表现为:
-`currentUserId: 121` - 用户ID有效
-`currentCustomerId: undefined` - 未选择账号
-`storeSessionsLength: 0` - 会话列表为空
-`数据库中没有缓存会话数据` - 数据库为空
## 📊 已添加的调试日志
修复后,控制台会显示以下日志:
### 1. 初始化阶段
```
🔄 需要完整同步,开始同步服务器数据...
🔄 开始同步会话列表用户ID: 121
```
### 2. API 请求阶段
```
📡 请求第 1 页会话列表... {page: 1, limit: 500}
📥 第 1 页API响应: {type: "object", isArray: true, length: X, ...}
```
### 3. 数据同步阶段
```
💾 同步第 1 页到数据库: {friends: X, groups: Y, total: Z}
✅ 第 1 页同步完成,数据库现有会话数: X
```
### 4. UI 更新阶段
```
✅ UI已更新显示会话数: X
✅ 同步完成,已设置 hasLoadedOnce = true
```
## 🛠️ 排查步骤
### 步骤 1检查 API 请求
打开浏览器开发者工具 → Network 标签页:
1. 查找请求:`/v1/kefu/message/list?page=1&limit=500`
2. 检查:
- **状态码**:应该是 `200`
- **响应数据**:查看 Response 标签页
- **请求头**:确认 `Authorization` 头存在
**如果 API 请求失败:**
- 401Token 过期,需要重新登录
- 403权限不足
- 500服务器错误
- 网络错误:检查网络连接
### 步骤 2检查 API 响应数据格式
在控制台查看 `📥 第 1 页API响应` 日志:
**正常情况:**
```javascript
{
type: "object",
isArray: true,
length: 10, // 有数据
firstItem: { id: 123, nickname: "...", ... }
}
```
**异常情况:**
```javascript
{
type: "object",
isArray: false, // ❌ 不是数组
length: undefined
}
// 或
{
type: "object",
isArray: true,
length: 0 // ❌ 空数组
}
```
### 步骤 3检查数据同步
查看 `💾 同步第 X 页到数据库` 日志:
**正常情况:**
```javascript
{
friends: 5,
groups: 3,
total: 8
}
```
**异常情况:**
```javascript
{
friends: 0,
groups: 0,
total: 0 // ❌ 没有数据被同步
}
```
### 步骤 4检查数据库写入
查看 `✅ 第 X 页同步完成,数据库现有会话数` 日志:
- 如果数字为 0数据未写入数据库
- 如果数字 > 0数据已写入但可能 UI 未更新
### 步骤 5手动触发同步
如果自动同步失败,可以:
1. **点击同步按钮**:会话列表上方的"同步"按钮
2. **点击刷新按钮**:空状态下的"刷新会话列表"按钮
3. **在控制台执行**
```javascript
// 需要先获取组件实例或直接调用 API
import { getMessageList } from '@/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/api';
// 测试 API
getMessageList({ page: 1, limit: 500 })
.then(result => {
console.log('API 响应:', result);
console.log('数据类型:', typeof result);
console.log('是否为数组:', Array.isArray(result));
console.log('数据长度:', result?.length);
})
.catch(error => {
console.error('API 错误:', error);
});
```
## 🔧 常见问题及解决方案
### 问题 1API 返回空数组
**原因:**
- 用户确实没有会话数据
- API 参数错误
- 服务器过滤了数据
**解决:**
1. 检查 API 请求参数是否正确
2. 确认用户是否有会话数据(联系后端)
3. 检查是否有筛选条件
### 问题 2API 返回非数组格式
**原因:**
- API 响应格式变更
- 响应被包装在 `data` 字段中
**解决:**
检查 `api/request.ts` 中的响应拦截器,确认数据提取逻辑:
```typescript
// 在 request.ts 中
if (bizSuccess === true || (!hasBizCode && !hasBizSuccess)) {
return payload.data ?? payload; // 这里可能有问题
}
```
### 问题 3数据同步到数据库但 UI 不更新
**原因:**
- Store 状态未更新
- 账号切换过滤掉了所有数据
**解决:**
1. 检查 `storeSessions``filteredSessions` 的值
2. 检查 `currentCustomer?.id` 是否正确
3. 尝试切换到"全部账号"accountId = 0
### 问题 4数据库写入失败
**原因:**
- IndexedDB 权限问题
- 数据库版本不兼容
- 数据格式错误
**解决:**
1. 检查浏览器控制台是否有 IndexedDB 错误
2. 尝试清除浏览器数据并重新加载
3. 检查数据库结构是否匹配
## 📝 临时修复方案
如果问题持续存在,可以尝试:
### 方案 1清除所有数据并重新加载
```javascript
// 在控制台执行
localStorage.clear();
sessionStorage.clear();
indexedDB.databases().then(dbs => {
dbs.forEach(db => {
indexedDB.deleteDatabase(db.name);
});
});
window.location.reload();
```
### 方案 2强制显示全部会话
临时修改代码,在 `MessageList/index.tsx` 中:
```typescript
// 临时修复:强制显示全部会话,忽略账号过滤
useEffect(() => {
const accountId = 0; // 强制使用全部账号
switchAccount(accountId);
}, [switchAccount]);
```
### 方案 3绕过 Store直接使用数据库数据
```typescript
// 在 MessageList 组件中
useEffect(() => {
const loadDirectly = async () => {
const sessions = await MessageManager.getUserSessions(currentUserId);
if (sessions.length > 0) {
setFilteredSessions(sessions); // 直接设置本地状态
}
};
loadDirectly();
}, [currentUserId]);
```
## 🎯 下一步行动
1. **刷新页面**,观察控制台日志
2. **查看 Network 标签页**,检查 API 请求
3. **根据日志信息**,按照上述步骤排查
4. **如果问题仍然存在**,请提供:
- 完整的控制台日志
- Network 标签页中的 API 请求和响应
- 浏览器版本和操作系统
---
*最后更新2024年*
*相关文件:`src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx`*

View File

@@ -1,203 +0,0 @@
# 会话列表问题快速诊断
## 📋 请在浏览器控制台F12执行以下命令
### 1. 检查控制台日志
请在控制台中查找以下日志:
```
✅ 应该看到:
- 🔄 开始同步会话列表用户ID: 121
- 📡 请求第 1 页会话列表...
- 📥 第 1 页API响应: {...}
- 💾 同步第 1 页到数据库: {...}
- ✅ 第 1 页同步完成,数据库现有会话数: X
❌ 如果没看到上述日志,说明同步未执行
```
### 2. 手动测试 API
在控制台执行以下代码测试 API
```javascript
// 测试会话列表 API
fetch('/v1/kefu/message/list?page=1&limit=500', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
.then(res => res.json())
.then(data => {
console.log('API 响应:', data);
console.log('是否成功:', data.code === 200 || data.success === true);
console.log('数据类型:', typeof data);
console.log('是否为数组:', Array.isArray(data));
console.log('数据长度:', data?.length);
console.log('第一条数据:', data?.[0]);
})
.catch(err => {
console.error('API 错误:', err);
});
```
### 3. 检查数据库内容
在控制台执行:
```javascript
// 打开 IndexedDB
const userId = 121; // 你的用户 ID
const dbName = `CunkebaoDatabase_${userId}`;
const request = indexedDB.open(dbName);
request.onsuccess = function(event) {
const db = event.target.result;
const transaction = db.transaction(['chatSessions'], 'readonly');
const objectStore = transaction.objectStore('chatSessions');
const getAllRequest = objectStore.getAll();
getAllRequest.onsuccess = function() {
const sessions = getAllRequest.result;
console.log('数据库会话数量:', sessions.length);
console.log('数据库会话列表:', sessions);
// 按账号分组统计
const byAccount = {};
sessions.forEach(s => {
const accountId = s.wechatAccountId || 0;
byAccount[accountId] = (byAccount[accountId] || 0) + 1;
});
console.log('按账号统计:', byAccount);
};
};
request.onerror = function() {
console.error('打开数据库失败');
};
```
### 4. 检查账号信息
```javascript
// 检查当前账号
const customerStore = localStorage.getItem('customer-storage');
if (customerStore) {
const parsed = JSON.parse(customerStore);
console.log('当前账号:', parsed.state?.currentCustomer);
console.log('账号列表:', parsed.state?.customerList);
} else {
console.error('未找到账号信息');
}
```
### 5. 检查 Store 状态
在控制台执行:
```javascript
// 需要在组件内部或使用 React DevTools
// 如果可以访问 window 对象上的 store
console.log('查看 React DevTools 中的 Components 标签');
console.log('找到 MessageList 组件,查看其 hooks 状态');
```
## 🔍 根据结果判断问题
### 情况 1: API 返回空数组
**日志**: `📥 第 1 页API响应: {length: 0}`
**原因**: 服务器上确实没有会话数据
**解决方案**:
1. 确认是否在微信客服端发送过消息
2. 联系后端确认数据是否正确
3. 检查是否有权限问题
### 情况 2: API 请求失败
**日志**: `❌ 第 1 页API请求失败`
**原因**:
- Token 过期401
- 网络问题
- API 地址错误
**解决方案**:
1. 检查 Network 标签页中的错误详情
2. 如果是 401重新登录
3. 如果是网络错误,检查网络连接
### 情况 3: 数据库为空但 API 有数据
**特征**: API 返回有数据,但数据库查询为空
**原因**: 数据写入数据库失败
**解决方案**:
```javascript
// 清除数据库重试
const userId = 121;
const dbName = `CunkebaoDatabase_${userId}`;
indexedDB.deleteDatabase(dbName).onsuccess = () => {
console.log('数据库已删除,刷新页面重试');
window.location.reload();
};
```
### 情况 4: 数据库有数据但 UI 不显示
**特征**: 数据库查询有数据,但页面不显示
**原因**: Store 状态未更新或账号过滤问题
**解决方案**:
```javascript
// 检查过滤逻辑
// 在控制台查看 React DevTools
// 或者临时修改代码强制显示全部账号
```
## 🚨 临时解决方案
如果以上诊断都正常,但问题仍然存在,尝试:
### 方案 1: 强制刷新数据
在控制台执行:
```javascript
// 清除所有缓存
localStorage.clear();
sessionStorage.clear();
// 删除所有数据库
indexedDB.databases().then(dbs => {
dbs.forEach(db => {
console.log('删除数据库:', db.name);
indexedDB.deleteDatabase(db.name);
});
console.log('所有数据已清除3秒后自动刷新...');
setTimeout(() => window.location.reload(), 3000);
});
```
### 方案 2: 手动触发同步
点击页面上的"刷新会话列表"按钮,或在控制台执行:
```javascript
// 如果能访问到组件实例,手动触发同步
// 查看 React DevTools 找到 MessageList 组件
// 手动调用 handleManualSync 方法
```
## 📊 请提供以下信息
执行完上述命令后,请提供:
1. ✅ 控制台中的同步日志(特别是 🔄 📡 📥 💾 ✅ 这些 emoji 开头的)
2. ✅ API 测试结果(步骤 2 的输出)
3. ✅ 数据库内容(步骤 3 的输出)
4. ✅ Network 标签页中 `/v1/kefu/message/list` 请求的截图或数据
这些信息将帮助我准确定位问题!
---
*提示:如果看到大量日志,可以右键点击控制台选择"保存为..."导出日志文件*