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:
338
docs/msgType49拆分-文章vs小程序.md
Normal file
338
docs/msgType49拆分-文章vs小程序.md
Normal 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 的每种子类型都有专属的处理逻辑了!** 🎊
|
||||
159
docs/msgType49拆分-真实案例测试.md
Normal file
159
docs/msgType49拆分-真实案例测试.md
Normal 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 标记、引号和 `&`
|
||||
|
||||
## 调试日志
|
||||
|
||||
当消息被正确处理时,控制台会输出:
|
||||
|
||||
```
|
||||
✅ 提取到小程序标题: 八达通充值 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. **转义字符** - 自动处理 `&` 等 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)
|
||||
145
docs/消息类型快速参考.md
Normal file
145
docs/消息类型快速参考.md
Normal 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>
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
完成!✨
|
||||
146
docs/消息类型迁移记录.md
Normal file
146
docs/消息类型迁移记录.md
Normal 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)
|
||||
```
|
||||
|
||||
迁移完成!🎊
|
||||
376
docs/消息类型配置指南.md
Normal file
376
docs/消息类型配置指南.md
Normal 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. 刷新页面验证
|
||||
|
||||
就这么简单!🎉
|
||||
135
docs/消息类型配置系统使用状态.md
Normal file
135
docs/消息类型配置系统使用状态.md
Normal 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`,添加一个对象配置即可!
|
||||
298
docs/系统消息集成到配置系统.md
Normal file
298
docs/系统消息集成到配置系统.md
Normal 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
148
msgManage_new_logic.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -120,8 +120,10 @@ const tryParseContentJson = (content: string): Record<string, any> | null => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 消息解析 Hook
|
||||
* 消息解析 Hook(旧版本,保留向后兼容)
|
||||
* 提取消息解析逻辑,使用 useCallback 优化性能
|
||||
*
|
||||
* @deprecated 请使用 useMessageTypeParser 替代
|
||||
*/
|
||||
export const useMessageParser = (contract: ContractData | weChatGroup) => {
|
||||
// 判断是否为表情包URL的工具函数
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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(/&/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("[小程序消息 - 解析失败]");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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("❌ [个人资料] 收起面板,不加载数据");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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("❌ 收起面板,不加载数据");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,无需重复处理
|
||||
|
||||
// ==================== 会话操作 ====================
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 用户未登录");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || "",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
206
会话列表修复说明.md
206
会话列表修复说明.md
@@ -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`*
|
||||
220
会话列表问题根本原因和修复.md
220
会话列表问题根本原因和修复.md
@@ -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*
|
||||
245
会话列表问题诊断指南.md
245
会话列表问题诊断指南.md
@@ -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 请求失败:**
|
||||
- 401:Token 过期,需要重新登录
|
||||
- 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);
|
||||
});
|
||||
```
|
||||
|
||||
## 🔧 常见问题及解决方案
|
||||
|
||||
### 问题 1:API 返回空数组
|
||||
|
||||
**原因:**
|
||||
- 用户确实没有会话数据
|
||||
- API 参数错误
|
||||
- 服务器过滤了数据
|
||||
|
||||
**解决:**
|
||||
1. 检查 API 请求参数是否正确
|
||||
2. 确认用户是否有会话数据(联系后端)
|
||||
3. 检查是否有筛选条件
|
||||
|
||||
### 问题 2:API 返回非数组格式
|
||||
|
||||
**原因:**
|
||||
- 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`*
|
||||
203
快速诊断命令.md
203
快速诊断命令.md
@@ -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` 请求的截图或数据
|
||||
|
||||
这些信息将帮助我准确定位问题!
|
||||
|
||||
---
|
||||
|
||||
*提示:如果看到大量日志,可以右键点击控制台选择"保存为..."导出日志文件*
|
||||
Reference in New Issue
Block a user