48 files changed
+5394
-943
No files matched your search
@@ -0,0 +1,59 @@
|
||||
name: " 报告 Bug"
|
||||
description: "代码出现了非预期的问题、崩溃或报错"
|
||||
title: "[Bug]: "
|
||||
labels: ["type: bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请提供尽可能多的细节,这将帮助我们更快定位 `type: bug`。
|
||||
- type: checkboxes
|
||||
id: pre-check
|
||||
attributes:
|
||||
label: 提交前自测
|
||||
description: 请务必确认以下事项:
|
||||
options:
|
||||
- label: 我已经查阅了文档,并且搜索过现有的 Issues,确认这不是一个重复问题。
|
||||
required: true
|
||||
- label: 我使用的是当前最新的版本。
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 请清晰、明确地描述你遇到了什么问题。
|
||||
placeholder: 当我执行...操作时,程序崩溃了,或者输出了不正确的结果...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 提供精确的步骤,帮助我们复现该问题。如果可能,请提供最小复现代码协助我们的定位问题。
|
||||
placeholder: |
|
||||
1. 运行命令 '...'
|
||||
2. 点击 '...'
|
||||
3. 发现问题 '...'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 你期望看到什么结果?
|
||||
placeholder: 程序应该正常输出...而不是崩溃。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 报错日志或截图
|
||||
description: 请提供终端报错信息、浏览器 Console 日志,或直接将截图拖入此处。
|
||||
render: shell
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: 运行环境
|
||||
description: 你的操作系统、软件版本、依赖环境、系统架构等(如:Windows 11, Node v18.16.0)
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 🤔 找不到合适的模板?
|
||||
about: 如果你只是想闲聊或者你的问题不属于上述任何分类,请前往我们的的Telegram频道与我们交流。
|
||||
@@ -0,0 +1,24 @@
|
||||
name: " 文档反馈"
|
||||
description: "文档存在错别字、描述不清晰或缺少必要的示例"
|
||||
title: "[Docs]: "
|
||||
labels: ["type: docs"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
优秀的文档和代码一样重要。感谢你帮助我们完善文档!
|
||||
- type: input
|
||||
id: doc-link
|
||||
attributes:
|
||||
label: 相关文档链接
|
||||
description: 请提供存在问题的文档 URL 或具体文件路径。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: issue-desc
|
||||
attributes:
|
||||
label: 问题描述与建议
|
||||
description: 文档哪里写错了?或者你建议补充什么内容?
|
||||
placeholder: 在 "快速开始" 章节中,第三步的命令拼写错误,应该改为...
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,25 @@
|
||||
name: "功能与体验优化"
|
||||
description: "对现有的功能逻辑进行优化,或改进用户体验"
|
||||
title: "[Enhancement]: "
|
||||
labels: ["type: enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。
|
||||
- type: textarea
|
||||
id: target
|
||||
attributes:
|
||||
label: 目标功能
|
||||
description: 你希望优化的功能或模块是哪一个?
|
||||
placeholder: 例如:当前的导出功能 / 某个页面的交互...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: improvement
|
||||
attributes:
|
||||
label: 优化建议
|
||||
description: 你认为应该怎么改?这会带来什么好处(如:性能提升、减少点击次数、视觉更美观)?
|
||||
placeholder: 我建议将...改成...,这样可以将处理速度提升一倍。
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,35 @@
|
||||
name: "全新功能请求"
|
||||
description: "提议一个目前项目中完全没有的新特性"
|
||||
title: "[Feature]: "
|
||||
labels: ["type: feature"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢你为项目提供新想法!详细的需求描述能极大地增加该功能被采纳的几率。
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: 需求背景 / 动机
|
||||
description: 为什么需要这个功能?你目前遇到了什么痛点?
|
||||
placeholder: 目前我在做...事情时,必须手动处理,非常不方便。如果能有...功能就太好了。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 期望的解决方案
|
||||
description: 请详细描述你希望这个新功能是什么样的,它是如何工作的?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 备选方案 (可选)
|
||||
description: 你目前在使用什么临时的替代方案?或者你有没有考虑过其他的实现方式?
|
||||
- type: checkboxes
|
||||
id: help-wanted
|
||||
attributes:
|
||||
label: 参与贡献
|
||||
options:
|
||||
- label: 我有能力且愿意提交 Pull Request 来实现这个功能!(我们会为你打上 `help wanted` 标签)
|
||||
@@ -0,0 +1,29 @@
|
||||
name: "使用答疑"
|
||||
description: "关于如何配置、如何使用项目的求助"
|
||||
title: "[Question]: "
|
||||
labels: ["type: question"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
在提问之前,请确保你已经仔细阅读过我们的官方文档。
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: 你的问题是什么?
|
||||
description: 请清晰地描述你的疑问。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: attempts
|
||||
attributes:
|
||||
label: 你做过哪些尝试?
|
||||
description: 请告诉我们你已经试过了什么方法,避免我们提供重复的建议。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: code-snippet
|
||||
attributes:
|
||||
label: 相关代码配置
|
||||
description: 请粘贴你当前的配置文件或调用的代码片段。
|
||||
render: javascript
|
||||
+326
-255
@@ -1,33 +1,43 @@
|
||||
# WeFlow HTTP API 接口文档
|
||||
# WeFlow HTTP API 文档
|
||||
|
||||
WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。
|
||||
WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件。
|
||||
|
||||
## 启用 API 服务
|
||||
## 启用方式
|
||||
|
||||
在设置页面 → API 服务 → 点击「启动服务」按钮。
|
||||
在应用设置页启用 `API 服务`。
|
||||
|
||||
默认端口:`5031`
|
||||
|
||||
## 基础地址
|
||||
|
||||
```
|
||||
http://127.0.0.1:5031
|
||||
```
|
||||
|
||||
---
|
||||
- 默认监听地址:`127.0.0.1`
|
||||
- 默认端口:`5031`
|
||||
- 基础地址:`http://127.0.0.1:5031`
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 健康检查
|
||||
- `GET /health`
|
||||
- `GET /api/v1/health`
|
||||
- `GET /api/v1/messages`
|
||||
- `GET /api/v1/sessions`
|
||||
- `GET /api/v1/contacts`
|
||||
- `GET /api/v1/group-members`
|
||||
- `GET /api/v1/media/*`
|
||||
|
||||
检查 API 服务是否正常运行。
|
||||
---
|
||||
|
||||
## 1. 健康检查
|
||||
|
||||
**请求**
|
||||
```
|
||||
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
或
|
||||
|
||||
```http
|
||||
GET /api/v1/health
|
||||
```
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
@@ -36,211 +46,180 @@ GET /health
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取消息列表
|
||||
## 2. 获取消息
|
||||
|
||||
获取指定会话的消息,支持 ChatLab 格式输出。
|
||||
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||
|
||||
**请求**
|
||||
```
|
||||
|
||||
```http
|
||||
GET /api/v1/messages
|
||||
```
|
||||
|
||||
**参数**
|
||||
### 参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` |
|
||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
||||
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
|
||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
||||
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 |
|
||||
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) |
|
||||
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) |
|
||||
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
|
||||
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||
|
||||
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
|
||||
|
||||
**示例请求**
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 获取消息(原始格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
|
||||
|
||||
# 获取消息(ChatLab 格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
|
||||
|
||||
# 带时间范围查询
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
||||
|
||||
# 开启媒体导出(只导出图片和语音)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0
|
||||
|
||||
# 关键词过滤
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20"
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&chatlab=1"
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260131"
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0"
|
||||
```
|
||||
|
||||
**响应(原始格式)**
|
||||
### JSON 响应字段
|
||||
|
||||
顶层字段:
|
||||
|
||||
- `success`
|
||||
- `talker`
|
||||
- `count`
|
||||
- `hasMore`
|
||||
- `media.enabled`
|
||||
- `media.exportPath`
|
||||
- `media.count`
|
||||
- `messages`
|
||||
|
||||
单条消息字段:
|
||||
|
||||
- `localId`
|
||||
- `serverId`
|
||||
- `localType`
|
||||
- `createTime`
|
||||
- `isSend`
|
||||
- `senderUsername`
|
||||
- `content`
|
||||
- `rawContent`
|
||||
- `parsedContent`
|
||||
- `mediaType`
|
||||
- `mediaFileName`
|
||||
- `mediaUrl`
|
||||
- `mediaLocalPath`
|
||||
|
||||
**示例响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"talker": "wxid_xxx",
|
||||
"count": 50,
|
||||
"talker": "xxx@chatroom",
|
||||
"count": 2,
|
||||
"hasMore": true,
|
||||
"media": {
|
||||
"enabled": true,
|
||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||
"count": 12
|
||||
"count": 1
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"localId": 123,
|
||||
"serverId": "456",
|
||||
"localType": 1,
|
||||
"createTime": 1738713600,
|
||||
"isSend": 0,
|
||||
"senderUsername": "wxid_member",
|
||||
"content": "你好",
|
||||
"rawContent": "你好",
|
||||
"parsedContent": "你好"
|
||||
},
|
||||
{
|
||||
"localId": 124,
|
||||
"localType": 3,
|
||||
"createTime": 1738713660,
|
||||
"isSend": 0,
|
||||
"senderUsername": "wxid_member",
|
||||
"content": "[图片]",
|
||||
"createTime": 1738713600000,
|
||||
"senderUsername": "wxid_sender",
|
||||
"mediaType": "image",
|
||||
"mediaFileName": "image_123.jpg",
|
||||
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
|
||||
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||
"mediaFileName": "abc123.jpg",
|
||||
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg",
|
||||
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**响应(ChatLab 格式)**
|
||||
```json
|
||||
{
|
||||
"chatlab": {
|
||||
"version": "0.0.2",
|
||||
"exportedAt": 1738713600000,
|
||||
"generator": "WeFlow",
|
||||
"description": "Exported from WeFlow"
|
||||
},
|
||||
"meta": {
|
||||
"name": "会话名称",
|
||||
"platform": "wechat",
|
||||
"type": "private",
|
||||
"ownerId": "wxid_me"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
"platformId": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"groupNickname": "群昵称"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"sender": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"timestamp": 1738713600000,
|
||||
"type": 0,
|
||||
"content": "消息内容",
|
||||
"mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg"
|
||||
}
|
||||
],
|
||||
"media": {
|
||||
"enabled": true,
|
||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||
"count": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
### ChatLab 响应
|
||||
|
||||
当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构:
|
||||
|
||||
- `chatlab.version`
|
||||
- `chatlab.exportedAt`
|
||||
- `chatlab.generator`
|
||||
- `meta.name`
|
||||
- `meta.platform`
|
||||
- `meta.type`
|
||||
- `meta.groupId`
|
||||
- `meta.groupAvatar`
|
||||
- `meta.ownerId`
|
||||
- `members[].platformId`
|
||||
- `members[].accountName`
|
||||
- `members[].groupNickname`
|
||||
- `members[].avatar`
|
||||
- `messages[].sender`
|
||||
- `messages[].accountName`
|
||||
- `messages[].groupNickname`
|
||||
- `messages[].timestamp`
|
||||
- `messages[].type`
|
||||
- `messages[].content`
|
||||
- `messages[].platformMessageId`
|
||||
- `messages[].mediaPath`
|
||||
|
||||
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
||||
|
||||
---
|
||||
|
||||
### 3. 访问导出媒体文件
|
||||
|
||||
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
|
||||
## 3. 获取会话列表
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/media/{relativePath}
|
||||
```
|
||||
|
||||
**路径参数**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` |
|
||||
|
||||
**支持的媒体类型**
|
||||
|
||||
| 扩展名 | Content-Type |
|
||||
|--------|-------------|
|
||||
| `.png` | image/png |
|
||||
| `.jpg` / `.jpeg` | image/jpeg |
|
||||
| `.gif` | image/gif |
|
||||
| `.webp` | image/webp |
|
||||
| `.wav` | audio/wav |
|
||||
| `.mp3` | audio/mpeg |
|
||||
| `.mp4` | video/mp4 |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
# 访问导出的图片
|
||||
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg
|
||||
|
||||
# 访问导出的语音
|
||||
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav
|
||||
|
||||
# 访问导出的视频
|
||||
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4
|
||||
```
|
||||
|
||||
**响应**
|
||||
|
||||
成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。
|
||||
|
||||
失败时返回:
|
||||
```json
|
||||
{ "error": "Media not found" }
|
||||
```
|
||||
|
||||
> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。
|
||||
|
||||
---
|
||||
|
||||
### 4. 获取会话列表
|
||||
|
||||
获取所有会话列表。
|
||||
|
||||
**请求**
|
||||
```
|
||||
```http
|
||||
GET /api/v1/sessions
|
||||
```
|
||||
|
||||
**参数**
|
||||
### 参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
GET http://127.0.0.1:5031/api/v1/sessions
|
||||
### 响应字段
|
||||
|
||||
GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
||||
```
|
||||
- `success`
|
||||
- `count`
|
||||
- `sessions[].username`
|
||||
- `sessions[].displayName`
|
||||
- `sessions[].type`
|
||||
- `sessions[].lastTimestamp`
|
||||
- `sessions[].unreadCount`
|
||||
|
||||
**示例响应**
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"total": 100,
|
||||
"count": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"username": "wxid_xxx",
|
||||
"displayName": "用户名",
|
||||
"lastMessage": "最后一条消息",
|
||||
"lastTime": 1738713600000,
|
||||
"username": "xxx@chatroom",
|
||||
"displayName": "项目群",
|
||||
"type": 2,
|
||||
"lastTimestamp": 1738713600,
|
||||
"unreadCount": 0
|
||||
}
|
||||
]
|
||||
@@ -249,40 +228,48 @@ GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
||||
|
||||
---
|
||||
|
||||
### 4. 获取联系人列表
|
||||
|
||||
获取所有联系人信息。
|
||||
## 4. 获取联系人列表
|
||||
|
||||
**请求**
|
||||
```
|
||||
|
||||
```http
|
||||
GET /api/v1/contacts
|
||||
```
|
||||
|
||||
**参数**
|
||||
### 参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `keyword` | string | ❌ | 搜索关键词 |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
GET http://127.0.0.1:5031/api/v1/contacts
|
||||
### 响应字段
|
||||
|
||||
GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
|
||||
```
|
||||
- `success`
|
||||
- `count`
|
||||
- `contacts[].username`
|
||||
- `contacts[].displayName`
|
||||
- `contacts[].remark`
|
||||
- `contacts[].nickname`
|
||||
- `contacts[].alias`
|
||||
- `contacts[].avatarUrl`
|
||||
- `contacts[].type`
|
||||
|
||||
**示例响应**
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"count": 1,
|
||||
"contacts": [
|
||||
{
|
||||
"userName": "wxid_xxx",
|
||||
"alias": "微信号",
|
||||
"nickName": "昵称",
|
||||
"remark": "备注名"
|
||||
"username": "wxid_xxx",
|
||||
"displayName": "张三",
|
||||
"remark": "客户张三",
|
||||
"nickname": "张三",
|
||||
"alias": "zhangsan",
|
||||
"avatarUrl": "https://example.com/avatar.jpg",
|
||||
"type": "friend"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -290,60 +277,157 @@ GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
|
||||
|
||||
---
|
||||
|
||||
## ChatLab 格式说明
|
||||
## 5. 获取群成员列表
|
||||
|
||||
ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。
|
||||
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||
|
||||
### 消息类型映射
|
||||
**请求**
|
||||
|
||||
| ChatLab Type | 值 | 说明 |
|
||||
|--------------|-----|------|
|
||||
| TEXT | 0 | 文本消息 |
|
||||
| IMAGE | 1 | 图片 |
|
||||
| VOICE | 2 | 语音 |
|
||||
| VIDEO | 3 | 视频 |
|
||||
| FILE | 4 | 文件 |
|
||||
| EMOJI | 5 | 表情 |
|
||||
| LINK | 7 | 链接 |
|
||||
| LOCATION | 8 | 位置 |
|
||||
| RED_PACKET | 20 | 红包 |
|
||||
| TRANSFER | 21 | 转账 |
|
||||
| CALL | 23 | 通话 |
|
||||
| SYSTEM | 80 | 系统消息 |
|
||||
| RECALL | 81 | 撤回消息 |
|
||||
| OTHER | 99 | 其他 |
|
||||
```http
|
||||
GET /api/v1/group-members
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||
|
||||
### 响应字段
|
||||
|
||||
- `success`
|
||||
- `chatroomId`
|
||||
- `count`
|
||||
- `fromCache`
|
||||
- `updatedAt`
|
||||
- `members[].wxid`
|
||||
- `members[].displayName`
|
||||
- `members[].nickname`
|
||||
- `members[].remark`
|
||||
- `members[].alias`
|
||||
- `members[].groupNickname`
|
||||
- `members[].avatarUrl`
|
||||
- `members[].isOwner`
|
||||
- `members[].isFriend`
|
||||
- `members[].messageCount`
|
||||
|
||||
**示例请求**
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
||||
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=1"
|
||||
```
|
||||
|
||||
**示例响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"chatroomId": "xxx@chatroom",
|
||||
"count": 2,
|
||||
"fromCache": false,
|
||||
"updatedAt": 1760000000000,
|
||||
"members": [
|
||||
{
|
||||
"wxid": "wxid_member_a",
|
||||
"displayName": "客户A",
|
||||
"nickname": "阿甲",
|
||||
"remark": "客户A",
|
||||
"alias": "kehua",
|
||||
"groupNickname": "甲方",
|
||||
"avatarUrl": "https://example.com/a.jpg",
|
||||
"isOwner": true,
|
||||
"isFriend": true,
|
||||
"messageCount": 128
|
||||
},
|
||||
{
|
||||
"wxid": "wxid_member_b",
|
||||
"displayName": "李四",
|
||||
"nickname": "李四",
|
||||
"remark": "",
|
||||
"alias": "",
|
||||
"groupNickname": "",
|
||||
"avatarUrl": "",
|
||||
"isOwner": false,
|
||||
"isFriend": false,
|
||||
"messageCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `displayName` 是当前应用内的主展示名。
|
||||
- `groupNickname` 是成员在该群里的群昵称。
|
||||
- `remark` 是你对该联系人的备注。
|
||||
- `alias` 是微信号。
|
||||
- 当微信源数据里没有群昵称时,`groupNickname` 会为空。
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
## 6. 访问导出媒体
|
||||
|
||||
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/v1/media/{relativePath}
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg"
|
||||
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/voices/voice_100.wav"
|
||||
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/videos/video_200.mp4"
|
||||
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
||||
```
|
||||
|
||||
### 支持的 Content-Type
|
||||
|
||||
| 扩展名 | Content-Type |
|
||||
| --- | --- |
|
||||
| `.png` | `image/png` |
|
||||
| `.jpg` / `.jpeg` | `image/jpeg` |
|
||||
| `.gif` | `image/gif` |
|
||||
| `.webp` | `image/webp` |
|
||||
| `.wav` | `audio/wav` |
|
||||
| `.mp3` | `audio/mpeg` |
|
||||
| `.mp4` | `video/mp4` |
|
||||
|
||||
常见错误响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Media not found"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 使用示例
|
||||
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
# 健康检查
|
||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
||||
|
||||
# 获取会话列表
|
||||
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
# 获取消息
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
||||
|
||||
# 获取 ChatLab 格式
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:5031/health
|
||||
|
||||
# 获取会话列表
|
||||
curl http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
# 获取消息(ChatLab 格式)
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
||||
curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三"
|
||||
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
||||
```
|
||||
|
||||
### Python
|
||||
@@ -353,39 +437,26 @@ import requests
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5031"
|
||||
|
||||
# 获取会话列表
|
||||
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
|
||||
print(sessions)
|
||||
messages = requests.get(
|
||||
f"{BASE_URL}/api/v1/messages",
|
||||
params={"talker": "xxx@chatroom", "limit": 50}
|
||||
).json()
|
||||
|
||||
members = requests.get(
|
||||
f"{BASE_URL}/api/v1/group-members",
|
||||
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
|
||||
).json()
|
||||
|
||||
# 获取消息
|
||||
messages = requests.get(f"{BASE_URL}/api/v1/messages", params={
|
||||
"talker": "wxid_xxx",
|
||||
"limit": 100,
|
||||
"chatlab": 1
|
||||
}).json()
|
||||
print(messages)
|
||||
```
|
||||
|
||||
### JavaScript / Node.js
|
||||
|
||||
```javascript
|
||||
const BASE_URL = "http://127.0.0.1:5031";
|
||||
|
||||
// 获取会话列表
|
||||
const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json());
|
||||
console.log(sessions);
|
||||
|
||||
// 获取消息(ChatLab 格式)
|
||||
const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`)
|
||||
.then(r => r.json());
|
||||
console.log(messages);
|
||||
print(members)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
## 8. 注意事项
|
||||
|
||||
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
|
||||
2. 需要先连接数据库才能查询数据
|
||||
3. 时间参数格式为 `YYYYMMDD`(如 20260205)
|
||||
4. 支持 CORS,可从浏览器前端直接调用
|
||||
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||
3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。
|
||||
4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。
|
||||
5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。
|
||||
+75
-3
@@ -97,6 +97,9 @@ let mainWindowReady = false
|
||||
let shouldShowMain = true
|
||||
let isAppQuitting = false
|
||||
let tray: Tray | null = null
|
||||
let isClosePromptVisible = false
|
||||
|
||||
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||
|
||||
// 更新下载状态管理(Issue #294 修复)
|
||||
let isDownloadInProgress = false
|
||||
@@ -253,6 +256,19 @@ const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
|
||||
win.webContents.on('did-finish-load', emitMaximizeState)
|
||||
}
|
||||
|
||||
const getWindowCloseBehavior = (): WindowCloseBehavior => {
|
||||
const behavior = configService?.get('windowCloseBehavior')
|
||||
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
|
||||
}
|
||||
|
||||
const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
|
||||
if (isClosePromptVisible) return
|
||||
isClosePromptVisible = true
|
||||
win.webContents.send('window:confirmCloseRequested', {
|
||||
canMinimizeToTray: Boolean(tray)
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
// 获取图标路径 - 打包后在 resources 目录
|
||||
const { autoShow = true } = options
|
||||
@@ -354,10 +370,22 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
})
|
||||
|
||||
win.on('close', (e) => {
|
||||
if (isAppQuitting) return
|
||||
// 关闭主窗口时隐藏到状态栏而不是退出
|
||||
if (isAppQuitting || win !== mainWindow) return
|
||||
e.preventDefault()
|
||||
win.hide()
|
||||
const closeBehavior = getWindowCloseBehavior()
|
||||
|
||||
if (closeBehavior === 'quit') {
|
||||
isAppQuitting = true
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
if (closeBehavior === 'tray' && tray) {
|
||||
win.hide()
|
||||
return
|
||||
}
|
||||
|
||||
requestMainWindowCloseConfirmation(win)
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
@@ -365,6 +393,7 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
|
||||
mainWindow = null
|
||||
mainWindowReady = false
|
||||
isClosePromptVisible = false
|
||||
|
||||
if (process.platform !== 'darwin' && !isAppQuitting) {
|
||||
destroyNotificationWindow()
|
||||
@@ -1154,6 +1183,33 @@ function registerIpcHandlers() {
|
||||
BrowserWindow.fromWebContents(event.sender)?.close()
|
||||
})
|
||||
|
||||
ipcMain.handle('window:respondCloseConfirm', async (_event, action: 'tray' | 'quit' | 'cancel') => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
isClosePromptVisible = false
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'tray') {
|
||||
if (tray) {
|
||||
mainWindow.hide()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (action === 'quit') {
|
||||
isAppQuitting = true
|
||||
app.quit()
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
} finally {
|
||||
isClosePromptVisible = false
|
||||
}
|
||||
})
|
||||
|
||||
// 更新窗口控件主题色
|
||||
ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
@@ -1563,6 +1619,10 @@ function registerIpcHandlers() {
|
||||
return chatService.getMessageById(sessionId, localId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:searchMessages', async (_, keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => {
|
||||
return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
|
||||
return chatService.execQuery(kind, path, sql)
|
||||
})
|
||||
@@ -1889,6 +1949,18 @@ function registerIpcHandlers() {
|
||||
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'groupAnalytics:getGroupMemberMessages',
|
||||
async (
|
||||
_,
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||
) => {
|
||||
return groupAnalyticsService.getGroupMemberMessages(chatroomId, memberUsername, options)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
|
||||
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
||||
})
|
||||
|
||||
@@ -94,6 +94,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
||||
},
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => {
|
||||
const listener = (_: unknown, payload: { canMinimizeToTray: boolean }) => callback(payload)
|
||||
ipcRenderer.on('window:confirmCloseRequested', listener)
|
||||
return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener)
|
||||
},
|
||||
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') =>
|
||||
ipcRenderer.invoke('window:respondCloseConfirm', action),
|
||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||
@@ -218,6 +225,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
||||
getMessage: (sessionId: string, localId: number) =>
|
||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
|
||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
||||
ipcRenderer.on('wcdb-change', callback)
|
||||
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
||||
@@ -283,6 +292,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||
getGroupMemberMessages: (
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||
) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options),
|
||||
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||
|
||||
+455
-140
@@ -37,6 +37,7 @@ export interface ChatSession {
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
messageKey: string
|
||||
localId: number
|
||||
serverId: number
|
||||
localType: number
|
||||
@@ -233,6 +234,8 @@ class ChatService {
|
||||
// 缓存会话表信息,避免每次查询
|
||||
private sessionTablesCache = new Map<string, Array<{ tableName: string; dbPath: string }>>()
|
||||
private messageTableColumnsCache = new Map<string, { columns: Set<string>; updatedAt: number }>()
|
||||
private messageName2IdTableCache = new Map<string, string | null>()
|
||||
private messageSenderIdCache = new Map<string, string | null>()
|
||||
private readonly sessionTablesCacheTtl = 300000 // 5分钟
|
||||
private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000
|
||||
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
||||
@@ -1433,7 +1436,7 @@ class ChatService {
|
||||
startTime: number = 0,
|
||||
endTime: number = 0,
|
||||
ascending: boolean = false
|
||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> {
|
||||
let releaseMessageCursorMutex: (() => void) | null = null
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
@@ -1492,7 +1495,6 @@ class ChatService {
|
||||
|
||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
||||
this.messageCursors.set(sessionId, state)
|
||||
releaseMessageCursorMutex?.()
|
||||
|
||||
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
||||
// 注意:仅在 offset === 0 时重建游标最安全;
|
||||
@@ -1512,7 +1514,7 @@ class ChatService {
|
||||
}
|
||||
if (!skipBatch.rows || skipBatch.rows.length === 0) {
|
||||
console.warn(`[ChatService] 跳过时数据耗尽: skipped=${skipped}/${offset}`)
|
||||
return { success: true, messages: [], hasMore: false }
|
||||
return { success: true, messages: [], hasMore: false, nextOffset: skipped }
|
||||
}
|
||||
|
||||
const count = skipBatch.rows.length
|
||||
@@ -1531,7 +1533,7 @@ class ChatService {
|
||||
|
||||
if (!skipBatch.hasMore) {
|
||||
console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`)
|
||||
return { success: true, messages: [], hasMore: false }
|
||||
return { success: true, messages: [], hasMore: false, nextOffset: skipped }
|
||||
}
|
||||
}
|
||||
if (attempts >= maxSkipAttempts) {
|
||||
@@ -1548,91 +1550,28 @@ class ChatService {
|
||||
return { success: false, error: '游标状态未初始化' }
|
||||
}
|
||||
|
||||
// 获取当前批次的消息
|
||||
// Use buffered rows from skip logic if available
|
||||
let rows: any[] = state.bufferedMessages || []
|
||||
state.bufferedMessages = undefined // Clear buffer after use
|
||||
|
||||
// Track actual hasMore status from C++ layer
|
||||
// If we have buffered messages, we need to check if there's more data
|
||||
let actualHasMore = rows.length > 0 // If buffer exists, assume there might be more
|
||||
|
||||
// If buffer is not enough to fill a batch, try to fetch more
|
||||
// Or if buffer is empty, fetch a batch
|
||||
if (rows.length < batchSize) {
|
||||
const nextBatch = await wcdbService.fetchMessageBatch(state.cursor)
|
||||
if (nextBatch.success && nextBatch.rows) {
|
||||
rows = rows.concat(nextBatch.rows)
|
||||
actualHasMore = nextBatch.hasMore === true
|
||||
} else if (!nextBatch.success) {
|
||||
console.error('[ChatService] 获取消息批次失败:', nextBatch.error)
|
||||
// If we have some buffered rows, we can still return them?
|
||||
// Or fail? Let's return what we have if any, otherwise fail.
|
||||
if (rows.length === 0) {
|
||||
return { success: false, error: nextBatch.error || '获取消息失败' }
|
||||
}
|
||||
actualHasMore = false
|
||||
}
|
||||
const collected = await this.collectVisibleMessagesFromCursor(
|
||||
sessionId,
|
||||
state.cursor,
|
||||
limit,
|
||||
state.bufferedMessages as Record<string, any>[] | undefined
|
||||
)
|
||||
state.bufferedMessages = collected.bufferedRows
|
||||
if (!collected.success) {
|
||||
return { success: false, error: collected.error || '获取消息失败' }
|
||||
}
|
||||
|
||||
// If we have more than limit (due to buffer + full batch), slice it
|
||||
if (rows.length > limit) {
|
||||
rows = rows.slice(0, limit)
|
||||
// Note: We don't adjust state.fetched here because it tracks cursor position.
|
||||
// Next time offset will catch up or mismatch trigger reset.
|
||||
}
|
||||
|
||||
// Use actual hasMore from C++ layer, not simplified row count check
|
||||
const hasMore = actualHasMore
|
||||
|
||||
const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows))
|
||||
|
||||
// 🔒 安全验证:过滤掉不属于当前 sessionId 的消息(防止 C++ 层或缓存错误)
|
||||
const filtered = normalized.filter(msg => {
|
||||
// 检查消息的 senderUsername 或 rawContent 中的 talker
|
||||
// 群聊消息:senderUsername 是群成员,需要检查 _db_path 或上下文
|
||||
// 单聊消息:senderUsername 应该是 sessionId 或自己
|
||||
const isGroupChat = sessionId.includes('@chatroom')
|
||||
|
||||
if (isGroupChat) {
|
||||
// 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId)
|
||||
return true
|
||||
} else {
|
||||
// 单聊消息:senderUsername 应该是 sessionId(对方)或为空/null(自己)
|
||||
if (!msg.senderUsername || msg.senderUsername === sessionId) {
|
||||
return true
|
||||
}
|
||||
// 如果 isSend 为 1,说明是自己发的,允许通过
|
||||
if (msg.isSend === 1) {
|
||||
return true
|
||||
}
|
||||
// 其他情况:可能是错误的消息
|
||||
console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (filtered.length < normalized.length) {
|
||||
console.warn(`[ChatService] 过滤了 ${normalized.length - filtered.length} 条异常消息`)
|
||||
}
|
||||
|
||||
// 并发检查并修复缺失 CDN URL 的表情包
|
||||
const fixPromises: Promise<void>[] = []
|
||||
for (const msg of filtered) {
|
||||
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
|
||||
fixPromises.push(this.fallbackEmoticon(msg))
|
||||
}
|
||||
}
|
||||
|
||||
if (fixPromises.length > 0) {
|
||||
await Promise.allSettled(fixPromises)
|
||||
}
|
||||
|
||||
state.fetched += rows.length
|
||||
const rawRowsConsumed = collected.rawRowsConsumed || 0
|
||||
const filtered = collected.messages || []
|
||||
const hasMore = collected.hasMore === true
|
||||
state.fetched += rawRowsConsumed
|
||||
releaseMessageCursorMutex?.()
|
||||
|
||||
this.messageCacheService.set(sessionId, filtered)
|
||||
return { success: true, messages: filtered, hasMore }
|
||||
console.log(
|
||||
`[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}`
|
||||
)
|
||||
return { success: true, messages: filtered, hasMore, nextOffset: state.fetched }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取消息失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
@@ -1732,7 +1671,7 @@ class ChatService {
|
||||
}
|
||||
|
||||
|
||||
async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||
async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) {
|
||||
@@ -1746,24 +1685,19 @@ class ChatService {
|
||||
}
|
||||
|
||||
try {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
||||
if (!batch.success || !batch.rows) {
|
||||
return { success: false, error: batch.error || '获取消息失败' }
|
||||
const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursorResult.cursor, limit)
|
||||
if (!collected.success) {
|
||||
return { success: false, error: collected.error || '获取消息失败' }
|
||||
}
|
||||
const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(batch.rows as Record<string, any>[]))
|
||||
|
||||
// 并发检查并修复缺失 CDN URL 的表情包
|
||||
const fixPromises: Promise<void>[] = []
|
||||
for (const msg of normalized) {
|
||||
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
|
||||
fixPromises.push(this.fallbackEmoticon(msg))
|
||||
}
|
||||
console.log(
|
||||
`[ChatService] getLatestMessages session=${sessionId} rawRowsConsumed=${collected.rawRowsConsumed || 0} visibleMessagesReturned=${collected.messages?.length || 0} filteredOut=${collected.filteredOut || 0} nextOffset=${collected.rawRowsConsumed || 0} hasMore=${collected.hasMore === true}`
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
messages: collected.messages,
|
||||
hasMore: collected.hasMore,
|
||||
nextOffset: collected.rawRowsConsumed || 0
|
||||
}
|
||||
if (fixPromises.length > 0) {
|
||||
await Promise.allSettled(fixPromises)
|
||||
}
|
||||
|
||||
return { success: true, messages: normalized, hasMore: batch.hasMore === true }
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
||||
}
|
||||
@@ -1819,6 +1753,174 @@ class ChatService {
|
||||
return messages
|
||||
}
|
||||
|
||||
private encodeMessageKeySegment(value: unknown): string {
|
||||
const normalized = String(value ?? '').trim()
|
||||
return encodeURIComponent(normalized)
|
||||
}
|
||||
|
||||
private getMessageSourceInfo(row: Record<string, any>): { dbName?: string; tableName?: string; dbPath?: string } {
|
||||
const dbPath = String(
|
||||
this.getRowField(row, ['_db_path', 'db_path', 'dbPath', 'database_path', 'databasePath', 'source_db_path'])
|
||||
|| ''
|
||||
).trim()
|
||||
const explicitDbName = String(
|
||||
this.getRowField(row, ['db_name', 'dbName', 'database_name', 'databaseName', 'db', 'database', 'source_db'])
|
||||
|| ''
|
||||
).trim()
|
||||
const tableName = String(
|
||||
this.getRowField(row, ['table_name', 'tableName', 'table', 'source_table', 'sourceTable'])
|
||||
|| ''
|
||||
).trim()
|
||||
const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '')
|
||||
return {
|
||||
dbName: dbName || undefined,
|
||||
tableName: tableName || undefined,
|
||||
dbPath: dbPath || undefined
|
||||
}
|
||||
}
|
||||
|
||||
private buildMessageKey(input: {
|
||||
localId: number
|
||||
serverId: number
|
||||
createTime: number
|
||||
sortSeq: number
|
||||
senderUsername?: string | null
|
||||
localType: number
|
||||
dbName?: string
|
||||
tableName?: string
|
||||
dbPath?: string
|
||||
}): string {
|
||||
const localId = Number.isFinite(input.localId) ? Math.max(0, Math.floor(input.localId)) : 0
|
||||
const serverId = Number.isFinite(input.serverId) ? Math.max(0, Math.floor(input.serverId)) : 0
|
||||
const createTime = Number.isFinite(input.createTime) ? Math.max(0, Math.floor(input.createTime)) : 0
|
||||
const sortSeq = Number.isFinite(input.sortSeq) ? Math.max(0, Math.floor(input.sortSeq)) : 0
|
||||
const localType = Number.isFinite(input.localType) ? Math.floor(input.localType) : 0
|
||||
const senderUsername = this.encodeMessageKeySegment(input.senderUsername || '')
|
||||
const dbName = String(input.dbName || '').trim() || (input.dbPath ? basename(input.dbPath, extname(input.dbPath)) : '')
|
||||
const tableName = String(input.tableName || '').trim()
|
||||
|
||||
if (localId > 0 && dbName && tableName) {
|
||||
return `${this.encodeMessageKeySegment(dbName)}:${this.encodeMessageKeySegment(tableName)}:${localId}`
|
||||
}
|
||||
|
||||
if (serverId > 0) {
|
||||
return `server:${serverId}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
|
||||
}
|
||||
|
||||
return `fallback:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
|
||||
}
|
||||
|
||||
private isMessageVisibleForSession(sessionId: string, msg: Message): boolean {
|
||||
const isGroupChat = sessionId.includes('@chatroom')
|
||||
if (isGroupChat) {
|
||||
return true
|
||||
}
|
||||
if (!msg.senderUsername || msg.senderUsername === sessionId) {
|
||||
return true
|
||||
}
|
||||
if (msg.isSend === 1) {
|
||||
return true
|
||||
}
|
||||
console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
|
||||
return false
|
||||
}
|
||||
|
||||
private async repairEmojiMessages(messages: Message[]): Promise<void> {
|
||||
const fixPromises: Promise<void>[] = []
|
||||
for (const msg of messages) {
|
||||
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
|
||||
fixPromises.push(this.fallbackEmoticon(msg))
|
||||
}
|
||||
}
|
||||
if (fixPromises.length > 0) {
|
||||
await Promise.allSettled(fixPromises)
|
||||
}
|
||||
}
|
||||
|
||||
private async collectVisibleMessagesFromCursor(
|
||||
sessionId: string,
|
||||
cursor: number,
|
||||
limit: number,
|
||||
initialRows: Record<string, any>[] = []
|
||||
): Promise<{
|
||||
success: boolean
|
||||
messages?: Message[]
|
||||
hasMore?: boolean
|
||||
error?: string
|
||||
rawRowsConsumed?: number
|
||||
filteredOut?: number
|
||||
bufferedRows?: Record<string, any>[]
|
||||
}> {
|
||||
const visibleMessages: Message[] = []
|
||||
let queuedRows = Array.isArray(initialRows) ? initialRows.slice() : []
|
||||
let rawRowsConsumed = 0
|
||||
let filteredOut = 0
|
||||
let cursorMayHaveMore = queuedRows.length > 0
|
||||
|
||||
while (visibleMessages.length < limit) {
|
||||
if (queuedRows.length === 0) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batch.success) {
|
||||
console.error('[ChatService] 获取消息批次失败:', batch.error)
|
||||
if (visibleMessages.length === 0) {
|
||||
return { success: false, error: batch.error || '获取消息失败' }
|
||||
}
|
||||
cursorMayHaveMore = false
|
||||
break
|
||||
}
|
||||
|
||||
const batchRows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||
cursorMayHaveMore = batch.hasMore === true
|
||||
if (batchRows.length === 0) {
|
||||
break
|
||||
}
|
||||
queuedRows = batchRows
|
||||
}
|
||||
|
||||
const rowsToProcess = queuedRows
|
||||
queuedRows = []
|
||||
const mappedMessages = this.mapRowsToMessages(rowsToProcess)
|
||||
for (let index = 0; index < mappedMessages.length; index += 1) {
|
||||
const msg = mappedMessages[index]
|
||||
rawRowsConsumed += 1
|
||||
if (this.isMessageVisibleForSession(sessionId, msg)) {
|
||||
visibleMessages.push(msg)
|
||||
if (visibleMessages.length >= limit) {
|
||||
if (index + 1 < rowsToProcess.length) {
|
||||
queuedRows = rowsToProcess.slice(index + 1)
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
filteredOut += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleMessages.length >= limit) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!cursorMayHaveMore) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredOut > 0) {
|
||||
console.warn(`[ChatService] 过滤了 ${filteredOut} 条异常消息`)
|
||||
}
|
||||
|
||||
const normalized = this.normalizeMessageOrder(visibleMessages)
|
||||
await this.repairEmojiMessages(normalized)
|
||||
return {
|
||||
success: true,
|
||||
messages: normalized,
|
||||
hasMore: queuedRows.length > 0 || cursorMayHaveMore,
|
||||
rawRowsConsumed,
|
||||
filteredOut,
|
||||
bufferedRows: queuedRows.length > 0 ? queuedRows : undefined
|
||||
}
|
||||
}
|
||||
|
||||
private getRowField(row: Record<string, any>, keys: string[]): any {
|
||||
for (const key of keys) {
|
||||
if (row[key] !== undefined && row[key] !== null) return row[key]
|
||||
@@ -1890,6 +1992,62 @@ class ChatService {
|
||||
return [lowerRaw]
|
||||
}
|
||||
|
||||
private resolveMessageIsSend(rawIsSend: number | null, senderUsername?: string | null): {
|
||||
isSend: number | null
|
||||
selfMatched: boolean
|
||||
correctedBySelfIdentity: boolean
|
||||
} {
|
||||
const normalizedRawIsSend = Number.isFinite(rawIsSend as number) ? rawIsSend : null
|
||||
const senderKeys = this.buildIdentityKeys(String(senderUsername || ''))
|
||||
if (senderKeys.length === 0) {
|
||||
return {
|
||||
isSend: normalizedRawIsSend,
|
||||
selfMatched: false,
|
||||
correctedBySelfIdentity: false
|
||||
}
|
||||
}
|
||||
|
||||
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const selfKeys = this.buildIdentityKeys(myWxid)
|
||||
if (selfKeys.length === 0) {
|
||||
return {
|
||||
isSend: normalizedRawIsSend,
|
||||
selfMatched: false,
|
||||
correctedBySelfIdentity: false
|
||||
}
|
||||
}
|
||||
|
||||
const selfMatched = senderKeys.some(senderKey =>
|
||||
selfKeys.some(selfKey =>
|
||||
senderKey === selfKey ||
|
||||
senderKey.startsWith(selfKey + '_') ||
|
||||
selfKey.startsWith(senderKey + '_')
|
||||
)
|
||||
)
|
||||
|
||||
if (selfMatched && normalizedRawIsSend !== 1) {
|
||||
return {
|
||||
isSend: 1,
|
||||
selfMatched: true,
|
||||
correctedBySelfIdentity: true
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedRawIsSend === null) {
|
||||
return {
|
||||
isSend: selfMatched ? 1 : 0,
|
||||
selfMatched,
|
||||
correctedBySelfIdentity: false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isSend: normalizedRawIsSend,
|
||||
selfMatched,
|
||||
correctedBySelfIdentity: false
|
||||
}
|
||||
}
|
||||
|
||||
private extractGroupMemberUsername(member: any): string {
|
||||
if (!member) return ''
|
||||
if (typeof member === 'string') return member.trim()
|
||||
@@ -2948,12 +3106,10 @@ class ChatService {
|
||||
|
||||
private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const cleanedWxid = myWxid ? this.cleanAccountDirName(myWxid) : null
|
||||
const myWxidLower = myWxid ? myWxid.toLowerCase() : null
|
||||
const cleanedWxidLower = cleanedWxid ? cleanedWxid.toLowerCase() : null
|
||||
|
||||
const messages: Message[] = []
|
||||
for (const row of rows) {
|
||||
const sourceInfo = this.getMessageSourceInfo(row)
|
||||
const rawMessageContent = this.getRowField(row, [
|
||||
'message_content',
|
||||
'messageContent',
|
||||
@@ -2974,30 +3130,14 @@ class ChatService {
|
||||
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
|
||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
||||
const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
||||
let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
|
||||
const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
|
||||
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
||||
|| this.extractSenderUsernameFromContent(content)
|
||||
|| null
|
||||
const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername)
|
||||
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
||||
|
||||
if (senderUsername && (myWxidLower || cleanedWxidLower)) {
|
||||
const senderLower = String(senderUsername).toLowerCase()
|
||||
const expectedIsSend = (
|
||||
senderLower === myWxidLower ||
|
||||
senderLower === cleanedWxidLower ||
|
||||
// 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup,而 sender 是 custom)
|
||||
(myWxidLower && myWxidLower.startsWith(senderLower + '_')) ||
|
||||
(cleanedWxidLower && cleanedWxidLower.startsWith(senderLower + '_'))
|
||||
) ? 1 : 0
|
||||
if (isSend === null) {
|
||||
isSend = expectedIsSend
|
||||
// [DEBUG] Issue #34: 记录 isSend 推断过程
|
||||
if (expectedIsSend === 0 && localType === 1) {
|
||||
// 仅在被判为接收且是文本消息时记录,避免刷屏
|
||||
//
|
||||
}
|
||||
}
|
||||
} else if (senderUsername && !myWxid) {
|
||||
if (senderUsername && !myWxid) {
|
||||
// [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送
|
||||
if (messages.length < 5) {
|
||||
console.warn(`[ChatService] Warning: myWxid not set. Cannot determine if message is sent by me. sender=${senderUsername}`)
|
||||
@@ -3160,12 +3300,25 @@ class ChatService {
|
||||
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
|
||||
}
|
||||
|
||||
const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
|
||||
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
|
||||
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
||||
|
||||
messages.push({
|
||||
localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
|
||||
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
|
||||
messageKey: this.buildMessageKey({
|
||||
localId,
|
||||
serverId,
|
||||
createTime,
|
||||
sortSeq,
|
||||
senderUsername,
|
||||
localType,
|
||||
...sourceInfo
|
||||
}),
|
||||
localId,
|
||||
serverId,
|
||||
localType,
|
||||
createTime,
|
||||
sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime),
|
||||
sortSeq,
|
||||
isSend,
|
||||
senderUsername,
|
||||
parsedContent: this.parseMessageContent(content, localType),
|
||||
@@ -3217,7 +3370,8 @@ class ChatService {
|
||||
transferPayerUsername,
|
||||
transferReceiverUsername,
|
||||
chatRecordTitle,
|
||||
chatRecordList
|
||||
chatRecordList,
|
||||
_db_path: sourceInfo.dbPath
|
||||
})
|
||||
const last = messages[messages.length - 1]
|
||||
if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) {
|
||||
@@ -4306,6 +4460,75 @@ class ChatService {
|
||||
return result.rows[0]?.name || null
|
||||
}
|
||||
|
||||
private async resolveMessageName2IdTableName(dbPath: string): Promise<string | null> {
|
||||
const normalizedDbPath = String(dbPath || '').trim()
|
||||
if (!normalizedDbPath) return null
|
||||
if (this.messageName2IdTableCache.has(normalizedDbPath)) {
|
||||
return this.messageName2IdTableCache.get(normalizedDbPath) || null
|
||||
}
|
||||
|
||||
const result = await wcdbService.execQuery(
|
||||
'message',
|
||||
normalizedDbPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%' ORDER BY name DESC LIMIT 1"
|
||||
)
|
||||
const tableName = result.success && result.rows && result.rows.length > 0
|
||||
? String(result.rows[0]?.name || '').trim() || null
|
||||
: null
|
||||
this.messageName2IdTableCache.set(normalizedDbPath, tableName)
|
||||
return tableName
|
||||
}
|
||||
|
||||
private async resolveMessageSenderUsernameById(dbPath: string, senderId: unknown): Promise<string | null> {
|
||||
const normalizedDbPath = String(dbPath || '').trim()
|
||||
const numericSenderId = Number.parseInt(String(senderId ?? '').trim(), 10)
|
||||
if (!normalizedDbPath || !Number.isFinite(numericSenderId) || numericSenderId <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheKey = `${normalizedDbPath}::${numericSenderId}`
|
||||
if (this.messageSenderIdCache.has(cacheKey)) {
|
||||
return this.messageSenderIdCache.get(cacheKey) || null
|
||||
}
|
||||
|
||||
const name2IdTable = await this.resolveMessageName2IdTableName(normalizedDbPath)
|
||||
if (!name2IdTable) {
|
||||
this.messageSenderIdCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const escapedTableName = String(name2IdTable).replace(/"/g, '""')
|
||||
const result = await wcdbService.execQuery(
|
||||
'message',
|
||||
normalizedDbPath,
|
||||
`SELECT user_name FROM "${escapedTableName}" WHERE rowid = ${numericSenderId} LIMIT 1`
|
||||
)
|
||||
const username = result.success && result.rows && result.rows.length > 0
|
||||
? String(result.rows[0]?.user_name || result.rows[0]?.userName || '').trim() || null
|
||||
: null
|
||||
this.messageSenderIdCache.set(cacheKey, username)
|
||||
return username
|
||||
}
|
||||
|
||||
private async resolveSenderUsernameForMessageRow(
|
||||
row: Record<string, any>,
|
||||
rawContent: string
|
||||
): Promise<string | null> {
|
||||
const directSender = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
||||
|| this.extractSenderUsernameFromContent(rawContent)
|
||||
if (directSender) {
|
||||
return directSender
|
||||
}
|
||||
|
||||
const dbPath = this.getRowField(row, ['db_path', 'dbPath', '_db_path'])
|
||||
const realSenderId = this.getRowField(row, ['real_sender_id', 'realSenderId'])
|
||||
if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.resolveMessageSenderUsernameById(String(dbPath), realSenderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否像 wxid
|
||||
*/
|
||||
@@ -5524,6 +5747,12 @@ class ChatService {
|
||||
}
|
||||
|
||||
// 3. 读取解密后的文件并转成 base64
|
||||
// 如果已经是 data URL,直接返回 base64 部分
|
||||
if (result.localPath.startsWith('data:')) {
|
||||
const base64Data = result.localPath.split(',')[1]
|
||||
return { success: true, data: base64Data }
|
||||
}
|
||||
|
||||
// localPath 是 file:// URL,需要转换成文件路径
|
||||
const filePath = result.localPath.startsWith('file://')
|
||||
? result.localPath.replace(/^file:\/\//, '')
|
||||
@@ -6564,8 +6793,12 @@ class ChatService {
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
const message = this.parseMessage(row)
|
||||
const row = {
|
||||
...(result.rows[0] as Record<string, any>),
|
||||
db_path: dbPath,
|
||||
table_name: tableName
|
||||
}
|
||||
const message = await this.parseMessage(row, { source: 'detail', sessionId })
|
||||
|
||||
if (message.localId !== 0) {
|
||||
return { success: true, message }
|
||||
@@ -6580,7 +6813,60 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
private parseMessage(row: any): Message {
|
||||
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
|
||||
try {
|
||||
const result = await wcdbService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
|
||||
if (!result.success || !result.messages) {
|
||||
return { success: false, error: result.error || '搜索失败' }
|
||||
}
|
||||
const messages: Message[] = []
|
||||
const isGroupSearch = Boolean(String(sessionId || '').trim().endsWith('@chatroom'))
|
||||
|
||||
for (const row of result.messages) {
|
||||
let message = await this.parseMessage(row, { source: 'search', sessionId })
|
||||
const needsDetailHydration = isGroupSearch &&
|
||||
Boolean(sessionId) &&
|
||||
message.localId > 0 &&
|
||||
(!message.senderUsername || message.isSend === null)
|
||||
|
||||
if (needsDetailHydration && sessionId) {
|
||||
const detail = await this.getMessageById(sessionId, message.localId)
|
||||
if (detail.success && detail.message) {
|
||||
message = {
|
||||
...message,
|
||||
...detail.message,
|
||||
parsedContent: message.parsedContent || detail.message.parsedContent,
|
||||
rawContent: message.rawContent || detail.message.rawContent,
|
||||
content: message.content || detail.message.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroupSearch && (needsDetailHydration || message.isSend === 1)) {
|
||||
console.info('[ChatService][GroupSearchHydratedHit]', {
|
||||
sessionId,
|
||||
localId: message.localId,
|
||||
senderUsername: message.senderUsername,
|
||||
isSend: message.isSend,
|
||||
senderDisplayName: message.senderDisplayName,
|
||||
senderAvatarUrl: message.senderAvatarUrl,
|
||||
usedDetailHydration: needsDetailHydration,
|
||||
parsedContent: message.parsedContent
|
||||
})
|
||||
}
|
||||
|
||||
messages.push(message)
|
||||
}
|
||||
|
||||
return { success: true, messages }
|
||||
} catch (e) {
|
||||
console.error('ChatService: searchMessages 失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> {
|
||||
const sourceInfo = this.getMessageSourceInfo(row)
|
||||
const rawContent = this.decodeMessageContent(
|
||||
this.getRowField(row, [
|
||||
'message_content',
|
||||
@@ -6601,19 +6887,35 @@ class ChatService {
|
||||
)
|
||||
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
|
||||
// 实际项目中建议抽取 parseRawMessage(row) 供多处使用
|
||||
const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
|
||||
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
|
||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
|
||||
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
||||
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
||||
const rawIsSend = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
||||
const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent)
|
||||
const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername)
|
||||
const msg: Message = {
|
||||
localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
|
||||
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
|
||||
localType: this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0),
|
||||
createTime: this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0),
|
||||
sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)),
|
||||
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
|
||||
senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
||||
|| this.extractSenderUsernameFromContent(rawContent)
|
||||
|| null,
|
||||
messageKey: this.buildMessageKey({
|
||||
localId,
|
||||
serverId,
|
||||
createTime,
|
||||
sortSeq,
|
||||
senderUsername,
|
||||
localType,
|
||||
...sourceInfo
|
||||
}),
|
||||
localId,
|
||||
serverId,
|
||||
localType,
|
||||
createTime,
|
||||
sortSeq,
|
||||
isSend: sendState.isSend,
|
||||
senderUsername,
|
||||
rawContent: rawContent,
|
||||
content: rawContent, // 添加原始内容供视频MD5解析使用
|
||||
parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
|
||||
parsedContent: this.parseMessageContent(rawContent, localType),
|
||||
_db_path: sourceInfo.dbPath
|
||||
}
|
||||
|
||||
if (msg.localId === 0 || msg.createTime === 0) {
|
||||
@@ -6629,6 +6931,19 @@ class ChatService {
|
||||
})
|
||||
}
|
||||
|
||||
if (options?.source === 'search' && String(options.sessionId || '').endsWith('@chatroom') && sendState.selfMatched) {
|
||||
console.info('[ChatService][GroupSearchSelfHit]', {
|
||||
sessionId: options.sessionId,
|
||||
localId,
|
||||
createTime,
|
||||
senderUsername,
|
||||
rawIsSend,
|
||||
resolvedIsSend: sendState.isSend,
|
||||
correctedBySelfIdentity: sendState.correctedBySelfIdentity,
|
||||
rowKeys: Object.keys(row)
|
||||
})
|
||||
}
|
||||
|
||||
// 图片/语音解析逻辑 (简化示例,实际应调用现有解析方法)
|
||||
if (msg.localType === 3) { // Image
|
||||
const imgInfo = this.parseImageInfo(rawContent)
|
||||
|
||||
@@ -47,9 +47,10 @@ interface ConfigSchema {
|
||||
|
||||
// 通知
|
||||
notificationEnabled: boolean
|
||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||
wordCloudExcludeWords: string[]
|
||||
}
|
||||
|
||||
@@ -116,6 +117,7 @@ export class ConfigService {
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: [],
|
||||
windowCloseBehavior: 'ask',
|
||||
wordCloudExcludeWords: []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,13 +1,90 @@
|
||||
import { join, basename } from 'path'
|
||||
import { existsSync, readdirSync, statSync } from 'fs'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { createDecipheriv } from 'crypto'
|
||||
|
||||
export interface WxidInfo {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export class DbPathService {
|
||||
private readVarint(buf: Buffer, offset: number): { value: number, length: number } {
|
||||
let value = 0;
|
||||
let length = 0;
|
||||
let shift = 0;
|
||||
while (offset < buf.length && shift < 32) {
|
||||
const b = buf[offset++];
|
||||
value |= (b & 0x7f) << shift;
|
||||
length++;
|
||||
if ((b & 0x80) === 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
return { value, length };
|
||||
}
|
||||
|
||||
private extractMmkvString(buf: Buffer, keyName: string): string {
|
||||
const keyBuf = Buffer.from(keyName, 'utf8');
|
||||
const idx = buf.indexOf(keyBuf);
|
||||
if (idx === -1) return '';
|
||||
|
||||
try {
|
||||
let offset = idx + keyBuf.length;
|
||||
const v1 = this.readVarint(buf, offset);
|
||||
offset += v1.length;
|
||||
const v2 = this.readVarint(buf, offset);
|
||||
offset += v2.length;
|
||||
|
||||
// 合理性检查
|
||||
if (v2.value > 0 && v2.value <= 10000 && offset + v2.value <= buf.length) {
|
||||
return buf.toString('utf8', offset, offset + v2.value);
|
||||
}
|
||||
} catch { }
|
||||
return '';
|
||||
}
|
||||
|
||||
private parseGlobalConfig(rootPath: string): { wxid: string, nickname: string, avatarUrl: string } | null {
|
||||
try {
|
||||
const configPath = join(rootPath, 'all_users', 'config', 'global_config');
|
||||
if (!existsSync(configPath)) return null;
|
||||
|
||||
const fullData = readFileSync(configPath);
|
||||
if (fullData.length <= 4) return null;
|
||||
const encryptedData = fullData.subarray(4);
|
||||
|
||||
const key = Buffer.alloc(16, 0);
|
||||
Buffer.from('xwechat_crypt_key').copy(key); // 直接硬编码,iv更是不重要
|
||||
const iv = Buffer.alloc(16, 0);
|
||||
|
||||
const decipher = createDecipheriv('aes-128-cfb', key, iv);
|
||||
decipher.setAutoPadding(false);
|
||||
const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
|
||||
|
||||
const wxid = this.extractMmkvString(decrypted, 'mmkv_key_user_name');
|
||||
const nickname = this.extractMmkvString(decrypted, 'mmkv_key_nick_name');
|
||||
let avatarUrl = this.extractMmkvString(decrypted, 'mmkv_key_head_img_url');
|
||||
|
||||
if (!avatarUrl && decrypted.includes('http')) {
|
||||
const httpIdx = decrypted.indexOf('http');
|
||||
const nullIdx = decrypted.indexOf(0x00, httpIdx);
|
||||
if (nullIdx !== -1) {
|
||||
avatarUrl = decrypted.toString('utf8', httpIdx, nullIdx);
|
||||
}
|
||||
}
|
||||
|
||||
if (wxid || nickname) {
|
||||
return { wxid, nickname, avatarUrl };
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error('解析 global_config 失败:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 自动检测微信数据库根目录
|
||||
*/
|
||||
@@ -135,21 +212,16 @@ export class DbPathService {
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try {
|
||||
stat = statSync(entryPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
try { stat = statSync(entryPath) } catch { continue }
|
||||
if (!stat.isDirectory()) continue
|
||||
const lower = entry.toLowerCase()
|
||||
if (lower === 'all_users') continue
|
||||
if (!entry.includes('_')) continue
|
||||
|
||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (wxids.length === 0) {
|
||||
const rootName = basename(rootPath)
|
||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||
@@ -159,12 +231,25 @@ export class DbPathService {
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return wxids.sort((a, b) => {
|
||||
const sorted = wxids.sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
});
|
||||
|
||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||
if (globalInfo) {
|
||||
for (const w of sorted) {
|
||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||
w.nickname = globalInfo.nickname;
|
||||
w.avatarUrl = globalInfo.avatarUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 扫描 wxid 列表
|
||||
*/
|
||||
@@ -187,10 +272,21 @@ export class DbPathService {
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return wxids.sort((a, b) => {
|
||||
const sorted = wxids.sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
});
|
||||
|
||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||
if (globalInfo) {
|
||||
for (const w of sorted) {
|
||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||
w.nickname = globalInfo.nickname;
|
||||
w.avatarUrl = globalInfo.avatarUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -562,23 +562,50 @@ class ExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 contact.chat_room.ext_buffer 解析群昵称(纯 SQL)
|
||||
* 获取群成员群昵称。优先使用 DLL,必要时回退到 `contact.chat_room.ext_buffer` 解析。
|
||||
*/
|
||||
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||
const nicknameMap = new Map<string, string>()
|
||||
|
||||
try {
|
||||
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||
if (dllResult.success && dllResult.nicknames) {
|
||||
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom dll error:', e)
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用参数化查询防止SQL注入
|
||||
const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1'
|
||||
const result = await wcdbService.execQuery('contact', null, sql, [chatroomId])
|
||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||
return new Map<string, string>()
|
||||
return nicknameMap
|
||||
}
|
||||
|
||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
||||
if (!extBuffer) return new Map<string, string>()
|
||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
|
||||
if (!extBuffer) return nicknameMap
|
||||
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
|
||||
return nicknameMap
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom error:', e)
|
||||
return new Map<string, string>()
|
||||
return nicknameMap
|
||||
}
|
||||
}
|
||||
|
||||
private mergeGroupNicknameEntries(
|
||||
target: Map<string, string>,
|
||||
entries: Iterable<[string, string]>
|
||||
): void {
|
||||
for (const [memberIdRaw, nicknameRaw] of entries) {
|
||||
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
|
||||
if (!nickname) continue
|
||||
for (const alias of this.buildGroupNicknameIdCandidates([memberIdRaw])) {
|
||||
if (!alias) continue
|
||||
if (!target.has(alias)) target.set(alias, nickname)
|
||||
const lower = alias.toLowerCase()
|
||||
if (!target.has(lower)) target.set(lower, nickname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4453,6 +4480,7 @@ class ExportService {
|
||||
|
||||
const cleanedMyWxid = conn.cleanedWxid
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||
@@ -5650,6 +5678,7 @@ class ExportService {
|
||||
|
||||
const cleanedMyWxid = conn.cleanedWxid
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
|
||||
|
||||
@@ -49,6 +49,12 @@ export interface GroupMediaStats {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface GroupMemberMessagesPage {
|
||||
messages: Message[]
|
||||
hasMore: boolean
|
||||
nextCursor: number
|
||||
}
|
||||
|
||||
interface GroupMemberContactInfo {
|
||||
remark: string
|
||||
nickName: string
|
||||
@@ -255,20 +261,47 @@ class GroupAnalyticsService {
|
||||
* 从 DLL 获取群成员的群昵称
|
||||
*/
|
||||
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||
const nicknameMap = new Map<string, string>()
|
||||
|
||||
try {
|
||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||
if (dllResult.success && dllResult.nicknames) {
|
||||
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom dll error:', e)
|
||||
}
|
||||
|
||||
try {
|
||||
const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1'
|
||||
const result = await wcdbService.execQuery('contact', null, sql, [chatroomId])
|
||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||
return new Map<string, string>()
|
||||
return nicknameMap
|
||||
}
|
||||
|
||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
||||
if (!extBuffer) return new Map<string, string>()
|
||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
|
||||
if (!extBuffer) return nicknameMap
|
||||
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
|
||||
return nicknameMap
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom error:', e)
|
||||
return new Map<string, string>()
|
||||
return nicknameMap
|
||||
}
|
||||
}
|
||||
|
||||
private mergeGroupNicknameEntries(
|
||||
target: Map<string, string>,
|
||||
entries: Iterable<[string, string]>
|
||||
): void {
|
||||
for (const [memberIdRaw, nicknameRaw] of entries) {
|
||||
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
|
||||
if (!nickname) continue
|
||||
for (const alias of this.buildIdCandidates([memberIdRaw])) {
|
||||
if (!alias) continue
|
||||
if (!target.has(alias)) target.set(alias, nickname)
|
||||
const lower = alias.toLowerCase()
|
||||
if (!target.has(lower)) target.set(lower, nickname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,6 +804,100 @@ class GroupAnalyticsService {
|
||||
return { success: true, data: matchedMessages }
|
||||
}
|
||||
|
||||
async getGroupMemberMessages(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||
): Promise<{ success: boolean; data?: GroupMemberMessagesPage; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success) return { success: false, error: conn.error }
|
||||
|
||||
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||
if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' }
|
||||
|
||||
const startTimeValue = Number.isFinite(options?.startTime) && typeof options?.startTime === 'number'
|
||||
? Math.max(0, Math.floor(options.startTime))
|
||||
: 0
|
||||
const endTimeValue = Number.isFinite(options?.endTime) && typeof options?.endTime === 'number'
|
||||
? Math.max(0, Math.floor(options.endTime))
|
||||
: 0
|
||||
const limit = Number.isFinite(options?.limit) && typeof options?.limit === 'number'
|
||||
? Math.max(1, Math.min(100, Math.floor(options.limit)))
|
||||
: 50
|
||||
let cursor = Number.isFinite(options?.cursor) && typeof options?.cursor === 'number'
|
||||
? Math.max(0, Math.floor(options.cursor))
|
||||
: 0
|
||||
|
||||
const matchedMessages: Message[] = []
|
||||
const batchSize = Math.max(limit * 2, 100)
|
||||
let hasMore = false
|
||||
|
||||
while (matchedMessages.length < limit) {
|
||||
const batch = await chatService.getMessages(
|
||||
normalizedChatroomId,
|
||||
cursor,
|
||||
batchSize,
|
||||
startTimeValue,
|
||||
endTimeValue,
|
||||
false
|
||||
)
|
||||
if (!batch.success || !batch.messages) {
|
||||
return { success: false, error: batch.error || '获取群成员消息失败' }
|
||||
}
|
||||
|
||||
const currentMessages = batch.messages
|
||||
const nextCursor = typeof batch.nextOffset === 'number'
|
||||
? Math.max(cursor, Math.floor(batch.nextOffset))
|
||||
: cursor + currentMessages.length
|
||||
|
||||
let overflowMatchFound = false
|
||||
for (const message of currentMessages) {
|
||||
if (!this.isSameAccountIdentity(normalizedMemberUsername, message.senderUsername)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (matchedMessages.length < limit) {
|
||||
matchedMessages.push(message)
|
||||
} else {
|
||||
overflowMatchFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
|
||||
if (overflowMatchFound) {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
|
||||
if (currentMessages.length === 0 || !batch.hasMore) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
if (matchedMessages.length >= limit) {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
messages: matchedMessages,
|
||||
hasMore,
|
||||
nextCursor: cursor
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
|
||||
@@ -11,6 +11,7 @@ import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { videoService } from './videoService'
|
||||
import { imageDecryptService } from './imageDecryptService'
|
||||
import { groupAnalyticsService } from './groupAnalyticsService'
|
||||
|
||||
// ChatLab 格式定义
|
||||
interface ChatLabHeader {
|
||||
@@ -238,6 +239,8 @@ class HttpService {
|
||||
await this.handleSessions(url, res)
|
||||
} else if (pathname === '/api/v1/contacts') {
|
||||
await this.handleContacts(url, res)
|
||||
} else if (pathname === '/api/v1/group-members') {
|
||||
await this.handleGroupMembers(url, res)
|
||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||
this.handleMediaRequest(pathname, res)
|
||||
} else {
|
||||
@@ -589,6 +592,54 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理群成员查询
|
||||
* GET /api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=0
|
||||
*/
|
||||
private async handleGroupMembers(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const chatroomId = (url.searchParams.get('chatroomId') || url.searchParams.get('talker') || '').trim()
|
||||
const includeMessageCounts = this.parseBooleanParam(url, ['includeMessageCounts', 'withCounts'], false)
|
||||
const forceRefresh = this.parseBooleanParam(url, ['forceRefresh'], false)
|
||||
|
||||
if (!chatroomId) {
|
||||
this.sendError(res, 400, 'Missing chatroomId')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await groupAnalyticsService.getGroupMembersPanelData(chatroomId, {
|
||||
forceRefresh,
|
||||
includeMessageCounts
|
||||
})
|
||||
if (!result.success || !result.data) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get group members')
|
||||
return
|
||||
}
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
chatroomId,
|
||||
count: result.data.length,
|
||||
fromCache: result.fromCache,
|
||||
updatedAt: result.updatedAt,
|
||||
members: result.data.map((member) => ({
|
||||
wxid: member.username,
|
||||
displayName: member.displayName,
|
||||
nickname: member.nickname || '',
|
||||
remark: member.remark || '',
|
||||
alias: member.alias || '',
|
||||
groupNickname: member.groupNickname || '',
|
||||
avatarUrl: member.avatarUrl,
|
||||
isOwner: Boolean(member.isOwner),
|
||||
isFriend: Boolean(member.isFriend),
|
||||
messageCount: Number.isFinite(member.messageCount) ? member.messageCount : 0
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
private getApiMediaExportPath(): string {
|
||||
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||
}
|
||||
@@ -886,7 +937,12 @@ class HttpService {
|
||||
|
||||
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
|
||||
if (!sender) return ''
|
||||
return groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || ''
|
||||
const cleaned = this.normalizeAccountId(sender)
|
||||
return groupNicknamesMap.get(sender)
|
||||
|| groupNicknamesMap.get(sender.toLowerCase())
|
||||
|| groupNicknamesMap.get(cleaned)
|
||||
|| groupNicknamesMap.get(cleaned.toLowerCase())
|
||||
|| ''
|
||||
}
|
||||
|
||||
private resolveChatLabSenderInfo(
|
||||
@@ -957,7 +1013,21 @@ class HttpService {
|
||||
try {
|
||||
const result = await wcdbService.getGroupNicknames(talkerId)
|
||||
if (result.success && result.nicknames) {
|
||||
groupNicknamesMap = new Map(Object.entries(result.nicknames))
|
||||
groupNicknamesMap = new Map()
|
||||
for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) {
|
||||
const memberId = String(memberIdRaw || '').trim()
|
||||
const nickname = String(nicknameRaw || '').trim()
|
||||
if (!memberId || !nickname) continue
|
||||
|
||||
groupNicknamesMap.set(memberId, nickname)
|
||||
groupNicknamesMap.set(memberId.toLowerCase(), nickname)
|
||||
|
||||
const cleaned = this.normalizeAccountId(memberId)
|
||||
if (cleaned) {
|
||||
groupNicknamesMap.set(cleaned, nickname)
|
||||
groupNicknamesMap.set(cleaned.toLowerCase(), nickname)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HttpService] Failed to get group nicknames:', e)
|
||||
|
||||
@@ -436,6 +436,10 @@ export class ImageDecryptService {
|
||||
if (imageMd5) {
|
||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
||||
if (res) return res
|
||||
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
|
||||
const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||
if (datNameRes) return datNameRes
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||
@@ -889,7 +893,8 @@ export class ImageDecryptService {
|
||||
|
||||
const now = new Date()
|
||||
const months: string[] = []
|
||||
for (let i = 0; i < 2; i++) {
|
||||
// Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months".
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
months.push(mStr)
|
||||
|
||||
@@ -116,11 +116,30 @@ export class KeyServiceMac {
|
||||
}
|
||||
}
|
||||
|
||||
private async checkSipStatus(): Promise<{ enabled: boolean; error?: string }> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
|
||||
const enabled = stdout.toLowerCase().includes('enabled')
|
||||
return { enabled }
|
||||
} catch (e: any) {
|
||||
return { enabled: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
async autoGetDbKey(
|
||||
timeoutMs = 60_000,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<DbKeyResult> {
|
||||
try {
|
||||
// 检测 SIP 状态
|
||||
const sipStatus = await this.checkSipStatus()
|
||||
if (sipStatus.enabled) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. 重启 Mac 并按住 Command + R 进入恢复模式\n2. 打开终端,输入: csrutil disable\n3. 重启电脑'
|
||||
}
|
||||
}
|
||||
|
||||
onStatus?.('正在获取数据库密钥...', 0)
|
||||
onStatus?.('正在请求管理员授权并执行 helper...', 0)
|
||||
let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string }
|
||||
@@ -488,26 +507,39 @@ export class KeyServiceMac {
|
||||
|
||||
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
|
||||
if (wxidCandidates.length === 0) {
|
||||
return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' }
|
||||
return { success: false, error: '未找到可用的账号候选,请先选择正确的账号目录' }
|
||||
}
|
||||
|
||||
const accountPathCandidates = this.collectAccountPathCandidates(accountPath)
|
||||
|
||||
// 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错
|
||||
let verifyCiphertext: Buffer | null = null
|
||||
if (accountPath && existsSync(accountPath)) {
|
||||
const template = await this._findTemplateData(accountPath, 32)
|
||||
verifyCiphertext = template.ciphertext
|
||||
}
|
||||
if (verifyCiphertext) {
|
||||
if (accountPathCandidates.length > 0) {
|
||||
onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`)
|
||||
for (const candidateWxid of wxidCandidates) {
|
||||
for (const code of codes) {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
||||
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||
return { success: true, xorKey, aesKey }
|
||||
for (const candidateAccountPath of accountPathCandidates) {
|
||||
if (!existsSync(candidateAccountPath)) continue
|
||||
const template = await this._findTemplateData(candidateAccountPath, 32)
|
||||
if (!template.ciphertext) continue
|
||||
|
||||
const accountDirWxid = basename(candidateAccountPath)
|
||||
const orderedWxids: string[] = []
|
||||
this.pushAccountIdCandidates(orderedWxids, accountDirWxid)
|
||||
for (const candidate of wxidCandidates) {
|
||||
this.pushAccountIdCandidates(orderedWxids, candidate)
|
||||
}
|
||||
|
||||
for (const candidateWxid of orderedWxids) {
|
||||
for (const code of codes) {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
|
||||
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||
return { success: true, xorKey, aesKey }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
||||
return {
|
||||
success: false,
|
||||
error: '缓存 code 与当前账号 wxid 未匹配。若数据库密钥获取后微信刚刚崩溃并重启,可能当前选中的账号目录已经不是最新会话;请先重新扫描 wxid,或直接使用内存扫描。'
|
||||
}
|
||||
}
|
||||
|
||||
// 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code)
|
||||
@@ -542,16 +574,21 @@ export class KeyServiceMac {
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
|
||||
// 2. 找微信 PID
|
||||
const pid = await this.findWeChatPid()
|
||||
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
|
||||
|
||||
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
|
||||
|
||||
// 3. 持续轮询内存扫描
|
||||
// 2. 持续轮询微信 PID 与内存扫描,兼容微信崩溃后重启 PID 变化
|
||||
const deadline = Date.now() + 60_000
|
||||
let scanCount = 0
|
||||
let lastPid: number | null = null
|
||||
while (Date.now() < deadline) {
|
||||
const pid = await this.findWeChatPid()
|
||||
if (!pid) {
|
||||
onProgress?.('暂未检测到微信主进程,请确认微信已经重新打开...')
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
continue
|
||||
}
|
||||
if (lastPid !== pid) {
|
||||
lastPid = pid
|
||||
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
|
||||
}
|
||||
scanCount++
|
||||
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
|
||||
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
||||
@@ -764,7 +801,7 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
const current = chunk.subarray(0, bytesRead)
|
||||
const data = trailing ? Buffer.concat([trailing, current]) : current
|
||||
const data: Buffer = trailing ? Buffer.concat([trailing, current]) : current
|
||||
const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext)
|
||||
if (key) return key
|
||||
// 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中)
|
||||
@@ -793,8 +830,8 @@ export class KeyServiceMac {
|
||||
}
|
||||
const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]'
|
||||
let stdout = '', stderr = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString()
|
||||
console.log(tag, chunk.toString().trim())
|
||||
})
|
||||
@@ -819,11 +856,8 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
private async findWeChatPid(): Promise<number | null> {
|
||||
const { execSync } = await import('child_process')
|
||||
try {
|
||||
const output = execSync('pgrep -x WeChat', { encoding: 'utf8' })
|
||||
const pid = parseInt(output.trim())
|
||||
return isNaN(pid) ? null : pid
|
||||
return await this.getWeChatPid()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -840,12 +874,70 @@ export class KeyServiceMac {
|
||||
this.machPortDeallocate = null
|
||||
}
|
||||
|
||||
private normalizeAccountId(value: string): string {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
private isIgnoredAccountName(value: string): boolean {
|
||||
const lowered = String(value || '').trim().toLowerCase()
|
||||
if (!lowered) return true
|
||||
return lowered === 'xwechat_files' ||
|
||||
lowered === 'all_users' ||
|
||||
lowered === 'backup' ||
|
||||
lowered === 'wmpf' ||
|
||||
lowered === 'app_data'
|
||||
}
|
||||
|
||||
private isReasonableAccountId(value: string): boolean {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return false
|
||||
if (trimmed.includes('/') || trimmed.includes('\\')) return false
|
||||
return !this.isIgnoredAccountName(trimmed)
|
||||
}
|
||||
|
||||
private isAccountDirPath(entryPath: string): boolean {
|
||||
return existsSync(join(entryPath, 'db_storage')) ||
|
||||
existsSync(join(entryPath, 'msg')) ||
|
||||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
|
||||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
|
||||
}
|
||||
|
||||
private resolveXwechatRootFromPath(accountPath?: string): string | null {
|
||||
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
if (!normalized) return null
|
||||
const marker = '/xwechat_files'
|
||||
const markerIdx = normalized.indexOf(marker)
|
||||
if (markerIdx < 0) return null
|
||||
return normalized.slice(0, markerIdx + marker.length)
|
||||
}
|
||||
|
||||
private pushAccountIdCandidates(candidates: string[], value?: string): void {
|
||||
const pushUnique = (item: string) => {
|
||||
const trimmed = String(item || '').trim()
|
||||
if (!trimmed || candidates.includes(trimmed)) return
|
||||
candidates.push(trimmed)
|
||||
}
|
||||
|
||||
const raw = String(value || '').trim()
|
||||
if (!this.isReasonableAccountId(raw)) return
|
||||
pushUnique(raw)
|
||||
const normalized = this.normalizeAccountId(raw)
|
||||
if (normalized && normalized !== raw && this.isReasonableAccountId(normalized)) {
|
||||
pushUnique(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
private cleanWxid(wxid: string): string {
|
||||
const first = wxid.indexOf('_')
|
||||
if (first === -1) return wxid
|
||||
const second = wxid.indexOf('_', first + 1)
|
||||
if (second === -1) return wxid
|
||||
return wxid.substring(0, second)
|
||||
return this.normalizeAccountId(wxid)
|
||||
}
|
||||
|
||||
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||
@@ -858,32 +950,59 @@ export class KeyServiceMac {
|
||||
|
||||
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const pushUnique = (value: string) => {
|
||||
const v = String(value || '').trim()
|
||||
if (!v || candidates.includes(v)) return
|
||||
candidates.push(v)
|
||||
}
|
||||
|
||||
// 1) 显式传参优先
|
||||
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
|
||||
this.pushAccountIdCandidates(candidates, wxidParam)
|
||||
|
||||
if (accountPath) {
|
||||
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const dirName = basename(normalized)
|
||||
// 2) 当前目录名为 wxid_*
|
||||
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
||||
// 2) 当前目录名本身就是账号目录
|
||||
this.pushAccountIdCandidates(candidates, dirName)
|
||||
|
||||
// 3) 从 xwechat_files 根目录枚举全部 wxid_* 目录
|
||||
const marker = '/xwechat_files'
|
||||
const markerIdx = normalized.indexOf(marker)
|
||||
if (markerIdx >= 0) {
|
||||
const root = normalized.slice(0, markerIdx + marker.length)
|
||||
// 3) 从 xwechat_files 根目录枚举全部账号目录
|
||||
const root = this.resolveXwechatRootFromPath(accountPath)
|
||||
if (root) {
|
||||
if (existsSync(root)) {
|
||||
try {
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (!entry.name.startsWith('wxid_')) continue
|
||||
pushUnique(entry.name)
|
||||
const entryPath = join(root, entry.name)
|
||||
if (!this.isAccountDirPath(entryPath)) continue
|
||||
this.pushAccountIdCandidates(candidates, entry.name)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) candidates.push('unknown')
|
||||
return candidates
|
||||
}
|
||||
|
||||
private collectAccountPathCandidates(accountPath?: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const pushUnique = (value?: string) => {
|
||||
const v = String(value || '').trim()
|
||||
if (!v || candidates.includes(v)) return
|
||||
candidates.push(v)
|
||||
}
|
||||
|
||||
if (accountPath) pushUnique(accountPath)
|
||||
|
||||
if (accountPath) {
|
||||
const root = this.resolveXwechatRootFromPath(accountPath)
|
||||
if (root) {
|
||||
if (existsSync(root)) {
|
||||
try {
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const entryPath = join(root, entry.name)
|
||||
if (!this.isAccountDirPath(entryPath)) continue
|
||||
if (!this.isReasonableAccountId(entry.name)) continue
|
||||
pushUnique(entryPath)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -892,7 +1011,6 @@ export class KeyServiceMac {
|
||||
}
|
||||
}
|
||||
|
||||
pushUnique('unknown')
|
||||
return candidates
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export class WcdbCore {
|
||||
private wcdbGetEmoticonCdnUrl: any = null
|
||||
private wcdbGetDbStatus: any = null
|
||||
private wcdbGetVoiceData: any = null
|
||||
private wcdbSearchMessages: any = null
|
||||
private wcdbGetSnsTimeline: any = null
|
||||
private wcdbGetSnsAnnualStats: any = null
|
||||
private wcdbInstallSnsBlockDeleteTrigger: any = null
|
||||
@@ -817,6 +818,13 @@ export class WcdbCore {
|
||||
this.wcdbGetVoiceData = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_search_messages(wcdb_handle handle, const char* session_id, const char* keyword, int32_t limit, int32_t offset, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||
try {
|
||||
this.wcdbSearchMessages = this.lib.func('int32 wcdb_search_messages(int64 handle, const char* sessionId, const char* keyword, int32 limit, int32 offset, int32 beginTimestamp, int32 endTimestamp, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbSearchMessages = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
|
||||
try {
|
||||
this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)')
|
||||
@@ -1488,10 +1496,19 @@ export class WcdbCore {
|
||||
}
|
||||
|
||||
// 让出控制权,避免阻塞事件循环
|
||||
const handle = this.handle
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
// await 后 handle 可能已被关闭,需重新检查
|
||||
if (handle === null || this.handle !== handle) {
|
||||
if (Object.keys(resultMap).length > 0) {
|
||||
return { success: true, map: resultMap, error: '连接已断开' }
|
||||
}
|
||||
return { success: false, error: '连接已断开' }
|
||||
}
|
||||
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr)
|
||||
const result = this.wcdbGetAvatarUrls(handle, JSON.stringify(toFetch), outPtr)
|
||||
|
||||
// DLL 调用后再次让出控制权
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
@@ -2270,6 +2287,36 @@ export class WcdbCore {
|
||||
})
|
||||
}
|
||||
|
||||
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbSearchMessages) return { success: false, error: '当前 DLL 版本不支持搜索消息' }
|
||||
try {
|
||||
const handle = this.handle
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
if (handle === null || this.handle !== handle) return { success: false, error: '连接已断开' }
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbSearchMessages(
|
||||
handle,
|
||||
sessionId || '',
|
||||
keyword,
|
||||
limit || 50,
|
||||
offset || 0,
|
||||
beginTimestamp || 0,
|
||||
endTimestamp || 0,
|
||||
outPtr
|
||||
)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `搜索消息失败: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析搜索结果失败' }
|
||||
const messages = JSON.parse(jsonStr)
|
||||
return { success: true, messages }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
||||
|
||||
@@ -406,6 +406,10 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageById', { sessionId, localId })
|
||||
}
|
||||
|
||||
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||
return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音数据
|
||||
*/
|
||||
|
||||
@@ -140,6 +140,9 @@ if (parentPort) {
|
||||
case 'getMessageById':
|
||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||
break
|
||||
case 'searchMessages':
|
||||
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getVoiceData':
|
||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||
if (!result.success) {
|
||||
|
||||
@@ -132,7 +132,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
||||
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||
const winWidth = 344
|
||||
const winWidth = position === 'top-center' ? 280 : 344
|
||||
const winHeight = 114
|
||||
const padding = 20
|
||||
|
||||
@@ -140,6 +140,10 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
||||
let y = 0
|
||||
|
||||
switch (position) {
|
||||
case 'top-center':
|
||||
x = (screenWidth - winWidth) / 2
|
||||
y = padding
|
||||
break
|
||||
case 'top-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = padding
|
||||
@@ -166,7 +170,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
||||
win.showInactive() // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||
|
||||
win.webContents.send('notification:show', data)
|
||||
win.webContents.send('notification:show', { ...data, position })
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
+39
@@ -37,6 +37,7 @@ import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||
|
||||
function RouteStateRedirect({ to }: { to: string }) {
|
||||
const location = useLocation()
|
||||
@@ -85,6 +86,8 @@ function App() {
|
||||
const isExportRoute = routeLocation.pathname === '/export'
|
||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false)
|
||||
const [canMinimizeToTray, setCanMinimizeToTray] = useState(false)
|
||||
|
||||
// 锁定状态
|
||||
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||
@@ -107,6 +110,15 @@ function App() {
|
||||
}
|
||||
}, [location])
|
||||
|
||||
useEffect(() => {
|
||||
const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => {
|
||||
setCanMinimizeToTray(Boolean(payload.canMinimizeToTray))
|
||||
setShowCloseDialog(true)
|
||||
})
|
||||
|
||||
return () => removeCloseConfirmListener()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const body = document.body
|
||||
@@ -315,6 +327,26 @@ function App() {
|
||||
setUpdateInfo(null)
|
||||
}
|
||||
|
||||
const handleWindowCloseAction = async (
|
||||
action: 'tray' | 'quit' | 'cancel',
|
||||
rememberChoice = false
|
||||
) => {
|
||||
setShowCloseDialog(false)
|
||||
if (rememberChoice && action !== 'cancel') {
|
||||
try {
|
||||
await configService.setWindowCloseBehavior(action)
|
||||
} catch (error) {
|
||||
console.error('保存关闭偏好失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI.window.respondCloseConfirm(action)
|
||||
} catch (error) {
|
||||
console.error('处理关闭确认失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动时自动检查配置并连接数据库
|
||||
useEffect(() => {
|
||||
if (isAgreementWindow || isOnboardingWindow) return
|
||||
@@ -593,6 +625,13 @@ function App() {
|
||||
progress={downloadProgress}
|
||||
/>
|
||||
|
||||
<WindowCloseDialog
|
||||
open={showCloseDialog}
|
||||
canMinimizeToTray={canMinimizeToTray}
|
||||
onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
|
||||
onCancel={() => handleWindowCloseAction('cancel')}
|
||||
/>
|
||||
|
||||
<div className="main-layout">
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<main className="content">
|
||||
|
||||
@@ -50,6 +50,21 @@
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.avatar-loading {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary, #999);
|
||||
background-color: var(--bg-tertiary, #e0e0e0);
|
||||
border-radius: inherit;
|
||||
|
||||
.avatar-loading-icon {
|
||||
animation: avatar-spin 0.9s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading Skeleton */
|
||||
.avatar-skeleton {
|
||||
position: absolute;
|
||||
@@ -76,4 +91,14 @@
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes avatar-spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-10
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { User } from 'lucide-react'
|
||||
import { Loader2, User } from 'lucide-react'
|
||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||
import './Avatar.scss'
|
||||
|
||||
@@ -13,6 +13,7 @@ interface AvatarProps {
|
||||
shape?: 'circle' | 'square' | 'rounded'
|
||||
className?: string
|
||||
lazy?: boolean
|
||||
loading?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
@@ -23,12 +24,14 @@ export const Avatar = React.memo(function Avatar({
|
||||
shape = 'rounded',
|
||||
className = '',
|
||||
lazy = true,
|
||||
loading = false,
|
||||
onClick
|
||||
}: AvatarProps) {
|
||||
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
||||
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
||||
const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src])
|
||||
const [imageLoaded, setImageLoaded] = useState(isCached)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [imageError, setImageError] = useState(isFailed)
|
||||
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
||||
const [isInQueue, setIsInQueue] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
@@ -42,7 +45,7 @@ export const Avatar = React.memo(function Avatar({
|
||||
|
||||
// Intersection Observer for lazy loading
|
||||
useEffect(() => {
|
||||
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return
|
||||
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached || imageError || isFailed) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@@ -50,10 +53,11 @@ export const Avatar = React.memo(function Avatar({
|
||||
if (entry.isIntersecting && !isInQueue) {
|
||||
setIsInQueue(true)
|
||||
avatarLoadQueue.enqueue(src).then(() => {
|
||||
setImageError(false)
|
||||
setShouldLoad(true)
|
||||
}).catch(() => {
|
||||
// 加载失败不要立刻显示错误,让浏览器渲染去报错
|
||||
setShouldLoad(true)
|
||||
setImageError(true)
|
||||
setShouldLoad(false)
|
||||
}).finally(() => {
|
||||
setIsInQueue(false)
|
||||
})
|
||||
@@ -67,14 +71,18 @@ export const Avatar = React.memo(function Avatar({
|
||||
observer.observe(containerRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [src, lazy, shouldLoad, isInQueue, isCached])
|
||||
}, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed])
|
||||
|
||||
// Reset state when src changes
|
||||
useEffect(() => {
|
||||
const cached = src ? loadedAvatarCache.has(src) : false
|
||||
const failed = src ? avatarLoadQueue.hasFailed(src) : false
|
||||
setImageLoaded(cached)
|
||||
setImageError(false)
|
||||
if (lazy && !cached) {
|
||||
setImageError(failed)
|
||||
if (failed) {
|
||||
setShouldLoad(false)
|
||||
setIsInQueue(false)
|
||||
} else if (lazy && !cached) {
|
||||
setShouldLoad(false)
|
||||
setIsInQueue(false)
|
||||
} else {
|
||||
@@ -95,6 +103,7 @@ export const Avatar = React.memo(function Avatar({
|
||||
}
|
||||
|
||||
const hasValidUrl = !!src && !imageError && shouldLoad
|
||||
const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -112,13 +121,30 @@ export const Avatar = React.memo(function Avatar({
|
||||
alt={name || 'avatar'}
|
||||
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
||||
onLoad={() => {
|
||||
if (src) loadedAvatarCache.add(src)
|
||||
if (src) {
|
||||
avatarLoadQueue.clearFailed(src)
|
||||
loadedAvatarCache.add(src)
|
||||
}
|
||||
setImageLoaded(true)
|
||||
setImageError(false)
|
||||
}}
|
||||
onError={() => {
|
||||
if (src) {
|
||||
avatarLoadQueue.markFailed(src)
|
||||
loadedAvatarCache.delete(src)
|
||||
}
|
||||
setImageLoaded(false)
|
||||
setImageError(true)
|
||||
setShouldLoad(false)
|
||||
}}
|
||||
onError={() => setImageError(true)}
|
||||
loading={lazy ? "lazy" : "eager"}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</>
|
||||
) : shouldShowLoadingPlaceholder ? (
|
||||
<div className="avatar-loading">
|
||||
<Loader2 size="50%" className="avatar-loading-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import type { ChatSession } from '../types/models'
|
||||
import type { ChatSession, Message } from '../types/models'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export function GlobalSessionMonitor() {
|
||||
@@ -20,9 +20,9 @@ export function GlobalSessionMonitor() {
|
||||
}, [sessions])
|
||||
|
||||
// 去重辅助函数:获取消息 key
|
||||
const getMessageKey = (msg: any) => {
|
||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||
const getMessageKey = (msg: Message) => {
|
||||
if (msg.messageKey) return msg.messageKey
|
||||
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||
}
|
||||
|
||||
// 处理数据库变更
|
||||
@@ -267,7 +267,12 @@ export function GlobalSessionMonitor() {
|
||||
try {
|
||||
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
||||
if (result.success && result.messages && result.messages.length > 0) {
|
||||
appendMessages(result.messages, false) // 追加到末尾
|
||||
const latestMessages = useChatStore.getState().messages || []
|
||||
const existingKeys = new Set(latestMessages.map(getMessageKey))
|
||||
const newMessages = result.messages.filter((msg: Message) => !existingKeys.has(getMessageKey(msg)))
|
||||
if (newMessages.length > 0) {
|
||||
appendMessages(newMessages, false)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('后台活跃会话刷新失败:', e)
|
||||
|
||||
@@ -134,6 +134,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.top-center {
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
|
||||
// 灵动岛样式
|
||||
border-radius: 40px !important;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&.static {
|
||||
border-radius: 40px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface NotificationToastProps {
|
||||
onClose: () => void
|
||||
onClick: (sessionId: string) => void
|
||||
duration?: number
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
isStatic?: boolean
|
||||
initialVisible?: boolean
|
||||
}
|
||||
|
||||
+27
-17
@@ -6,6 +6,7 @@ import { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import * as configService from '../services/config'
|
||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||
import { UserRound } from 'lucide-react'
|
||||
|
||||
import './Sidebar.scss'
|
||||
|
||||
@@ -35,6 +36,7 @@ interface AccountProfilesCache {
|
||||
interface WxidOption {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
nickname?: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
@@ -280,26 +282,28 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
const accountsCache = readAccountProfilesCache()
|
||||
console.log('[切换账号] 账号缓存:', accountsCache)
|
||||
|
||||
const enrichedWxids = wxids.map(option => {
|
||||
const enrichedWxids = wxids.map((option: WxidOption) => {
|
||||
const normalizedWxid = normalizeAccountId(option.wxid)
|
||||
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
|
||||
|
||||
let displayName = option.nickname || option.wxid
|
||||
let avatarUrl = option.avatarUrl
|
||||
|
||||
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
|
||||
return {
|
||||
...option,
|
||||
displayName: userProfile.displayName,
|
||||
avatarUrl: userProfile.avatarUrl
|
||||
}
|
||||
displayName = userProfile.displayName || displayName
|
||||
avatarUrl = userProfile.avatarUrl || avatarUrl
|
||||
}
|
||||
if (cached) {
|
||||
console.log('[切换账号] 使用缓存:', option.wxid, cached)
|
||||
return {
|
||||
...option,
|
||||
displayName: cached.displayName,
|
||||
avatarUrl: cached.avatarUrl
|
||||
}
|
||||
|
||||
else if (cached) {
|
||||
displayName = cached.displayName || displayName
|
||||
avatarUrl = cached.avatarUrl || avatarUrl
|
||||
}
|
||||
|
||||
return {
|
||||
...option,
|
||||
displayName,
|
||||
avatarUrl
|
||||
}
|
||||
return { ...option, displayName: option.wxid }
|
||||
})
|
||||
|
||||
setWxidOptions(enrichedWxids)
|
||||
@@ -553,11 +557,17 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
type="button"
|
||||
>
|
||||
<div className="wxid-avatar">
|
||||
{option.avatarUrl ? <img src={option.avatarUrl} alt="" /> : <span>{getAvatarLetter(option.displayName || option.wxid)}</span>}
|
||||
{option.avatarUrl ? (
|
||||
<img src={option.avatarUrl} alt="" />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-tertiary)', borderRadius: '6px', color: 'var(--text-tertiary)' }}>
|
||||
<UserRound size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="wxid-info">
|
||||
<div className="wxid-name">{option.displayName || option.wxid}</div>
|
||||
<div className="wxid-id">{option.wxid}</div>
|
||||
<div className="wxid-name">{option.displayName}</div>
|
||||
{option.displayName !== option.wxid && <div className="wxid-id">{option.wxid}</div>}
|
||||
</div>
|
||||
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
.window-close-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(36, 42, 54, 0.18), transparent 48%),
|
||||
rgba(7, 10, 18, 0.56);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 3000;
|
||||
animation: windowCloseDialogFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.window-close-dialog {
|
||||
width: min(560px, 100%);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 94%, white 6%) 0%, var(--bg-primary) 100%);
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.window-close-dialog-header {
|
||||
padding: 28px 30px 18px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
|
||||
.window-close-dialog-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 14px 0 8px;
|
||||
font-size: 26px;
|
||||
line-height: 1.1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-body {
|
||||
padding: 20px 24px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.window-close-dialog-option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 18px 18px 18px 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 86%, white 14%) 0%, var(--bg-secondary) 100%);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
background 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
|
||||
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.is-danger:hover {
|
||||
border-color: rgba(205, 73, 73, 0.42);
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-option-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--primary) 14%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.window-close-dialog-option.is-danger .window-close-dialog-option-icon {
|
||||
background: rgba(205, 73, 73, 0.12);
|
||||
color: #cd4949;
|
||||
}
|
||||
|
||||
.window-close-dialog-option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-actions {
|
||||
padding: 8px 24px 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.window-close-dialog-remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 24px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 76%, transparent);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
border: 1.5px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg) scale(0.7);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent);
|
||||
}
|
||||
|
||||
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox::after {
|
||||
opacity: 1;
|
||||
transform: rotate(45deg) scale(1);
|
||||
}
|
||||
|
||||
.window-close-dialog-remember-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.window-close-dialog-cancel {
|
||||
min-width: 112px;
|
||||
padding: 12px 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
border-color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 24%, var(--border-color));
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-close {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 84%, transparent);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.window-close-dialog-overlay {
|
||||
padding: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.window-close-dialog {
|
||||
border-radius: 24px 24px 18px 18px;
|
||||
}
|
||||
|
||||
.window-close-dialog-header {
|
||||
padding: 24px 22px 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-body {
|
||||
padding: 18px 18px 10px;
|
||||
}
|
||||
|
||||
.window-close-dialog-actions {
|
||||
padding: 8px 18px 18px;
|
||||
}
|
||||
|
||||
.window-close-dialog-cancel {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes windowCloseDialogFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes windowCloseDialogSlideUp {
|
||||
from {
|
||||
transform: translateY(24px) scale(0.98);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Minimize2, Power, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import './WindowCloseDialog.scss'
|
||||
|
||||
interface WindowCloseDialogProps {
|
||||
open: boolean
|
||||
canMinimizeToTray: boolean
|
||||
onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function WindowCloseDialog({
|
||||
open,
|
||||
canMinimizeToTray,
|
||||
onSelect,
|
||||
onCancel
|
||||
}: WindowCloseDialogProps) {
|
||||
const [rememberChoice, setRememberChoice] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setRememberChoice(false)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, onCancel])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="window-close-dialog-overlay" onClick={onCancel}>
|
||||
<div
|
||||
className="window-close-dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="window-close-dialog-title"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="window-close-dialog-close"
|
||||
onClick={onCancel}
|
||||
aria-label="关闭提示"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="window-close-dialog-header">
|
||||
<span className="window-close-dialog-kicker">退出行为</span>
|
||||
<h2 id="window-close-dialog-title">关闭 WeFlow</h2>
|
||||
<p>
|
||||
{canMinimizeToTray
|
||||
? '你可以保留后台进程与本地 API,或者直接完全退出应用。'
|
||||
: '当前系统托盘不可用,本次只能完全退出应用。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="window-close-dialog-body">
|
||||
{canMinimizeToTray && (
|
||||
<button
|
||||
type="button"
|
||||
className="window-close-dialog-option"
|
||||
onClick={() => onSelect('tray', rememberChoice)}
|
||||
>
|
||||
<span className="window-close-dialog-option-icon">
|
||||
<Minimize2 size={18} />
|
||||
</span>
|
||||
<span className="window-close-dialog-option-text">
|
||||
<strong>最小化到系统托盘</strong>
|
||||
<span>继续保留后台进程和本地 API,稍后可从托盘恢复。</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="window-close-dialog-option is-danger"
|
||||
onClick={() => onSelect('quit', rememberChoice)}
|
||||
>
|
||||
<span className="window-close-dialog-option-icon">
|
||||
<Power size={18} />
|
||||
</span>
|
||||
<span className="window-close-dialog-option-text">
|
||||
<strong>完全关闭</strong>
|
||||
<span>结束 WeFlow 进程,并停止当前保留的本地 API。</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="window-close-dialog-remember">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberChoice}
|
||||
onChange={(event) => setRememberChoice(event.target.checked)}
|
||||
/>
|
||||
<span className="window-close-dialog-checkbox" aria-hidden="true" />
|
||||
<span className="window-close-dialog-remember-text">下次不再提示,直接按本次选择处理</span>
|
||||
</label>
|
||||
|
||||
<div className="window-close-dialog-actions">
|
||||
<button type="button" className="window-close-dialog-cancel" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+255
-4
@@ -1129,8 +1129,12 @@
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
|
||||
.highlight {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
@@ -2761,7 +2765,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
@@ -3045,13 +3049,15 @@
|
||||
}
|
||||
|
||||
.member-flag {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 9999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.owner {
|
||||
color: #f59e0b;
|
||||
@@ -4538,7 +4544,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
@@ -4630,3 +4636,248 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 会话内搜索栏
|
||||
// 会话内搜索浮窗
|
||||
.in-session-search-popup {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 16px;
|
||||
width: 360px;
|
||||
max-height: 500px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.in-session-search-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
.search-icon {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
&::placeholder { color: var(--text-tertiary); }
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-result-header {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.in-session-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: background 0.15s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.result-header {
|
||||
flex-shrink: 0;
|
||||
|
||||
.result-info {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
.result-sender {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.result-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-tertiary);
|
||||
gap: 12px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索分类标题
|
||||
.search-section-header {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 全局消息搜索结果面板
|
||||
.global-msg-search-results {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.search-loading,
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-results-list {
|
||||
.session-item {
|
||||
display: flex;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
gap: 12px;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.session-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.session-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.session-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.session-preview {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.highlight {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.search-count {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.msg-search-toggle-btn.active {
|
||||
color: var(--accent-color, #07c160);
|
||||
}
|
||||
.in-session-search-btn.active {
|
||||
color: var(--accent-color, #07c160);
|
||||
}
|
||||
+1193
-87
@@ -34,6 +34,122 @@ const SYSTEM_MESSAGE_TYPES = [
|
||||
266287972401, // 拍一拍
|
||||
]
|
||||
|
||||
interface PendingInSessionSearchPayload {
|
||||
sessionId: string
|
||||
keyword: string
|
||||
firstMsgTime: number
|
||||
results: Message[]
|
||||
}
|
||||
|
||||
function sortMessagesByCreateTimeDesc<T extends Pick<Message, 'createTime' | 'localId'>>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
|
||||
if (timeDiff !== 0) return timeDiff
|
||||
return (b.localId || 0) - (a.localId || 0)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeSearchIdentityText(value?: string | null): string | undefined {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return undefined
|
||||
const lower = normalized.toLowerCase()
|
||||
if (normalized === '未知' || lower === 'unknown' || lower === 'null' || lower === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
if (lower.startsWith('unknown_sender_')) {
|
||||
return undefined
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeSearchAvatarUrl(value?: string | null): string | undefined {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return undefined
|
||||
const lower = normalized.toLowerCase()
|
||||
if (lower === 'null' || lower === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isWxidLikeSearchIdentity(value?: string | null): boolean {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
if (normalized.startsWith('wxid_')) return true
|
||||
const suffixMatch = normalized.match(/^(.+)_([a-z0-9]{4})$/i)
|
||||
return Boolean(suffixMatch && suffixMatch[1].startsWith('wxid_'))
|
||||
}
|
||||
|
||||
function resolveSearchSenderDisplayName(
|
||||
displayName?: string | null,
|
||||
senderUsername?: string | null,
|
||||
sessionId?: string | null
|
||||
): string | undefined {
|
||||
const normalizedDisplayName = normalizeSearchIdentityText(displayName)
|
||||
if (!normalizedDisplayName) return undefined
|
||||
|
||||
const normalizedSenderUsername = normalizeSearchIdentityText(senderUsername)
|
||||
const normalizedSessionId = normalizeSearchIdentityText(sessionId)
|
||||
|
||||
if (normalizedSessionId && normalizedDisplayName === normalizedSessionId) {
|
||||
return undefined
|
||||
}
|
||||
if (isWxidLikeSearchIdentity(normalizedDisplayName)) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
normalizedSenderUsername &&
|
||||
normalizedDisplayName === normalizedSenderUsername &&
|
||||
isWxidLikeSearchIdentity(normalizedSenderUsername)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return normalizedDisplayName
|
||||
}
|
||||
|
||||
function resolveSearchSenderUsernameFallback(value?: string | null): string | undefined {
|
||||
const normalized = normalizeSearchIdentityText(value)
|
||||
if (!normalized || isWxidLikeSearchIdentity(normalized)) {
|
||||
return undefined
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function buildSearchIdentityCandidates(value?: string | null): string[] {
|
||||
const normalized = normalizeSearchIdentityText(value)
|
||||
if (!normalized) return []
|
||||
const lower = normalized.toLowerCase()
|
||||
const candidates = new Set<string>([lower])
|
||||
if (lower.startsWith('wxid_')) {
|
||||
const match = lower.match(/^(wxid_[^_]+)/i)
|
||||
if (match?.[1]) {
|
||||
candidates.add(match[1])
|
||||
}
|
||||
}
|
||||
return [...candidates]
|
||||
}
|
||||
|
||||
function isCurrentUserSearchIdentity(
|
||||
senderUsername?: string | null,
|
||||
myWxid?: string | null
|
||||
): boolean {
|
||||
const senderCandidates = buildSearchIdentityCandidates(senderUsername)
|
||||
const selfCandidates = buildSearchIdentityCandidates(myWxid)
|
||||
if (senderCandidates.length === 0 || selfCandidates.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const sender of senderCandidates) {
|
||||
for (const self of selfCandidates) {
|
||||
if (sender === self) return true
|
||||
if (sender.startsWith(self + '_')) return true
|
||||
if (self.startsWith(sender + '_')) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
interface XmlField {
|
||||
key: string;
|
||||
value: string;
|
||||
@@ -327,23 +443,99 @@ interface LoadMessagesOptions {
|
||||
switchRequestSeq?: number
|
||||
}
|
||||
|
||||
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
||||
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
|
||||
// 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染)
|
||||
// 高亮搜索关键词组件
|
||||
const HighlightText = React.memo(({ text, keyword }: { text: string; keyword: string }) => {
|
||||
if (!keyword) return <>{text}</>
|
||||
|
||||
const lowerText = text.toLowerCase()
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
const matchIndex = lowerText.indexOf(lowerKeyword)
|
||||
|
||||
if (matchIndex === -1) return <>{text}</>
|
||||
|
||||
// 如果匹配位置在后面且文本过长,截断前面部分
|
||||
const maxLength = 50
|
||||
let displayText = text
|
||||
|
||||
if (text.length > maxLength && matchIndex > 20) {
|
||||
const start = Math.max(0, matchIndex - 15)
|
||||
displayText = '...' + text.slice(start)
|
||||
}
|
||||
|
||||
const parts = displayText.split(new RegExp(`(${keyword})`, 'gi'))
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === lowerKeyword ?
|
||||
<span key={i} className="highlight">{part}</span> : part
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const HighlightTextNoTruncate = React.memo(({ text, keyword }: { text: string; keyword: string }) => {
|
||||
if (!keyword) return <>{text}</>
|
||||
|
||||
const lowerText = text.toLowerCase()
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
const matchIndex = lowerText.indexOf(lowerKeyword)
|
||||
|
||||
if (matchIndex === -1) return <>{text}</>
|
||||
|
||||
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const matchEnd = matchIndex + keyword.length
|
||||
const maxDisplayLength = 25
|
||||
|
||||
// 如果匹配位置不在开头,或文本过长,则居中显示
|
||||
if (matchIndex > 5 || text.length > maxDisplayLength) {
|
||||
const start = Math.max(0, matchIndex - 8)
|
||||
const end = Math.min(text.length, matchEnd + 15)
|
||||
const prefix = start > 0 ? '...' : ''
|
||||
const suffix = end < text.length ? '...' : ''
|
||||
const middleText = text.slice(start, end)
|
||||
|
||||
const parts = middleText.split(new RegExp(`(${escapedKeyword})`, 'gi'))
|
||||
return (
|
||||
<>
|
||||
{prefix}
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === lowerKeyword ?
|
||||
<span key={i} className="highlight">{part}</span> : part
|
||||
)}
|
||||
{suffix}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const parts = text.split(new RegExp(`(${escapedKeyword})`, 'gi'))
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === lowerKeyword ?
|
||||
<span key={i} className="highlight">{part}</span> : part
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
// 会话项组件(使用 memo 优化,避免不必要的重渲染)
|
||||
const SessionItem = React.memo(function SessionItem({
|
||||
session,
|
||||
isActive,
|
||||
onSelect,
|
||||
formatTime
|
||||
formatTime,
|
||||
searchKeyword
|
||||
}: {
|
||||
session: ChatSession
|
||||
isActive: boolean
|
||||
onSelect: (session: ChatSession) => void
|
||||
formatTime: (timestamp: number) => string
|
||||
searchKeyword?: string
|
||||
}) {
|
||||
const timeText = useMemo(() =>
|
||||
formatTime(session.lastTimestamp || session.sortTimestamp),
|
||||
@@ -375,6 +567,16 @@ const SessionItem = React.memo(function SessionItem({
|
||||
)
|
||||
}
|
||||
|
||||
// 根据匹配字段显示不同的 summary
|
||||
const summaryContent = useMemo(() => {
|
||||
if (session.matchedField === 'wxid') {
|
||||
return <span className="session-summary">wxid:<HighlightTextNoTruncate text={session.username} keyword={searchKeyword || ''} /></span>
|
||||
} else if (session.matchedField === 'alias' && session.alias) {
|
||||
return <span className="session-summary">微信号:<HighlightTextNoTruncate text={session.alias} keyword={searchKeyword || ''} /></span>
|
||||
}
|
||||
return <span className="session-summary">{session.summary || '暂无消息'}</span>
|
||||
}, [session.matchedField, session.username, session.alias, session.summary, searchKeyword])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`session-item ${isActive ? 'active' : ''} ${session.isMuted ? 'muted' : ''}`}
|
||||
@@ -388,11 +590,23 @@ const SessionItem = React.memo(function SessionItem({
|
||||
/>
|
||||
<div className="session-info">
|
||||
<div className="session-top">
|
||||
<span className="session-name">{session.displayName || session.username}</span>
|
||||
<span className="session-name">
|
||||
{(() => {
|
||||
const shouldHighlight = (session.matchedField as any) === 'name' && searchKeyword
|
||||
if (shouldHighlight) {
|
||||
console.log('高亮名字:', session.displayName, 'keyword:', searchKeyword)
|
||||
}
|
||||
return shouldHighlight ? (
|
||||
<HighlightText text={session.displayName || session.username} keyword={searchKeyword} />
|
||||
) : (
|
||||
session.displayName || session.username
|
||||
)
|
||||
})()}
|
||||
</span>
|
||||
<span className="session-time">{timeText}</span>
|
||||
</div>
|
||||
<div className="session-bottom">
|
||||
<span className="session-summary">{session.summary || '暂无消息'}</span>
|
||||
{summaryContent}
|
||||
<div className="session-badges">
|
||||
{session.isMuted && <BellOff size={12} className="mute-icon" />}
|
||||
{session.unreadCount > 0 && (
|
||||
@@ -411,11 +625,14 @@ const SessionItem = React.memo(function SessionItem({
|
||||
prevProps.session.displayName === nextProps.session.displayName &&
|
||||
prevProps.session.avatarUrl === nextProps.session.avatarUrl &&
|
||||
prevProps.session.summary === nextProps.session.summary &&
|
||||
prevProps.session.matchedField === nextProps.session.matchedField &&
|
||||
prevProps.session.alias === nextProps.session.alias &&
|
||||
prevProps.session.unreadCount === nextProps.session.unreadCount &&
|
||||
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
|
||||
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
|
||||
prevProps.session.isMuted === nextProps.session.isMuted &&
|
||||
prevProps.isActive === nextProps.isActive
|
||||
prevProps.isActive === nextProps.isActive &&
|
||||
prevProps.searchKeyword === nextProps.searchKeyword
|
||||
)
|
||||
})
|
||||
|
||||
@@ -473,8 +690,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const getMessageKey = useCallback((msg: Message): string => {
|
||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||
if (msg.messageKey) return msg.messageKey
|
||||
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||
}, [])
|
||||
const initialRevealTimerRef = useRef<number | null>(null)
|
||||
const sessionListRef = useRef<HTMLDivElement>(null)
|
||||
@@ -539,7 +756,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
|
||||
// 多选模式
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
||||
const [selectedMessages, setSelectedMessages] = useState<Set<number>>(new Set())
|
||||
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
|
||||
|
||||
// 编辑消息额外状态
|
||||
const [editMode, setEditMode] = useState<'raw' | 'fields'>('raw')
|
||||
@@ -564,6 +781,22 @@ function ChatPage(props: ChatPageProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0 })
|
||||
const [cancelDeleteRequested, setCancelDeleteRequested] = useState(false)
|
||||
// 会话内搜索
|
||||
const [showInSessionSearch, setShowInSessionSearch] = useState(false)
|
||||
const [inSessionQuery, setInSessionQuery] = useState('')
|
||||
const [inSessionResults, setInSessionResults] = useState<Message[]>([])
|
||||
const [inSessionSearching, setInSessionSearching] = useState(false)
|
||||
const [inSessionEnriching, setInSessionEnriching] = useState(false)
|
||||
const [inSessionSearchError, setInSessionSearchError] = useState<string | null>(null)
|
||||
const inSessionSearchRef = useRef<HTMLInputElement>(null)
|
||||
// 全局消息搜索
|
||||
const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false)
|
||||
const [globalMsgQuery, setGlobalMsgQuery] = useState('')
|
||||
const [globalMsgResults, setGlobalMsgResults] = useState<Array<Message & { sessionId: string }>>([])
|
||||
const [globalMsgSearching, setGlobalMsgSearching] = useState(false)
|
||||
const [globalMsgSearchError, setGlobalMsgSearchError] = useState<string | null>(null)
|
||||
const pendingInSessionSearchRef = useRef<PendingInSessionSearchPayload | null>(null)
|
||||
const pendingGlobalMsgSearchReplayRef = useRef<string | null>(null)
|
||||
|
||||
// 自定义删除确认对话框
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
@@ -2063,7 +2296,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
|
||||
// 联系人信息更新队列(防抖批量更新,避免频繁重渲染)
|
||||
const contactUpdateQueueRef = useRef<Map<string, { displayName?: string; avatarUrl?: string }>>(new Map())
|
||||
const contactUpdateQueueRef = useRef<Map<string, { displayName?: string; avatarUrl?: string; alias?: string }>>(new Map())
|
||||
const contactUpdateTimerRef = useRef<number | null>(null)
|
||||
const lastUpdateTimeRef = useRef(0)
|
||||
|
||||
@@ -2097,12 +2330,14 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (update) {
|
||||
const newDisplayName = update.displayName || session.displayName || session.username
|
||||
const newAvatarUrl = update.avatarUrl || session.avatarUrl
|
||||
if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl) {
|
||||
const newAlias = update.alias || session.alias
|
||||
if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl || newAlias !== session.alias) {
|
||||
hasChanges = true
|
||||
return {
|
||||
...session,
|
||||
displayName: newDisplayName,
|
||||
avatarUrl: newAvatarUrl
|
||||
avatarUrl: newAvatarUrl,
|
||||
alias: newAlias
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2134,7 +2369,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
const dllStart = performance.now()
|
||||
const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) as {
|
||||
success: boolean
|
||||
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||
contacts?: Record<string, { displayName?: string; avatarUrl?: string; alias?: string }>
|
||||
error?: string
|
||||
}
|
||||
const dllTime = performance.now() - dllStart
|
||||
@@ -2357,6 +2592,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
hasMore?: boolean;
|
||||
nextOffset?: number;
|
||||
error?: string
|
||||
}
|
||||
if (options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current) {
|
||||
@@ -2433,7 +2669,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
setCurrentOffset(offset + result.messages.length)
|
||||
const nextOffset = typeof result.nextOffset === 'number'
|
||||
? result.nextOffset
|
||||
: offset + result.messages.length
|
||||
setCurrentOffset(nextOffset)
|
||||
} else if (!result.success) {
|
||||
setNoMessageTable(true)
|
||||
setHasMoreMessages(false)
|
||||
@@ -2453,13 +2692,21 @@ function ChatPage(props: ChatPageProps) {
|
||||
pendingSessionLoadRef.current = null
|
||||
initialLoadRequestedSessionRef.current = null
|
||||
setIsSessionSwitching(false)
|
||||
|
||||
// 处理从全局搜索跳转过来的情况
|
||||
const pendingSearch = pendingInSessionSearchRef.current
|
||||
if (pendingSearch?.sessionId === sessionId) {
|
||||
pendingInSessionSearchRef.current = null
|
||||
void applyPendingInSessionSearch(sessionId, pendingSearch, options.switchRequestSeq)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleJumpDateSelect = useCallback((date: Date) => {
|
||||
if (!currentSessionId) return
|
||||
const handleJumpDateSelect = useCallback((date: Date, options: { sessionId?: string; switchRequestSeq?: number } = {}) => {
|
||||
const targetSessionId = String(options.sessionId || currentSessionRef.current || currentSessionId || '').trim()
|
||||
if (!targetSessionId) return
|
||||
const targetDate = new Date(date)
|
||||
const end = Math.floor(targetDate.setHours(23, 59, 59, 999) / 1000)
|
||||
// 日期跳转采用“锚点定位”而非“当天过滤”:
|
||||
@@ -2469,9 +2716,421 @@ function ChatPage(props: ChatPageProps) {
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(end)
|
||||
setShowJumpPopover(false)
|
||||
void loadMessages(currentSessionId, 0, 0, end, false)
|
||||
void loadMessages(targetSessionId, 0, 0, end, false, {
|
||||
switchRequestSeq: options.switchRequestSeq
|
||||
})
|
||||
}, [currentSessionId, loadMessages])
|
||||
|
||||
const cancelInSessionSearchTasks = useCallback(() => {
|
||||
inSessionSearchGenRef.current += 1
|
||||
if (inSessionSearchTimerRef.current) {
|
||||
clearTimeout(inSessionSearchTimerRef.current)
|
||||
inSessionSearchTimerRef.current = null
|
||||
}
|
||||
setInSessionSearching(false)
|
||||
setInSessionEnriching(false)
|
||||
}, [])
|
||||
|
||||
const resolveSearchSessionContext = useCallback((sessionId?: string) => {
|
||||
const normalizedSessionId = String(sessionId || currentSessionRef.current || currentSessionId || '').trim()
|
||||
const currentSearchSession = normalizedSessionId && Array.isArray(sessions)
|
||||
? sessions.find(session => session.username === normalizedSessionId)
|
||||
: undefined
|
||||
const resolvedSession = currentSearchSession
|
||||
? (
|
||||
standaloneSessionWindow &&
|
||||
normalizedInitialSessionId &&
|
||||
currentSearchSession.username === normalizedInitialSessionId
|
||||
? {
|
||||
...currentSearchSession,
|
||||
displayName: currentSearchSession.displayName || fallbackDisplayName || currentSearchSession.username,
|
||||
avatarUrl: currentSearchSession.avatarUrl || fallbackAvatarUrl || undefined
|
||||
}
|
||||
: currentSearchSession
|
||||
)
|
||||
: (
|
||||
normalizedSessionId
|
||||
? {
|
||||
username: normalizedSessionId,
|
||||
displayName: fallbackDisplayName || normalizedSessionId,
|
||||
avatarUrl: fallbackAvatarUrl || undefined
|
||||
} as ChatSession
|
||||
: undefined
|
||||
)
|
||||
const isGroupSearchSession = Boolean(
|
||||
resolvedSession && (
|
||||
isGroupChatSession(resolvedSession.username) ||
|
||||
(
|
||||
standaloneSessionWindow &&
|
||||
resolvedSession.username === normalizedInitialSessionId &&
|
||||
normalizedStandaloneInitialContactType === 'group'
|
||||
)
|
||||
)
|
||||
)
|
||||
const isDirectSearchSession = Boolean(
|
||||
resolvedSession &&
|
||||
isSingleContactSession(resolvedSession.username) &&
|
||||
!isGroupSearchSession
|
||||
)
|
||||
return {
|
||||
normalizedSessionId,
|
||||
resolvedSession,
|
||||
isDirectSearchSession,
|
||||
isGroupSearchSession,
|
||||
resolvedSessionDisplayName: normalizeSearchIdentityText(resolvedSession?.displayName) || normalizedSessionId || undefined,
|
||||
resolvedSessionAvatarUrl: normalizeSearchAvatarUrl(resolvedSession?.avatarUrl)
|
||||
}
|
||||
}, [
|
||||
currentSessionId,
|
||||
fallbackAvatarUrl,
|
||||
fallbackDisplayName,
|
||||
normalizedInitialSessionId,
|
||||
normalizedStandaloneInitialContactType,
|
||||
sessions,
|
||||
standaloneSessionWindow,
|
||||
isGroupChatSession
|
||||
])
|
||||
|
||||
const hydrateInSessionSearchResults = useCallback((rawMessages: Message[], sessionId?: string) => {
|
||||
const sortedMessages = sortMessagesByCreateTimeDesc(rawMessages || [])
|
||||
if (sortedMessages.length === 0) return []
|
||||
|
||||
const {
|
||||
normalizedSessionId,
|
||||
isDirectSearchSession,
|
||||
isGroupSearchSession,
|
||||
resolvedSessionDisplayName,
|
||||
resolvedSessionAvatarUrl
|
||||
} = resolveSearchSessionContext(sessionId)
|
||||
const resolvedSessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId)
|
||||
|
||||
return sortedMessages.map((message) => {
|
||||
const senderUsername = normalizeSearchIdentityText(message.senderUsername) || message.senderUsername
|
||||
const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(senderUsername, myWxid)
|
||||
const senderDisplayName = resolveSearchSenderDisplayName(
|
||||
message.senderDisplayName,
|
||||
senderUsername,
|
||||
normalizedSessionId
|
||||
)
|
||||
const senderUsernameFallback = resolveSearchSenderUsernameFallback(senderUsername)
|
||||
const senderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl)
|
||||
const nextIsSend = inferredSelfFromSender ? 1 : message.isSend
|
||||
const nextSenderDisplayName = nextIsSend === 1
|
||||
? (senderDisplayName || '我')
|
||||
: (
|
||||
senderDisplayName ||
|
||||
(isDirectSearchSession ? resolvedSessionDisplayName : undefined) ||
|
||||
senderUsernameFallback ||
|
||||
(isDirectSearchSession ? resolvedSessionUsernameFallback : undefined) ||
|
||||
'未知'
|
||||
)
|
||||
const nextSenderAvatarUrl = nextIsSend === 1
|
||||
? (senderAvatarUrl || myAvatarUrl)
|
||||
: (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined))
|
||||
|
||||
if (inferredSelfFromSender) {
|
||||
console.info('[InSessionSearch][GroupSelfHit][hydrate]', {
|
||||
sessionId: normalizedSessionId,
|
||||
localId: message.localId,
|
||||
senderUsername,
|
||||
rawIsSend: message.isSend,
|
||||
nextIsSend,
|
||||
rawSenderDisplayName: message.senderDisplayName,
|
||||
nextSenderDisplayName,
|
||||
rawSenderAvatarUrl: message.senderAvatarUrl,
|
||||
nextSenderAvatarUrl,
|
||||
myWxid,
|
||||
hasMyAvatarUrl: Boolean(myAvatarUrl)
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
senderUsername === message.senderUsername &&
|
||||
nextIsSend === message.isSend &&
|
||||
nextSenderDisplayName === message.senderDisplayName &&
|
||||
nextSenderAvatarUrl === message.senderAvatarUrl
|
||||
) {
|
||||
return message
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
isSend: nextIsSend,
|
||||
senderUsername,
|
||||
senderDisplayName: nextSenderDisplayName,
|
||||
senderAvatarUrl: nextSenderAvatarUrl
|
||||
}
|
||||
})
|
||||
}, [currentSessionId, myAvatarUrl, myWxid, resolveSearchSessionContext])
|
||||
|
||||
const enrichMessagesWithSenderProfiles = useCallback(async (rawMessages: Message[], sessionId?: string) => {
|
||||
let messages = hydrateInSessionSearchResults(rawMessages, sessionId)
|
||||
if (messages.length === 0) return []
|
||||
|
||||
const sessionContext = resolveSearchSessionContext(sessionId)
|
||||
const { normalizedSessionId, isDirectSearchSession, isGroupSearchSession } = sessionContext
|
||||
let resolvedSessionDisplayName = sessionContext.resolvedSessionDisplayName
|
||||
let resolvedSessionAvatarUrl = sessionContext.resolvedSessionAvatarUrl
|
||||
|
||||
if (
|
||||
normalizedSessionId &&
|
||||
isDirectSearchSession &&
|
||||
(
|
||||
!resolvedSessionAvatarUrl ||
|
||||
!resolvedSessionDisplayName ||
|
||||
resolvedSessionDisplayName === normalizedSessionId
|
||||
)
|
||||
) {
|
||||
try {
|
||||
const result = await window.electronAPI.chat.enrichSessionsContactInfo([normalizedSessionId])
|
||||
const profile = result.success && result.contacts ? result.contacts[normalizedSessionId] : undefined
|
||||
const profileDisplayName = resolveSearchSenderDisplayName(
|
||||
profile?.displayName,
|
||||
normalizedSessionId,
|
||||
normalizedSessionId
|
||||
)
|
||||
const profileAvatarUrl = normalizeSearchAvatarUrl(profile?.avatarUrl)
|
||||
if (profileDisplayName) {
|
||||
resolvedSessionDisplayName = profileDisplayName
|
||||
}
|
||||
if (profileAvatarUrl) {
|
||||
resolvedSessionAvatarUrl = profileAvatarUrl
|
||||
}
|
||||
if (profileDisplayName || profileAvatarUrl) {
|
||||
messages = messages.map((message) => {
|
||||
if (message.isSend === 1) return message
|
||||
const preservedDisplayName = resolveSearchSenderDisplayName(
|
||||
message.senderDisplayName,
|
||||
message.senderUsername,
|
||||
normalizedSessionId
|
||||
)
|
||||
return {
|
||||
...message,
|
||||
senderDisplayName: preservedDisplayName ||
|
||||
profileDisplayName ||
|
||||
resolvedSessionDisplayName ||
|
||||
resolveSearchSenderUsernameFallback(message.senderUsername) ||
|
||||
message.senderDisplayName,
|
||||
senderAvatarUrl: normalizeSearchAvatarUrl(message.senderAvatarUrl) || profileAvatarUrl || resolvedSessionAvatarUrl || message.senderAvatarUrl
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// ignore session profile enrichment errors and keep raw search results usable
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedSessionId && isGroupSearchSession) {
|
||||
const missingSenderMessages = messages.filter((message) => {
|
||||
if (message.localId <= 0) return false
|
||||
if (message.isSend === 1) return false
|
||||
return !normalizeSearchIdentityText(message.senderUsername)
|
||||
})
|
||||
|
||||
if (missingSenderMessages.length > 0) {
|
||||
const messageByLocalId = new Map<number, Message>()
|
||||
for (let index = 0; index < missingSenderMessages.length; index += 8) {
|
||||
const batch = missingSenderMessages.slice(index, index + 8)
|
||||
const detailResults = await Promise.allSettled(
|
||||
batch.map(async (message) => {
|
||||
const result = await window.electronAPI.chat.getMessage(normalizedSessionId, message.localId)
|
||||
if (!result.success || !result.message) return null
|
||||
return {
|
||||
localId: message.localId,
|
||||
message: hydrateInSessionSearchResults([{
|
||||
...message,
|
||||
...result.message,
|
||||
parsedContent: message.parsedContent || result.message.parsedContent,
|
||||
rawContent: message.rawContent || result.message.rawContent,
|
||||
content: message.content || result.message.content
|
||||
} as Message], normalizedSessionId)[0]
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
for (const detail of detailResults) {
|
||||
if (detail.status !== 'fulfilled' || !detail.value?.message) continue
|
||||
messageByLocalId.set(detail.value.localId, detail.value.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (messageByLocalId.size > 0) {
|
||||
messages = messages.map(message => messageByLocalId.get(message.localId) || message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const profileMap = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
||||
const pendingLoads: Array<Promise<void>> = []
|
||||
const missingUsernames: string[] = []
|
||||
|
||||
const usernames = [...new Set(
|
||||
messages
|
||||
.map((message) => normalizeSearchIdentityText(message.senderUsername))
|
||||
.filter((username): username is string => Boolean(username))
|
||||
)]
|
||||
|
||||
for (const username of usernames) {
|
||||
const cached = senderAvatarCache.get(username)
|
||||
if (cached) {
|
||||
profileMap.set(username, cached)
|
||||
continue
|
||||
}
|
||||
|
||||
const pending = senderAvatarLoading.get(username)
|
||||
if (pending) {
|
||||
pendingLoads.push(
|
||||
pending.then((profile) => {
|
||||
if (profile) {
|
||||
profileMap.set(username, profile)
|
||||
}
|
||||
}).catch(() => {})
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
missingUsernames.push(username)
|
||||
}
|
||||
|
||||
if (pendingLoads.length > 0) {
|
||||
await Promise.allSettled(pendingLoads)
|
||||
}
|
||||
|
||||
if (missingUsernames.length > 0) {
|
||||
try {
|
||||
const result = await window.electronAPI.chat.enrichSessionsContactInfo(missingUsernames)
|
||||
if (result.success && result.contacts) {
|
||||
for (const [username, profile] of Object.entries(result.contacts)) {
|
||||
const normalizedProfile = {
|
||||
avatarUrl: profile.avatarUrl,
|
||||
displayName: profile.displayName
|
||||
}
|
||||
profileMap.set(username, normalizedProfile)
|
||||
senderAvatarCache.set(username, normalizedProfile)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore sender enrichment errors and keep raw search results usable
|
||||
}
|
||||
}
|
||||
|
||||
return messages.map((message) => {
|
||||
const sender = normalizeSearchIdentityText(message.senderUsername)
|
||||
const profile = sender ? profileMap.get(sender) : undefined
|
||||
const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(sender, myWxid)
|
||||
const profileDisplayName = resolveSearchSenderDisplayName(
|
||||
profile?.displayName,
|
||||
sender,
|
||||
normalizedSessionId
|
||||
)
|
||||
const currentSenderDisplayName = resolveSearchSenderDisplayName(
|
||||
message.senderDisplayName,
|
||||
sender,
|
||||
normalizedSessionId
|
||||
)
|
||||
const senderUsernameFallback = resolveSearchSenderUsernameFallback(sender)
|
||||
const sessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId)
|
||||
const currentSenderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl)
|
||||
const nextIsSend = inferredSelfFromSender ? 1 : message.isSend
|
||||
const nextSenderDisplayName = nextIsSend === 1
|
||||
? (currentSenderDisplayName || profileDisplayName || '我')
|
||||
: (
|
||||
profileDisplayName ||
|
||||
currentSenderDisplayName ||
|
||||
(isDirectSearchSession ? resolvedSessionDisplayName : undefined) ||
|
||||
senderUsernameFallback ||
|
||||
(isDirectSearchSession ? sessionUsernameFallback : undefined) ||
|
||||
'未知'
|
||||
)
|
||||
const nextSenderAvatarUrl = nextIsSend === 1
|
||||
? (currentSenderAvatarUrl || myAvatarUrl || normalizeSearchAvatarUrl(profile?.avatarUrl))
|
||||
: (
|
||||
currentSenderAvatarUrl ||
|
||||
normalizeSearchAvatarUrl(profile?.avatarUrl) ||
|
||||
(isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)
|
||||
)
|
||||
|
||||
if (inferredSelfFromSender) {
|
||||
console.info('[InSessionSearch][GroupSelfHit][enrich]', {
|
||||
sessionId: normalizedSessionId,
|
||||
localId: message.localId,
|
||||
senderUsername: sender,
|
||||
rawIsSend: message.isSend,
|
||||
nextIsSend,
|
||||
profileDisplayName,
|
||||
currentSenderDisplayName,
|
||||
nextSenderDisplayName,
|
||||
profileAvatarUrl: normalizeSearchAvatarUrl(profile?.avatarUrl),
|
||||
currentSenderAvatarUrl,
|
||||
nextSenderAvatarUrl,
|
||||
myWxid,
|
||||
hasMyAvatarUrl: Boolean(myAvatarUrl)
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
sender === message.senderUsername &&
|
||||
nextIsSend === message.isSend &&
|
||||
nextSenderDisplayName === message.senderDisplayName &&
|
||||
nextSenderAvatarUrl === message.senderAvatarUrl
|
||||
) {
|
||||
return message
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
isSend: nextIsSend,
|
||||
senderUsername: sender || message.senderUsername,
|
||||
senderDisplayName: nextSenderDisplayName,
|
||||
senderAvatarUrl: nextSenderAvatarUrl
|
||||
}
|
||||
})
|
||||
}, [
|
||||
currentSessionId,
|
||||
hydrateInSessionSearchResults,
|
||||
myAvatarUrl,
|
||||
myWxid,
|
||||
resolveSearchSessionContext
|
||||
])
|
||||
|
||||
const applyPendingInSessionSearch = useCallback(async (
|
||||
sessionId: string,
|
||||
payload: PendingInSessionSearchPayload,
|
||||
switchRequestSeq?: number
|
||||
) => {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return
|
||||
if (payload.sessionId !== normalizedSessionId) return
|
||||
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
|
||||
if (currentSessionRef.current !== normalizedSessionId) return
|
||||
|
||||
const immediateResults = hydrateInSessionSearchResults(payload.results || [], normalizedSessionId)
|
||||
setShowInSessionSearch(true)
|
||||
setInSessionQuery(payload.keyword)
|
||||
setInSessionSearchError(null)
|
||||
setInSessionResults(immediateResults)
|
||||
|
||||
if (payload.firstMsgTime > 0) {
|
||||
handleJumpDateSelect(new Date(payload.firstMsgTime * 1000), {
|
||||
sessionId: normalizedSessionId,
|
||||
switchRequestSeq
|
||||
})
|
||||
}
|
||||
|
||||
setInSessionEnriching(true)
|
||||
void enrichMessagesWithSenderProfiles(immediateResults, normalizedSessionId).then((enrichedResults) => {
|
||||
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
|
||||
if (currentSessionRef.current !== normalizedSessionId) return
|
||||
setInSessionResults(enrichedResults)
|
||||
}).catch((error) => {
|
||||
console.warn('[InSessionSearch] 恢复全局搜索结果发送者信息失败:', error)
|
||||
}).finally(() => {
|
||||
if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return
|
||||
if (currentSessionRef.current !== normalizedSessionId) return
|
||||
setInSessionEnriching(false)
|
||||
})
|
||||
}, [enrichMessagesWithSenderProfiles, handleJumpDateSelect, hydrateInSessionSearchResults])
|
||||
|
||||
// 加载更晚的消息
|
||||
const loadLaterMessages = useCallback(async () => {
|
||||
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
|
||||
@@ -2537,6 +3196,20 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return
|
||||
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
|
||||
sessionSwitchRequestSeqRef.current = switchRequestSeq
|
||||
currentSessionRef.current = normalizedSessionId
|
||||
|
||||
const pendingSearch = pendingInSessionSearchRef.current
|
||||
const shouldPreservePendingSearch = pendingSearch?.sessionId === normalizedSessionId
|
||||
cancelInSessionSearchTasks()
|
||||
|
||||
// 清空会话内搜索状态(除非是从全局搜索跳转过来)
|
||||
if (!shouldPreservePendingSearch) {
|
||||
pendingInSessionSearchRef.current = null
|
||||
setShowInSessionSearch(false)
|
||||
setInSessionQuery('')
|
||||
setInSessionResults([])
|
||||
setInSessionSearchError(null)
|
||||
}
|
||||
|
||||
setCurrentSession(normalizedSessionId, { preserveMessages: false })
|
||||
setNoMessageTable(false)
|
||||
@@ -2546,6 +3219,13 @@ function ChatPage(props: ChatPageProps) {
|
||||
pendingSessionLoadRef.current = null
|
||||
initialLoadRequestedSessionRef.current = null
|
||||
setIsSessionSwitching(false)
|
||||
|
||||
// 处理从全局搜索跳转过来的情况
|
||||
if (pendingSearch?.sessionId === normalizedSessionId) {
|
||||
pendingInSessionSearchRef.current = null
|
||||
void applyPendingInSessionSearch(normalizedSessionId, pendingSearch, switchRequestSeq)
|
||||
}
|
||||
|
||||
void refreshSessionIncrementally(normalizedSessionId, switchRequestSeq)
|
||||
} else {
|
||||
pendingSessionLoadRef.current = normalizedSessionId
|
||||
@@ -2581,7 +3261,9 @@ function ChatPage(props: ChatPageProps) {
|
||||
restoreSessionWindowCache,
|
||||
refreshSessionIncrementally,
|
||||
hydrateSessionPreview,
|
||||
loadMessages
|
||||
loadMessages,
|
||||
cancelInSessionSearchTasks,
|
||||
applyPendingInSessionSearch
|
||||
])
|
||||
|
||||
// 选择会话
|
||||
@@ -2604,6 +3286,166 @@ function ChatPage(props: ChatPageProps) {
|
||||
setSearchKeyword('')
|
||||
}
|
||||
|
||||
// 会话内搜索
|
||||
const inSessionSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const inSessionSearchGenRef = useRef(0)
|
||||
const handleInSessionSearch = useCallback(async (keyword: string) => {
|
||||
setInSessionQuery(keyword)
|
||||
if (inSessionSearchTimerRef.current) clearTimeout(inSessionSearchTimerRef.current)
|
||||
inSessionSearchTimerRef.current = null
|
||||
inSessionSearchGenRef.current += 1
|
||||
if (!keyword.trim() || !currentSessionId) {
|
||||
setInSessionResults([])
|
||||
setInSessionSearchError(null)
|
||||
setInSessionSearching(false)
|
||||
setInSessionEnriching(false)
|
||||
return
|
||||
}
|
||||
setInSessionSearchError(null)
|
||||
const gen = inSessionSearchGenRef.current
|
||||
const sid = currentSessionId
|
||||
inSessionSearchTimerRef.current = setTimeout(async () => {
|
||||
if (gen !== inSessionSearchGenRef.current) return
|
||||
setInSessionSearching(true)
|
||||
try {
|
||||
const res = await window.electronAPI.chat.searchMessages(keyword.trim(), sid, 50, 0)
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.error || '搜索失败')
|
||||
}
|
||||
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
||||
const messages = hydrateInSessionSearchResults(res?.messages || [], sid)
|
||||
setInSessionResults(messages)
|
||||
setInSessionSearchError(null)
|
||||
|
||||
setInSessionEnriching(true)
|
||||
void enrichMessagesWithSenderProfiles(messages, sid).then((enriched) => {
|
||||
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
||||
setInSessionResults(enriched)
|
||||
}).catch((error) => {
|
||||
console.warn('[InSessionSearch] 补充发送者信息失败:', error)
|
||||
}).finally(() => {
|
||||
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
||||
setInSessionEnriching(false)
|
||||
})
|
||||
} catch (error) {
|
||||
if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return
|
||||
setInSessionResults([])
|
||||
setInSessionSearchError(error instanceof Error ? error.message : String(error))
|
||||
setInSessionEnriching(false)
|
||||
} finally {
|
||||
if (gen === inSessionSearchGenRef.current) setInSessionSearching(false)
|
||||
}
|
||||
}, 500)
|
||||
}, [currentSessionId, enrichMessagesWithSenderProfiles, hydrateInSessionSearchResults])
|
||||
|
||||
const handleToggleInSessionSearch = useCallback(() => {
|
||||
setShowInSessionSearch(v => {
|
||||
if (v) {
|
||||
cancelInSessionSearchTasks()
|
||||
setInSessionQuery('')
|
||||
setInSessionResults([])
|
||||
setInSessionSearchError(null)
|
||||
} else {
|
||||
setTimeout(() => inSessionSearchRef.current?.focus(), 50)
|
||||
}
|
||||
return !v
|
||||
})
|
||||
}, [cancelInSessionSearchTasks])
|
||||
|
||||
// 全局消息搜索
|
||||
const globalMsgSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const globalMsgSearchGenRef = useRef(0)
|
||||
const handleGlobalMsgSearch = useCallback(async (keyword: string) => {
|
||||
const normalizedKeyword = keyword.trim()
|
||||
setGlobalMsgQuery(keyword)
|
||||
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
||||
globalMsgSearchTimerRef.current = null
|
||||
globalMsgSearchGenRef.current += 1
|
||||
if (!normalizedKeyword) {
|
||||
pendingGlobalMsgSearchReplayRef.current = null
|
||||
setGlobalMsgResults([])
|
||||
setGlobalMsgSearchError(null)
|
||||
setShowGlobalMsgSearch(false)
|
||||
setGlobalMsgSearching(false)
|
||||
return
|
||||
}
|
||||
setShowGlobalMsgSearch(true)
|
||||
setGlobalMsgSearchError(null)
|
||||
|
||||
const sessionList = Array.isArray(sessionsRef.current) ? sessionsRef.current.filter((session) => String(session.username || '').trim()) : []
|
||||
if (!isConnectedRef.current || sessionList.length === 0) {
|
||||
pendingGlobalMsgSearchReplayRef.current = normalizedKeyword
|
||||
setGlobalMsgResults([])
|
||||
setGlobalMsgSearchError(null)
|
||||
setGlobalMsgSearching(false)
|
||||
return
|
||||
}
|
||||
|
||||
pendingGlobalMsgSearchReplayRef.current = null
|
||||
const gen = globalMsgSearchGenRef.current
|
||||
globalMsgSearchTimerRef.current = setTimeout(async () => {
|
||||
if (gen !== globalMsgSearchGenRef.current) return
|
||||
setGlobalMsgSearching(true)
|
||||
try {
|
||||
const results: Array<Message & { sessionId: string }> = []
|
||||
const concurrency = 6
|
||||
|
||||
for (let index = 0; index < sessionList.length; index += concurrency) {
|
||||
const chunk = sessionList.slice(index, index + concurrency)
|
||||
const chunkResults = await Promise.allSettled(
|
||||
chunk.map(async (session) => {
|
||||
const res = await window.electronAPI.chat.searchMessages(normalizedKeyword, session.username, 10, 0)
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.error || `搜索失败: ${session.username}`)
|
||||
}
|
||||
if (!res?.messages?.length) return []
|
||||
return res.messages.map((msg) => ({ ...msg, sessionId: session.username }))
|
||||
})
|
||||
)
|
||||
|
||||
if (gen !== globalMsgSearchGenRef.current) return
|
||||
|
||||
for (const item of chunkResults) {
|
||||
if (item.status === 'rejected') {
|
||||
throw item.reason instanceof Error ? item.reason : new Error(String(item.reason))
|
||||
}
|
||||
if (item.value.length > 0) {
|
||||
results.push(...item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
const timeDiff = (b.createTime || 0) - (a.createTime || 0)
|
||||
if (timeDiff !== 0) return timeDiff
|
||||
return (b.localId || 0) - (a.localId || 0)
|
||||
})
|
||||
|
||||
if (gen !== globalMsgSearchGenRef.current) return
|
||||
setGlobalMsgResults(results)
|
||||
setGlobalMsgSearchError(null)
|
||||
} catch (error) {
|
||||
if (gen !== globalMsgSearchGenRef.current) return
|
||||
setGlobalMsgResults([])
|
||||
setGlobalMsgSearchError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false)
|
||||
}
|
||||
}, 500)
|
||||
}, [])
|
||||
|
||||
const handleCloseGlobalMsgSearch = useCallback(() => {
|
||||
globalMsgSearchGenRef.current += 1
|
||||
if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current)
|
||||
globalMsgSearchTimerRef.current = null
|
||||
pendingGlobalMsgSearchReplayRef.current = null
|
||||
setShowGlobalMsgSearch(false)
|
||||
setGlobalMsgQuery('')
|
||||
setGlobalMsgResults([])
|
||||
setGlobalMsgSearchError(null)
|
||||
setGlobalMsgSearching(false)
|
||||
}, [])
|
||||
|
||||
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
|
||||
const scrollTimeoutRef = useRef<number | null>(null)
|
||||
const handleScroll = useCallback(() => {
|
||||
@@ -2884,6 +3726,28 @@ function ChatPage(props: ChatPageProps) {
|
||||
isConnectedRef.current = isConnected
|
||||
}, [isConnected])
|
||||
|
||||
useEffect(() => {
|
||||
const replayKeyword = pendingGlobalMsgSearchReplayRef.current
|
||||
if (!replayKeyword || !isConnected || sessions.length === 0) return
|
||||
pendingGlobalMsgSearchReplayRef.current = null
|
||||
void handleGlobalMsgSearch(replayKeyword)
|
||||
}, [isConnected, sessions.length, handleGlobalMsgSearch])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
inSessionSearchGenRef.current += 1
|
||||
if (inSessionSearchTimerRef.current) {
|
||||
clearTimeout(inSessionSearchTimerRef.current)
|
||||
inSessionSearchTimerRef.current = null
|
||||
}
|
||||
globalMsgSearchGenRef.current += 1
|
||||
if (globalMsgSearchTimerRef.current) {
|
||||
clearTimeout(globalMsgSearchTimerRef.current)
|
||||
globalMsgSearchTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
searchKeywordRef.current = searchKeyword
|
||||
}, [searchKeyword])
|
||||
@@ -3017,11 +3881,32 @@ function ChatPage(props: ChatPageProps) {
|
||||
return
|
||||
}
|
||||
const lower = searchKeyword.toLowerCase()
|
||||
setFilteredSessions(visible.filter(s =>
|
||||
s.displayName?.toLowerCase().includes(lower) ||
|
||||
s.username.toLowerCase().includes(lower) ||
|
||||
s.summary.toLowerCase().includes(lower)
|
||||
))
|
||||
setFilteredSessions(visible
|
||||
.filter(s => {
|
||||
const matchedByName = s.displayName?.toLowerCase().includes(lower)
|
||||
const matchedByUsername = s.username.toLowerCase().includes(lower)
|
||||
const matchedByAlias = s.alias?.toLowerCase().includes(lower)
|
||||
return matchedByName || matchedByUsername || matchedByAlias
|
||||
})
|
||||
.map(s => {
|
||||
const matchedByName = s.displayName?.toLowerCase().includes(lower)
|
||||
const matchedByUsername = s.username.toLowerCase().includes(lower)
|
||||
const matchedByAlias = s.alias?.toLowerCase().includes(lower)
|
||||
|
||||
let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined
|
||||
|
||||
if (matchedByUsername && !matchedByName && !matchedByAlias) {
|
||||
matchedField = 'wxid'
|
||||
} else if (matchedByAlias && !matchedByName && !matchedByUsername) {
|
||||
matchedField = 'alias'
|
||||
} else if (matchedByName && !matchedByUsername && !matchedByAlias) {
|
||||
matchedField = 'name'
|
||||
}
|
||||
|
||||
// ✅ 关键点:返回一个新对象,解耦全局状态
|
||||
return { ...s, matchedField }
|
||||
})
|
||||
)
|
||||
}, [sessions, searchKeyword, setFilteredSessions])
|
||||
|
||||
// 折叠群列表(独立计算,供折叠 panel 使用)
|
||||
@@ -3030,11 +3915,34 @@ function ChatPage(props: ChatPageProps) {
|
||||
const folded = sessions.filter(s => s.isFolded)
|
||||
if (!searchKeyword.trim() || !foldedView) return folded
|
||||
const lower = searchKeyword.toLowerCase()
|
||||
return folded.filter(s =>
|
||||
s.displayName?.toLowerCase().includes(lower) ||
|
||||
s.username.toLowerCase().includes(lower) ||
|
||||
s.summary.toLowerCase().includes(lower)
|
||||
)
|
||||
return folded
|
||||
// 1. 先过滤
|
||||
.filter(s => {
|
||||
const matchedByName = s.displayName?.toLowerCase().includes(lower)
|
||||
const matchedByUsername = s.username.toLowerCase().includes(lower)
|
||||
const matchedByAlias = s.alias?.toLowerCase().includes(lower)
|
||||
const matchedBySummary = s.summary?.toLowerCase().includes(lower) // 注意:这里有个 summary
|
||||
|
||||
return matchedByName || matchedByUsername || matchedByAlias || matchedBySummary
|
||||
})
|
||||
// 2. 后映射
|
||||
.map(s => {
|
||||
const matchedByName = s.displayName?.toLowerCase().includes(lower)
|
||||
const matchedByUsername = s.username.toLowerCase().includes(lower)
|
||||
const matchedByAlias = s.alias?.toLowerCase().includes(lower)
|
||||
const matchedBySummary = s.summary?.toLowerCase().includes(lower)
|
||||
|
||||
let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined
|
||||
|
||||
if (matchedByUsername && !matchedByName && !matchedBySummary && !matchedByAlias) {
|
||||
matchedField = 'wxid'
|
||||
} else if (matchedByAlias && !matchedByName && !matchedBySummary && !matchedByUsername) {
|
||||
matchedField = 'alias'
|
||||
}
|
||||
|
||||
// ✅ 同样返回新对象
|
||||
return { ...s, matchedField }
|
||||
})
|
||||
}, [sessions, searchKeyword, foldedView])
|
||||
|
||||
const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0
|
||||
@@ -3571,38 +4479,38 @@ function ChatPage(props: ChatPageProps) {
|
||||
const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates])
|
||||
const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), [])
|
||||
|
||||
const lastSelectedIdRef = useRef<number | null>(null)
|
||||
const lastSelectedKeyRef = useRef<string | null>(null)
|
||||
|
||||
const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => {
|
||||
const handleToggleSelection = useCallback((messageKey: string, isShiftKey: boolean = false) => {
|
||||
setSelectedMessages(prev => {
|
||||
const next = new Set(prev)
|
||||
|
||||
// Range selection with Shift key
|
||||
if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) {
|
||||
if (isShiftKey && lastSelectedKeyRef.current !== null && lastSelectedKeyRef.current !== messageKey) {
|
||||
const currentMsgs = useChatStore.getState().messages || []
|
||||
const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current)
|
||||
const idx2 = currentMsgs.findIndex(m => m.localId === localId)
|
||||
const idx1 = currentMsgs.findIndex(m => getMessageKey(m) === lastSelectedKeyRef.current)
|
||||
const idx2 = currentMsgs.findIndex(m => getMessageKey(m) === messageKey)
|
||||
|
||||
if (idx1 !== -1 && idx2 !== -1) {
|
||||
const start = Math.min(idx1, idx2)
|
||||
const end = Math.max(idx1, idx2)
|
||||
for (let i = start; i <= end; i++) {
|
||||
next.add(currentMsgs[i].localId)
|
||||
next.add(getMessageKey(currentMsgs[i]))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal toggle
|
||||
if (next.has(localId)) {
|
||||
next.delete(localId)
|
||||
lastSelectedIdRef.current = null // Reset last selection on uncheck? Or keep? Usually keep last interaction.
|
||||
if (next.has(messageKey)) {
|
||||
next.delete(messageKey)
|
||||
lastSelectedKeyRef.current = null
|
||||
} else {
|
||||
next.add(localId)
|
||||
lastSelectedIdRef.current = localId
|
||||
next.add(messageKey)
|
||||
lastSelectedKeyRef.current = messageKey
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
}, [getMessageKey])
|
||||
|
||||
const formatBatchDateLabel = useCallback((dateStr: string) => {
|
||||
const [y, m, d] = dateStr.split('-').map(Number)
|
||||
@@ -3646,11 +4554,12 @@ function ChatPage(props: ChatPageProps) {
|
||||
// 执行单条删除动作
|
||||
const performSingleDelete = async (msg: Message) => {
|
||||
try {
|
||||
const dbPathHint = (msg as any)._db_path
|
||||
const targetMessageKey = getMessageKey(msg)
|
||||
const dbPathHint = msg._db_path
|
||||
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint)
|
||||
if (result.success) {
|
||||
const currentMessages = useChatStore.getState().messages || []
|
||||
const newMessages = currentMessages.filter(m => m.localId !== msg.localId)
|
||||
const newMessages = currentMessages.filter(m => getMessageKey(m) !== targetMessageKey)
|
||||
useChatStore.getState().setMessages(newMessages)
|
||||
} else {
|
||||
alert('删除失败: ' + (result.error || '原因未知'))
|
||||
@@ -3712,7 +4621,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (result.success) {
|
||||
const currentMessages = useChatStore.getState().messages || []
|
||||
const newMessages = currentMessages.map(m => {
|
||||
if (m.localId === editingMessage.message.localId) {
|
||||
if (getMessageKey(m) === getMessageKey(editingMessage.message)) {
|
||||
return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent }
|
||||
}
|
||||
return m
|
||||
@@ -3753,37 +4662,44 @@ function ChatPage(props: ChatPageProps) {
|
||||
|
||||
try {
|
||||
const currentMessages = useChatStore.getState().messages || []
|
||||
const selectedIds = Array.from(selectedMessages)
|
||||
const deletedIds = new Set<number>()
|
||||
const selectedKeys = Array.from(selectedMessages)
|
||||
const deletedKeys = new Set<string>()
|
||||
|
||||
for (let i = 0; i < selectedIds.length; i++) {
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
if (cancelDeleteRef.current) break
|
||||
|
||||
const id = selectedIds[i]
|
||||
const msgObj = currentMessages.find(m => m.localId === id)
|
||||
const dbPathHint = (msgObj as any)?._db_path
|
||||
const key = selectedKeys[i]
|
||||
const msgObj = currentMessages.find(m => getMessageKey(m) === key)
|
||||
const dbPathHint = msgObj?._db_path
|
||||
const createTime = msgObj?.createTime || 0
|
||||
const localId = msgObj?.localId || 0
|
||||
|
||||
try {
|
||||
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, id, createTime, dbPathHint)
|
||||
if (result.success) {
|
||||
deletedIds.add(id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`删除消息 ${id} 失败:`, err)
|
||||
if (!msgObj) {
|
||||
setDeleteProgress({ current: i + 1, total: selectedKeys.length })
|
||||
continue
|
||||
}
|
||||
|
||||
setDeleteProgress({ current: i + 1, total: selectedIds.length })
|
||||
try {
|
||||
const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, localId, createTime, dbPathHint)
|
||||
if (result.success) {
|
||||
deletedKeys.add(key)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`删除消息 ${localId} 失败:`, err)
|
||||
}
|
||||
|
||||
setDeleteProgress({ current: i + 1, total: selectedKeys.length })
|
||||
}
|
||||
|
||||
const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedIds.has(m.localId))
|
||||
const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedKeys.has(getMessageKey(m)))
|
||||
useChatStore.getState().setMessages(finalMessages)
|
||||
|
||||
setIsSelectionMode(false)
|
||||
setSelectedMessages(new Set())
|
||||
setSelectedMessages(new Set<string>())
|
||||
lastSelectedKeyRef.current = null
|
||||
|
||||
if (cancelDeleteRef.current) {
|
||||
alert(`操作已中止。已删除 ${deletedIds.size} 条,剩余记录保留。`)
|
||||
alert(`操作已中止。已删除 ${deletedKeys.size} 条,剩余记录保留。`)
|
||||
}
|
||||
} catch (e) {
|
||||
alert('批量删除出现错误: ' + String(e))
|
||||
@@ -3885,10 +4801,13 @@ function ChatPage(props: ChatPageProps) {
|
||||
type="text"
|
||||
placeholder="搜索"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleSearch(e.target.value)
|
||||
handleGlobalMsgSearch(e.target.value)
|
||||
}}
|
||||
/>
|
||||
{searchKeyword && (
|
||||
<button className="close-search" onClick={handleCloseSearch}>
|
||||
<button className="close-search" onClick={() => { handleCloseSearch(); handleCloseGlobalMsgSearch() }}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
@@ -3920,6 +4839,80 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全局消息搜索结果 */}
|
||||
{globalMsgQuery && (
|
||||
<div className="global-msg-search-results">
|
||||
{globalMsgSearching ? (
|
||||
<div className="search-loading">
|
||||
<Loader2 className="spin" size={20} />
|
||||
<span>搜索中...</span>
|
||||
</div>
|
||||
) : globalMsgSearchError ? (
|
||||
<div className="no-results">
|
||||
<AlertCircle size={32} />
|
||||
<p>{globalMsgSearchError}</p>
|
||||
</div>
|
||||
) : globalMsgResults.length > 0 ? (
|
||||
<>
|
||||
<div className="search-section-header">聊天记录:</div>
|
||||
<div className="search-results-list">
|
||||
{Object.entries(
|
||||
globalMsgResults.reduce((acc, msg) => {
|
||||
const sessionId = (msg as any).sessionId || '未知';
|
||||
if (!acc[sessionId]) acc[sessionId] = [];
|
||||
acc[sessionId].push(msg);
|
||||
return acc;
|
||||
}, {} as Record<string, Message[]>)
|
||||
).map(([sessionId, messages]) => {
|
||||
const session = sessions.find(s => s.username === sessionId);
|
||||
const firstMsg = messages[0];
|
||||
const count = messages.length;
|
||||
return (
|
||||
<div
|
||||
key={sessionId}
|
||||
className="session-item"
|
||||
onClick={() => {
|
||||
if (session) {
|
||||
pendingInSessionSearchRef.current = {
|
||||
sessionId,
|
||||
keyword: globalMsgQuery,
|
||||
firstMsgTime: firstMsg.createTime || 0,
|
||||
results: messages
|
||||
}
|
||||
handleSelectSession(session)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={session?.avatarUrl}
|
||||
name={session?.displayName || sessionId}
|
||||
size={48}
|
||||
/>
|
||||
<div className="session-content">
|
||||
<div className="session-top">
|
||||
<span className="session-name">{session?.displayName || sessionId}</span>
|
||||
</div>
|
||||
<div className="session-preview">
|
||||
<HighlightTextNoTruncate text={firstMsg.parsedContent || firstMsg.content || ''} keyword={globalMsgQuery} />
|
||||
</div>
|
||||
{count > 1 && (
|
||||
<div className="search-count">共 {count} 条相关聊天记录</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="no-results">
|
||||
<MessageSquare size={32} />
|
||||
<p>未找到相关消息</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ... (previous content) ... */}
|
||||
{shouldShowSessionsSkeleton ? (
|
||||
<div className="loading-sessions">
|
||||
@@ -3938,30 +4931,36 @@ function ChatPage(props: ChatPageProps) {
|
||||
{/* 普通会话列表 */}
|
||||
<div className="session-list-panel main-panel">
|
||||
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
|
||||
<div
|
||||
className="session-list"
|
||||
ref={sessionListRef}
|
||||
onScroll={() => {
|
||||
isScrollingRef.current = true
|
||||
if (sessionScrollTimeoutRef.current) {
|
||||
clearTimeout(sessionScrollTimeoutRef.current)
|
||||
}
|
||||
sessionScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
isScrollingRef.current = false
|
||||
sessionScrollTimeoutRef.current = null
|
||||
}, 200)
|
||||
}}
|
||||
>
|
||||
{filteredSessions.map(session => (
|
||||
<>
|
||||
{searchKeyword && (
|
||||
<div className="search-section-header">联系人:</div>
|
||||
)}
|
||||
<div
|
||||
className="session-list"
|
||||
ref={sessionListRef}
|
||||
onScroll={() => {
|
||||
isScrollingRef.current = true
|
||||
if (sessionScrollTimeoutRef.current) {
|
||||
clearTimeout(sessionScrollTimeoutRef.current)
|
||||
}
|
||||
sessionScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
isScrollingRef.current = false
|
||||
sessionScrollTimeoutRef.current = null
|
||||
}, 200)
|
||||
}}
|
||||
>
|
||||
{filteredSessions.map(session => (
|
||||
<SessionItem
|
||||
key={session.username}
|
||||
session={session}
|
||||
isActive={currentSessionId === session.username}
|
||||
onSelect={handleSelectSession}
|
||||
formatTime={formatSessionTime}
|
||||
searchKeyword={searchKeyword}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-sessions">
|
||||
<MessageSquare />
|
||||
@@ -3982,6 +4981,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
isActive={currentSessionId === session.username}
|
||||
onSelect={handleSelectSession}
|
||||
formatTime={formatSessionTime}
|
||||
searchKeyword={searchKeyword}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -3994,12 +4994,9 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拖动调节条 */}
|
||||
{!standaloneSessionWindow && <div className="resize-handle" onMouseDown={handleResizeStart} />}
|
||||
|
||||
{/* 右侧消息区域 */}
|
||||
@@ -4138,6 +5135,14 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<button
|
||||
className={`icon-btn in-session-search-btn ${showInSessionSearch ? 'active' : ''}`}
|
||||
onClick={handleToggleInSessionSearch}
|
||||
disabled={!currentSessionId}
|
||||
title="搜索会话消息"
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn refresh-messages-btn"
|
||||
onClick={handleRefreshMessages}
|
||||
@@ -4170,6 +5175,102 @@ function ChatPage(props: ChatPageProps) {
|
||||
onClose={() => setChatSnsTimelineTarget(null)}
|
||||
/>
|
||||
|
||||
{/* 会话内搜索浮窗 */}
|
||||
{showInSessionSearch && (
|
||||
<div className="in-session-search-popup">
|
||||
<div className="in-session-search-header">
|
||||
<Search size={16} className="search-icon" />
|
||||
<input
|
||||
ref={inSessionSearchRef}
|
||||
type="text"
|
||||
placeholder="搜索消息..."
|
||||
value={inSessionQuery}
|
||||
onChange={e => handleInSessionSearch(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
{inSessionSearching && <Loader2 size={16} className="spin" />}
|
||||
<button className="close-btn" onClick={handleToggleInSessionSearch}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{inSessionQuery && (
|
||||
<div className="search-result-header">
|
||||
{inSessionSearching
|
||||
? '搜索中...'
|
||||
: inSessionSearchError
|
||||
? '搜索失败'
|
||||
: `找到 ${inSessionResults.length} 条结果`}
|
||||
</div>
|
||||
)}
|
||||
{inSessionQuery && !inSessionSearching && inSessionSearchError && (
|
||||
<div className="no-results">
|
||||
<AlertCircle size={32} />
|
||||
<p>{inSessionSearchError}</p>
|
||||
</div>
|
||||
)}
|
||||
{inSessionResults.length > 0 && (
|
||||
<div className="in-session-results">
|
||||
{inSessionResults.map((msg, i) => {
|
||||
const resolvedSenderDisplayName = resolveSearchSenderDisplayName(
|
||||
msg.senderDisplayName,
|
||||
msg.senderUsername,
|
||||
currentSessionId
|
||||
)
|
||||
const resolvedSenderUsername = resolveSearchSenderUsernameFallback(msg.senderUsername)
|
||||
const resolvedSenderAvatarUrl = normalizeSearchAvatarUrl(msg.senderAvatarUrl)
|
||||
const resolvedCurrentSessionName = normalizeSearchIdentityText(currentSession?.displayName) ||
|
||||
resolveSearchSenderUsernameFallback(currentSession?.username) ||
|
||||
resolveSearchSenderUsernameFallback(currentSessionId)
|
||||
const senderName = resolvedSenderDisplayName || (
|
||||
msg.isSend === 1
|
||||
? '我'
|
||||
: (isCurrentSessionPrivateSnsSupported
|
||||
? resolvedCurrentSessionName || (inSessionEnriching ? '加载中...' : '未知')
|
||||
: resolvedSenderUsername || (inSessionEnriching ? '加载中...' : '未知成员'))
|
||||
)
|
||||
const senderAvatar = resolvedSenderAvatarUrl || (
|
||||
msg.isSend === 1
|
||||
? myAvatarUrl
|
||||
: (isCurrentSessionPrivateSnsSupported ? normalizeSearchAvatarUrl(currentSession?.avatarUrl) : undefined)
|
||||
)
|
||||
const senderAvatarLoading = inSessionEnriching && !senderAvatar
|
||||
const previewText = (msg.parsedContent || msg.content || '').slice(0, 80)
|
||||
const displayTime = msg.createTime
|
||||
? new Date(msg.createTime * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div key={i} className="result-item" onClick={() => {
|
||||
const ts = msg.createTime
|
||||
if (ts && currentSessionId) {
|
||||
setCurrentOffset(0)
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(0)
|
||||
void loadMessages(currentSessionId, 0, ts - 1, ts + 1, false)
|
||||
}
|
||||
}}>
|
||||
<div className="result-header">
|
||||
<Avatar src={senderAvatar} name={senderName} size={32} loading={senderAvatarLoading} />
|
||||
</div>
|
||||
<div className="result-content">
|
||||
<span className="result-sender">{senderName}</span>
|
||||
<span className="result-text">{previewText}</span>
|
||||
</div>
|
||||
<span className="result-time">{displayTime}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{inSessionQuery && !inSessionSearching && !inSessionSearchError && inSessionResults.length === 0 && (
|
||||
<div className="no-results">
|
||||
<MessageSquare size={32} />
|
||||
<p>未找到相关消息</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}>
|
||||
{standaloneSessionWindow && standaloneLoadStage !== 'ready' && (
|
||||
<div className="standalone-phase-overlay" role="status" aria-live="polite">
|
||||
@@ -4238,7 +5339,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
onRequireModelDownload={handleRequireModelDownload}
|
||||
onContextMenu={handleContextMenu}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={selectedMessages.has(msg.localId)}
|
||||
messageKey={messageKey}
|
||||
isSelected={selectedMessages.has(messageKey)}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
/>
|
||||
</div>
|
||||
@@ -4328,12 +5430,12 @@ function ChatPage(props: ChatPageProps) {
|
||||
<div className="group-member-badges">
|
||||
{member.isOwner && (
|
||||
<span className="member-flag owner" title="群主">
|
||||
<Crown size={12} />
|
||||
群主
|
||||
</span>
|
||||
)}
|
||||
{member.isFriend && (
|
||||
<span className="member-flag friend" title="好友">
|
||||
<UserCheck size={12} />
|
||||
好友
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -4813,7 +5915,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>
|
||||
<div className="menu-item" onClick={() => {
|
||||
setIsSelectionMode(true)
|
||||
setSelectedMessages(new Set([contextMenu.message.localId]))
|
||||
setSelectedMessages(new Set<string>([getMessageKey(contextMenu.message)]))
|
||||
lastSelectedKeyRef.current = getMessageKey(contextMenu.message)
|
||||
setContextMenu(null)
|
||||
}}>
|
||||
<CheckSquare size={16} />
|
||||
@@ -5089,7 +6192,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setIsSelectionMode(false)
|
||||
setSelectedMessages(new Set())
|
||||
setSelectedMessages(new Set<string>())
|
||||
lastSelectedKeyRef.current = null
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
@@ -5167,6 +6271,7 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
|
||||
// 消息气泡组件
|
||||
function MessageBubble({
|
||||
message,
|
||||
messageKey,
|
||||
session,
|
||||
showTime,
|
||||
myAvatarUrl,
|
||||
@@ -5178,6 +6283,7 @@ function MessageBubble({
|
||||
onToggleSelection
|
||||
}: {
|
||||
message: Message;
|
||||
messageKey: string;
|
||||
session: ChatSession;
|
||||
showTime?: boolean;
|
||||
myAvatarUrl?: string;
|
||||
@@ -5186,7 +6292,7 @@ function MessageBubble({
|
||||
onContextMenu?: (e: React.MouseEvent, message: Message) => void;
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelection?: (localId: number, isShiftKey?: boolean) => void;
|
||||
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void;
|
||||
}) {
|
||||
const isSystem = isSystemMessage(message.localType)
|
||||
const isEmoji = message.localType === 47
|
||||
@@ -5964,7 +7070,7 @@ function MessageBubble({
|
||||
onClick={(e) => {
|
||||
if (isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
onToggleSelection?.(message.localId, e.shiftKey)
|
||||
onToggleSelection?.(messageKey, e.shiftKey)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -7125,7 +8231,7 @@ function MessageBubble({
|
||||
onClick={(e) => {
|
||||
if (isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
onToggleSelection?.(message.localId, e.shiftKey)
|
||||
onToggleSelection?.(messageKey, e.shiftKey)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
+182
-16
@@ -1596,6 +1596,7 @@ function ExportPage() {
|
||||
const sessionMutualFriendsRunIdRef = useRef(0)
|
||||
const sessionMutualFriendsWorkerRunningRef = useRef(false)
|
||||
const sessionMutualFriendsBackgroundFeedTimerRef = useRef<number | null>(null)
|
||||
const sessionMutualFriendsPersistTimerRef = useRef<number | null>(null)
|
||||
const sessionMutualFriendsVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
|
||||
startIndex: 0,
|
||||
endIndex: -1
|
||||
@@ -2748,8 +2749,32 @@ function ExportPage() {
|
||||
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
||||
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
||||
}
|
||||
if (sessionMutualFriendsPersistTimerRef.current) {
|
||||
window.clearTimeout(sessionMutualFriendsPersistTimerRef.current)
|
||||
sessionMutualFriendsPersistTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const flushSessionMutualFriendsCache = useCallback(async () => {
|
||||
try {
|
||||
const scopeKey = await ensureExportCacheScope()
|
||||
await configService.setExportSessionMutualFriendsCache(
|
||||
scopeKey,
|
||||
sessionMutualFriendsDirectMetricsRef.current
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('写入导出页共同好友缓存失败:', error)
|
||||
}
|
||||
}, [ensureExportCacheScope])
|
||||
|
||||
const scheduleFlushSessionMutualFriendsCache = useCallback(() => {
|
||||
if (sessionMutualFriendsPersistTimerRef.current) return
|
||||
sessionMutualFriendsPersistTimerRef.current = window.setTimeout(() => {
|
||||
sessionMutualFriendsPersistTimerRef.current = null
|
||||
void flushSessionMutualFriendsCache()
|
||||
}, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS)
|
||||
}, [flushSessionMutualFriendsCache])
|
||||
|
||||
const isSessionMutualFriendsReady = useCallback((sessionId: string): boolean => {
|
||||
if (!sessionId) return true
|
||||
if (sessionMutualFriendsReadySetRef.current.has(sessionId)) return true
|
||||
@@ -2879,10 +2904,35 @@ function ExportPage() {
|
||||
}
|
||||
}, [getSessionMutualFriendProfile])
|
||||
|
||||
const rebuildSessionMutualFriendsStateFromDirectMetrics = useCallback((sessionIds?: string[]) => {
|
||||
const targets = Array.isArray(sessionIds) && sessionIds.length > 0
|
||||
? sessionIds
|
||||
: Object.keys(sessionMutualFriendsDirectMetricsRef.current)
|
||||
const nextMetrics: Record<string, SessionMutualFriendsMetric> = {}
|
||||
const readyIds: string[] = []
|
||||
for (const sessionIdRaw of targets) {
|
||||
const sessionId = String(sessionIdRaw || '').trim()
|
||||
if (!sessionId) continue
|
||||
const rebuilt = rebuildSessionMutualFriendsMetric(sessionId)
|
||||
if (!rebuilt) continue
|
||||
nextMetrics[sessionId] = rebuilt
|
||||
readyIds.push(sessionId)
|
||||
}
|
||||
sessionMutualFriendsMetricsRef.current = nextMetrics
|
||||
setSessionMutualFriendsMetrics(nextMetrics)
|
||||
if (readyIds.length > 0) {
|
||||
for (const sessionId of readyIds) {
|
||||
sessionMutualFriendsReadySetRef.current.add(sessionId)
|
||||
}
|
||||
patchSessionLoadTraceStage(readyIds, 'mutualFriends', 'done')
|
||||
}
|
||||
}, [patchSessionLoadTraceStage, rebuildSessionMutualFriendsMetric])
|
||||
|
||||
const applySessionMutualFriendsMetric = useCallback((sessionId: string, directMetric: SessionMutualFriendsMetric) => {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return
|
||||
sessionMutualFriendsDirectMetricsRef.current[normalizedSessionId] = directMetric
|
||||
scheduleFlushSessionMutualFriendsCache()
|
||||
|
||||
const impactedSessionIds = new Set<string>([normalizedSessionId])
|
||||
const allSessionIds = sessionsRef.current
|
||||
@@ -2912,7 +2962,7 @@ function ExportPage() {
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric])
|
||||
}, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric, scheduleFlushSessionMutualFriendsCache])
|
||||
|
||||
const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => {
|
||||
if (!sessionId) return true
|
||||
@@ -3339,11 +3389,13 @@ function ExportPage() {
|
||||
const [
|
||||
cachedContactsPayload,
|
||||
cachedMessageCountsPayload,
|
||||
cachedContentMetricsPayload
|
||||
cachedContentMetricsPayload,
|
||||
cachedMutualFriendsPayload
|
||||
] = await Promise.all([
|
||||
loadContactsCaches(scopeKey),
|
||||
configService.getExportSessionMessageCountCache(scopeKey),
|
||||
configService.getExportSessionContentMetricCache(scopeKey)
|
||||
configService.getExportSessionContentMetricCache(scopeKey),
|
||||
configService.getExportSessionMutualFriendsCache(scopeKey)
|
||||
])
|
||||
if (isStale()) return
|
||||
|
||||
@@ -3411,6 +3463,15 @@ function ExportPage() {
|
||||
if (cachedContentMetricReadySessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done')
|
||||
}
|
||||
const cachedMutualFriendDirectMetrics = Object.entries(cachedMutualFriendsPayload?.metrics || {}).reduce<Record<string, SessionMutualFriendsMetric>>((acc, [sessionIdRaw, metricRaw]) => {
|
||||
const sessionId = String(sessionIdRaw || '').trim()
|
||||
if (!exportableSessionIdSet.has(sessionId) || !isSingleContactSession(sessionId)) return acc
|
||||
const metric = metricRaw as SessionMutualFriendsMetric | undefined
|
||||
if (!metric || !Array.isArray(metric.items) || !Number.isFinite(metric.count)) return acc
|
||||
acc[sessionId] = metric
|
||||
return acc
|
||||
}, {})
|
||||
const cachedMutualFriendSessionIds = Object.keys(cachedMutualFriendDirectMetrics)
|
||||
|
||||
if (isStale()) return
|
||||
if (Object.keys(cachedMessageCounts).length > 0) {
|
||||
@@ -3422,6 +3483,13 @@ function ExportPage() {
|
||||
if (Object.keys(cachedContentMetrics).length > 0) {
|
||||
mergeSessionContentMetrics(cachedContentMetrics)
|
||||
}
|
||||
if (cachedMutualFriendSessionIds.length > 0) {
|
||||
sessionMutualFriendsDirectMetricsRef.current = cachedMutualFriendDirectMetrics
|
||||
rebuildSessionMutualFriendsStateFromDirectMetrics(cachedMutualFriendSessionIds)
|
||||
} else {
|
||||
sessionMutualFriendsMetricsRef.current = {}
|
||||
setSessionMutualFriendsMetrics({})
|
||||
}
|
||||
setSessions(baseSessions)
|
||||
sessionsHydratedAtRef.current = Date.now()
|
||||
void (async () => {
|
||||
@@ -3622,7 +3690,7 @@ function ExportPage() {
|
||||
} finally {
|
||||
if (!isStale()) setIsLoading(false)
|
||||
}
|
||||
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts])
|
||||
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, rebuildSessionMutualFriendsStateFromDirectMetrics, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExportRoute) return
|
||||
@@ -3630,10 +3698,7 @@ function ExportPage() {
|
||||
const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current &&
|
||||
sessionsRef.current.length > 0 &&
|
||||
now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS
|
||||
const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current &&
|
||||
now - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS
|
||||
|
||||
void loadBaseConfig()
|
||||
const baseConfigPromise = loadBaseConfig()
|
||||
void ensureSharedTabCountsLoaded()
|
||||
if (!hasFreshSessionSnapshot) {
|
||||
void loadSessions()
|
||||
@@ -3641,9 +3706,14 @@ function ExportPage() {
|
||||
|
||||
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!hasFreshSnsSnapshot) {
|
||||
void loadSnsStats({ full: true })
|
||||
}
|
||||
void (async () => {
|
||||
await baseConfigPromise
|
||||
const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current &&
|
||||
Date.now() - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS
|
||||
if (!hasFreshSnsSnapshot) {
|
||||
void loadSnsStats({ full: true })
|
||||
}
|
||||
})()
|
||||
}, 120)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
@@ -4988,9 +5058,14 @@ function ExportPage() {
|
||||
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
||||
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
||||
}
|
||||
if (sessionMutualFriendsPersistTimerRef.current) {
|
||||
window.clearTimeout(sessionMutualFriendsPersistTimerRef.current)
|
||||
sessionMutualFriendsPersistTimerRef.current = null
|
||||
}
|
||||
void flushSessionMediaMetricCache()
|
||||
void flushSessionMutualFriendsCache()
|
||||
}
|
||||
}, [flushSessionMediaMetricCache])
|
||||
}, [flushSessionMediaMetricCache, flushSessionMutualFriendsCache])
|
||||
|
||||
const contactByUsername = useMemo(() => {
|
||||
const map = new Map<string, ContactInfo>()
|
||||
@@ -5254,6 +5329,23 @@ function ExportPage() {
|
||||
console.error('导出页读取会话统计缓存失败:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (relationCacheResult.success && relationCacheResult.data) {
|
||||
const relationMetric = relationCacheResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||
const relationCacheMeta = relationCacheResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||
if (relationMetric) {
|
||||
applySessionDetailStats(normalizedSessionId, relationMetric, relationCacheMeta, true)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出页读取会话关系缓存失败:', error)
|
||||
}
|
||||
|
||||
const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0
|
||||
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
|
||||
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
|
||||
@@ -5302,16 +5394,36 @@ function ExportPage() {
|
||||
}
|
||||
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername])
|
||||
|
||||
const loadSessionRelationStats = useCallback(async () => {
|
||||
const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => {
|
||||
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
|
||||
if (!normalizedSessionId || isLoadingSessionRelationStats) return
|
||||
|
||||
const requestSeq = detailRequestSeqRef.current
|
||||
const forceRefresh = options?.forceRefresh === true
|
||||
setIsLoadingSessionRelationStats(true)
|
||||
try {
|
||||
if (!forceRefresh) {
|
||||
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
|
||||
const relationMetric = relationCacheResult.success && relationCacheResult.data
|
||||
? relationCacheResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||
: undefined
|
||||
const relationCacheMeta = relationCacheResult.success
|
||||
? relationCacheResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||
: undefined
|
||||
if (relationMetric) {
|
||||
applySessionDetailStats(normalizedSessionId, relationMetric, relationCacheMeta, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
|
||||
{ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
|
||||
@@ -5333,6 +5445,60 @@ function ExportPage() {
|
||||
}
|
||||
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
|
||||
|
||||
const handleRefreshTableData = useCallback(async () => {
|
||||
const scopeKey = await ensureExportCacheScope()
|
||||
|
||||
resetSessionMutualFriendsLoader()
|
||||
sessionMutualFriendsMetricsRef.current = {}
|
||||
setSessionMutualFriendsMetrics({})
|
||||
closeSessionMutualFriendsDialog()
|
||||
try {
|
||||
await configService.clearExportSessionMutualFriendsCache(scopeKey)
|
||||
} catch (error) {
|
||||
console.error('清理导出页共同好友缓存失败:', error)
|
||||
}
|
||||
|
||||
if (isSessionCountStageReady) {
|
||||
const visibleTargetIds = collectVisibleSessionMutualFriendsTargets(filteredContacts)
|
||||
const visibleTargetSet = new Set(visibleTargetIds)
|
||||
const remainingTargetIds = sessionsRef.current
|
||||
.filter((session) => session.hasSession && isSingleContactSession(session.username) && !visibleTargetSet.has(session.username))
|
||||
.map((session) => session.username)
|
||||
|
||||
if (visibleTargetIds.length > 0) {
|
||||
enqueueSessionMutualFriendsRequests(visibleTargetIds, { front: true })
|
||||
}
|
||||
if (remainingTargetIds.length > 0) {
|
||||
enqueueSessionMutualFriendsRequests(remainingTargetIds)
|
||||
}
|
||||
scheduleSessionMutualFriendsWorker()
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadContactsList({ scopeKey }),
|
||||
loadSnsStats({ full: true }),
|
||||
loadSnsUserPostCounts({ force: true })
|
||||
])
|
||||
|
||||
if (String(sessionDetail?.wxid || '').trim()) {
|
||||
void loadSessionRelationStats({ forceRefresh: true })
|
||||
}
|
||||
}, [
|
||||
closeSessionMutualFriendsDialog,
|
||||
collectVisibleSessionMutualFriendsTargets,
|
||||
enqueueSessionMutualFriendsRequests,
|
||||
ensureExportCacheScope,
|
||||
filteredContacts,
|
||||
isSessionCountStageReady,
|
||||
loadContactsList,
|
||||
loadSessionRelationStats,
|
||||
loadSnsStats,
|
||||
loadSnsUserPostCounts,
|
||||
resetSessionMutualFriendsLoader,
|
||||
scheduleSessionMutualFriendsWorker,
|
||||
sessionDetail?.wxid
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return
|
||||
if (snsUserPostCountsStatus === 'idle') {
|
||||
@@ -6371,7 +6537,7 @@ function ExportPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="secondary-btn" onClick={() => void loadContactsList()} disabled={isContactsListLoading}>
|
||||
<button className="secondary-btn" onClick={() => void handleRefreshTableData()} disabled={isContactsListLoading}>
|
||||
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
@@ -6468,7 +6634,7 @@ function ExportPage() {
|
||||
<li>可能原因3:数据库连接状态异常或 IPC 调用卡住。</li>
|
||||
</ul>
|
||||
<div className="issue-actions">
|
||||
<button className="issue-btn primary" onClick={() => void loadContactsList()}>
|
||||
<button className="issue-btn primary" onClick={() => void handleRefreshTableData()}>
|
||||
<RefreshCw size={14} />
|
||||
<span>重试加载</span>
|
||||
</button>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-analytics-page {
|
||||
@@ -10,6 +12,7 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
&.standalone {
|
||||
height: 100vh;
|
||||
@@ -197,6 +200,7 @@
|
||||
flex-direction: column;
|
||||
min-width: 250px;
|
||||
max-width: 450px;
|
||||
min-height: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
@@ -207,6 +211,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 56px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.search-row {
|
||||
flex: 1;
|
||||
@@ -296,6 +301,7 @@
|
||||
|
||||
.group-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
@@ -468,11 +474,18 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-drag-region {
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
@@ -495,22 +508,30 @@
|
||||
|
||||
.function-menu {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
|
||||
.selected-group-info {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 20px 24px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
.group-avatar.large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto 16px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@@ -529,45 +550,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
.selected-group-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.group-summary-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.function-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.function-card {
|
||||
width: 140px;
|
||||
padding: 24px 16px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
min-height: 148px;
|
||||
padding: 20px 18px;
|
||||
background: color-mix(in srgb, var(--card-bg) 92%, var(--bg-secondary));
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
box-shadow: var(--shadow-sm);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
box-shadow: var(--shadow-md);
|
||||
background: color-mix(in srgb, var(--card-bg) 100%, var(--bg-hover));
|
||||
}
|
||||
|
||||
svg {
|
||||
@@ -575,15 +615,22 @@
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.function-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -694,6 +741,7 @@
|
||||
|
||||
.content-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
@@ -785,7 +833,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.member-export-panel {
|
||||
.member-export-panel,
|
||||
.member-messages-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -1121,6 +1170,153 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.member-message-empty {
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.member-message-toolbar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(240px, 360px) minmax(160px, 1fr);
|
||||
align-items: end;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.member-message-toolbar-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.member-message-select-trigger {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.member-message-summary-text {
|
||||
align-self: flex-start;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.member-message-summary-card {
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--card-bg) 88%, var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.summary-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.member-message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-message-item {
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--card-bg) 92%, var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.member-message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.member-message-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.member-message-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.member-message-content {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.member-message-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.member-message-load-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 132px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9999px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.member-message-end {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.rankings-list {
|
||||
@@ -1405,6 +1601,16 @@
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.member-export-modal {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.member-result-modal {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// 成员详情弹框
|
||||
@@ -1496,6 +1702,34 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-modal-actions {
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.member-modal-primary-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1537,3 +1771,141 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.member-export-modal {
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-height: min(760px, calc(100vh - 32px));
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.member-export-modal-header {
|
||||
margin-bottom: 18px;
|
||||
padding-right: 40px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.member-export-panel {
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.member-result-modal {
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
position: relative;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&.success {
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||
}
|
||||
|
||||
&.error {
|
||||
border: 1px solid color-mix(in srgb, #ef4444 38%, var(--border-color));
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.member-result-modal-body {
|
||||
padding-right: 40px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.member-result-modal-actions {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.member-result-modal-btn {
|
||||
min-width: 96px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
}
|
||||
+639
-256
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown } from 'lucide-react'
|
||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare } from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import DateRangePicker from '../components/DateRangePicker'
|
||||
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
|
||||
import * as configService from '../services/config'
|
||||
import type { Message } from '../types/models'
|
||||
import {
|
||||
finishBackgroundTask,
|
||||
isBackgroundTaskCancelRequested,
|
||||
@@ -36,7 +37,7 @@ interface GroupMessageRank {
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||
type AnalysisFunction = 'members' | 'memberMessages' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||
|
||||
interface MemberMessageExportOptions {
|
||||
@@ -57,14 +58,105 @@ interface MemberExportFormatOption {
|
||||
desc: string
|
||||
}
|
||||
|
||||
interface GroupMemberMessagesPage {
|
||||
messages: Message[]
|
||||
hasMore: boolean
|
||||
nextCursor: number
|
||||
}
|
||||
|
||||
const MEMBER_MESSAGE_PAGE_SIZE = 40
|
||||
|
||||
const filterMembersByKeyword = (members: GroupMember[], keyword: string) => {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||
if (!normalizedKeyword) return members
|
||||
return members.filter(member => {
|
||||
const fields = [
|
||||
member.username,
|
||||
member.displayName,
|
||||
member.nickname,
|
||||
member.remark,
|
||||
member.alias,
|
||||
member.groupNickname
|
||||
]
|
||||
return fields.some(field => String(field || '').toLowerCase().includes(normalizedKeyword))
|
||||
})
|
||||
}
|
||||
|
||||
const formatMemberMessageTime = (createTime: number) => {
|
||||
if (!createTime) return '-'
|
||||
return new Date(createTime * 1000).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
const getMemberMessageTypeLabel = (message: Message) => {
|
||||
switch (message.localType) {
|
||||
case 1:
|
||||
return '文本'
|
||||
case 3:
|
||||
return '图片'
|
||||
case 34:
|
||||
return '语音'
|
||||
case 42:
|
||||
return '名片'
|
||||
case 43:
|
||||
return '视频'
|
||||
case 47:
|
||||
return '表情'
|
||||
case 48:
|
||||
return '位置'
|
||||
case 49:
|
||||
return message.fileName ? '文件' : '链接'
|
||||
case 50:
|
||||
return '通话'
|
||||
case 10000:
|
||||
case 10002:
|
||||
return '系统'
|
||||
default:
|
||||
return `类型 ${message.localType}`
|
||||
}
|
||||
}
|
||||
|
||||
const getMemberMessagePreview = (message: Message) => {
|
||||
const text = (message.parsedContent || message.content || message.rawContent || '').trim()
|
||||
switch (message.localType) {
|
||||
case 1:
|
||||
case 10000:
|
||||
case 10002:
|
||||
return text || '[空文本]'
|
||||
case 3:
|
||||
return text || '[图片]'
|
||||
case 34:
|
||||
return message.voiceDurationSeconds ? `[语音] ${message.voiceDurationSeconds} 秒` : '[语音]'
|
||||
case 42:
|
||||
return `[名片] ${message.cardNickname || message.cardUsername || text || '联系人名片'}`
|
||||
case 43:
|
||||
return text || '[视频]'
|
||||
case 47:
|
||||
return text || '[表情]'
|
||||
case 48:
|
||||
return `[位置] ${message.locationPoiname || message.locationLabel || text || '位置消息'}`
|
||||
case 49:
|
||||
if (message.fileName) return `[文件] ${message.fileName}`
|
||||
if (message.linkTitle) return `[链接] ${message.linkTitle}`
|
||||
return text || '[链接/文件]'
|
||||
case 50:
|
||||
return text || '[通话]'
|
||||
default:
|
||||
return text || `[消息类型 ${message.localType}]`
|
||||
}
|
||||
}
|
||||
|
||||
function GroupAnalyticsPage() {
|
||||
const location = useLocation()
|
||||
const [groups, setGroups] = useState<GroupChatInfo[]>([])
|
||||
const [filteredGroups, setFilteredGroups] = useState<GroupChatInfo[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedGroup, setSelectedGroup] = useState<GroupChatInfo | null>(null)
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null)
|
||||
const [selectedFunction, setSelectedFunction] = useState<AnalysisFunction | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const selectedGroup = useMemo(
|
||||
() => (selectedGroupId ? groups.find(group => group.username === selectedGroupId) || null : null),
|
||||
[groups, selectedGroupId]
|
||||
)
|
||||
|
||||
// 功能数据
|
||||
const [members, setMembers] = useState<GroupMember[]>([])
|
||||
@@ -74,7 +166,11 @@ function GroupAnalyticsPage() {
|
||||
const [functionLoading, setFunctionLoading] = useState(false)
|
||||
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
||||
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
|
||||
const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('')
|
||||
const [memberMessages, setMemberMessages] = useState<Message[]>([])
|
||||
const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false)
|
||||
const [memberMessagesCursor, setMemberMessagesCursor] = useState(0)
|
||||
const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false)
|
||||
const [selectedMessageMemberUsername, setSelectedMessageMemberUsername] = useState('')
|
||||
const [exportFolder, setExportFolder] = useState('')
|
||||
const [memberExportOptions, setMemberExportOptions] = useState<MemberMessageExportOptions>({
|
||||
format: 'excel',
|
||||
@@ -91,11 +187,17 @@ function GroupAnalyticsPage() {
|
||||
// 成员详情弹框
|
||||
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||
const [showMemberSelect, setShowMemberSelect] = useState(false)
|
||||
const [showMemberExportModal, setShowMemberExportModal] = useState(false)
|
||||
const [exportResultDialog, setExportResultDialog] = useState<{
|
||||
title: string
|
||||
message: string
|
||||
tone: 'success' | 'error'
|
||||
} | null>(null)
|
||||
const [showMessageMemberSelect, setShowMessageMemberSelect] = useState(false)
|
||||
const [showFormatSelect, setShowFormatSelect] = useState(false)
|
||||
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
|
||||
const [memberSearchKeyword, setMemberSearchKeyword] = useState('')
|
||||
const memberSelectDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [messageMemberSearchKeyword, setMessageMemberSearchKeyword] = useState('')
|
||||
const messageMemberSelectDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const formatDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -141,9 +243,9 @@ function GroupAnalyticsPage() {
|
||||
{ value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' },
|
||||
{ value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' }
|
||||
]), [])
|
||||
const selectedExportMember = useMemo(
|
||||
() => members.find(member => member.username === selectedExportMemberUsername) || null,
|
||||
[members, selectedExportMemberUsername]
|
||||
const selectedMessageMember = useMemo(
|
||||
() => members.find(member => member.username === selectedMessageMemberUsername) || null,
|
||||
[members, selectedMessageMemberUsername]
|
||||
)
|
||||
const selectedFormatOption = useMemo(
|
||||
() => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0],
|
||||
@@ -153,20 +255,26 @@ function GroupAnalyticsPage() {
|
||||
() => displayNameOptions.find(option => option.value === memberExportOptions.displayNamePreference) || displayNameOptions[0],
|
||||
[displayNameOptions, memberExportOptions.displayNamePreference]
|
||||
)
|
||||
const filteredMemberOptions = useMemo(() => {
|
||||
const keyword = memberSearchKeyword.trim().toLowerCase()
|
||||
if (!keyword) return members
|
||||
return members.filter(member => {
|
||||
const fields = [
|
||||
member.username,
|
||||
member.displayName,
|
||||
member.nickname,
|
||||
member.remark,
|
||||
member.alias
|
||||
]
|
||||
return fields.some(field => String(field || '').toLowerCase().includes(keyword))
|
||||
})
|
||||
}, [memberSearchKeyword, members])
|
||||
const filteredMessageMemberOptions = useMemo(() => {
|
||||
return filterMembersByKeyword(members, messageMemberSearchKeyword)
|
||||
}, [members, messageMemberSearchKeyword])
|
||||
|
||||
const resetMemberMessageState = useCallback((clearSelection = true) => {
|
||||
setMemberMessages([])
|
||||
setMemberMessagesHasMore(false)
|
||||
setMemberMessagesCursor(0)
|
||||
setMemberMessagesLoadingMore(false)
|
||||
setShowMessageMemberSelect(false)
|
||||
if (clearSelection) {
|
||||
setSelectedMessageMemberUsername('')
|
||||
setMessageMemberSearchKeyword('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getSelectedTimeRange = () => ({
|
||||
startTime: startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined,
|
||||
endTime: endDate ? Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000) : undefined
|
||||
})
|
||||
|
||||
const loadExportPath = useCallback(async () => {
|
||||
try {
|
||||
@@ -240,20 +348,20 @@ function GroupAnalyticsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (members.length === 0) {
|
||||
setSelectedExportMemberUsername('')
|
||||
setSelectedMessageMemberUsername('')
|
||||
return
|
||||
}
|
||||
const exists = members.some(member => member.username === selectedExportMemberUsername)
|
||||
if (!exists) {
|
||||
setSelectedExportMemberUsername(members[0].username)
|
||||
const messageExists = members.some(member => member.username === selectedMessageMemberUsername)
|
||||
if (!messageExists) {
|
||||
setSelectedMessageMemberUsername(members[0].username)
|
||||
}
|
||||
}, [members, selectedExportMemberUsername])
|
||||
}, [members, selectedMessageMemberUsername])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) {
|
||||
setShowMemberSelect(false)
|
||||
if (showMessageMemberSelect && messageMemberSelectDropdownRef.current && !messageMemberSelectDropdownRef.current.contains(target)) {
|
||||
setShowMessageMemberSelect(false)
|
||||
}
|
||||
if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) {
|
||||
setShowFormatSelect(false)
|
||||
@@ -264,7 +372,7 @@ function GroupAnalyticsPage() {
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showDisplayNameSelect, showFormatSelect, showMemberSelect])
|
||||
}, [showDisplayNameSelect, showFormatSelect, showMessageMemberSelect])
|
||||
|
||||
useEffect(() => {
|
||||
if (preselectAppliedRef.current) return
|
||||
@@ -274,7 +382,7 @@ function GroupAnalyticsPage() {
|
||||
preselectAppliedRef.current = true
|
||||
|
||||
if (matchedGroup) {
|
||||
setSelectedGroup(matchedGroup)
|
||||
setSelectedGroupId(matchedGroup.username)
|
||||
setSelectedFunction(null)
|
||||
setSearchQuery('')
|
||||
}
|
||||
@@ -301,7 +409,7 @@ function GroupAnalyticsPage() {
|
||||
|
||||
// 日期范围变化时自动刷新
|
||||
useEffect(() => {
|
||||
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') {
|
||||
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') {
|
||||
setDateRangeReady(false)
|
||||
loadFunctionData(selectedFunction)
|
||||
}
|
||||
@@ -311,9 +419,11 @@ function GroupAnalyticsPage() {
|
||||
const handleChange = () => {
|
||||
setGroups([])
|
||||
setFilteredGroups([])
|
||||
setSelectedGroup(null)
|
||||
setSelectedGroupId(null)
|
||||
setSelectedFunction(null)
|
||||
setMembers([])
|
||||
resetMemberMessageState()
|
||||
setShowMemberExportModal(false)
|
||||
setRankings([])
|
||||
setActiveHours({})
|
||||
setMediaStats(null)
|
||||
@@ -322,41 +432,77 @@ function GroupAnalyticsPage() {
|
||||
}
|
||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||
}, [loadExportPath, loadGroups])
|
||||
}, [loadExportPath, loadGroups, resetMemberMessageState])
|
||||
|
||||
const handleGroupSelect = (group: GroupChatInfo) => {
|
||||
if (selectedGroup?.username !== group.username) {
|
||||
setSelectedGroup(group)
|
||||
setSelectedFunction(null)
|
||||
setSelectedExportMemberUsername('')
|
||||
setMemberSearchKeyword('')
|
||||
setShowMemberSelect(false)
|
||||
setShowFormatSelect(false)
|
||||
setShowDisplayNameSelect(false)
|
||||
}
|
||||
setSelectedGroupId(group.username)
|
||||
setSelectedFunction(null)
|
||||
setSelectedMember(null)
|
||||
setShowMemberExportModal(false)
|
||||
resetMemberMessageState()
|
||||
setShowFormatSelect(false)
|
||||
setShowDisplayNameSelect(false)
|
||||
}
|
||||
|
||||
|
||||
const loadMemberMessagesPage = async (
|
||||
targetGroup: GroupChatInfo,
|
||||
memberUsername: string,
|
||||
options?: {
|
||||
cursor?: number
|
||||
append?: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}
|
||||
): Promise<GroupMemberMessagesPage> => {
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMemberMessages(targetGroup.username, memberUsername, {
|
||||
startTime: options?.startTime,
|
||||
endTime: options?.endTime,
|
||||
limit: MEMBER_MESSAGE_PAGE_SIZE,
|
||||
cursor: options?.cursor && options.cursor > 0 ? options.cursor : undefined
|
||||
})
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.error || '读取成员消息失败')
|
||||
}
|
||||
|
||||
setMemberMessages(prev => {
|
||||
if (!options?.append) return result.data!.messages
|
||||
const next = [...prev]
|
||||
const seen = new Set(prev.map(message => message.messageKey))
|
||||
for (const message of result.data!.messages) {
|
||||
if (seen.has(message.messageKey)) continue
|
||||
seen.add(message.messageKey)
|
||||
next.push(message)
|
||||
}
|
||||
return next
|
||||
})
|
||||
setMemberMessagesHasMore(result.data.hasMore)
|
||||
setMemberMessagesCursor(result.data.nextCursor || 0)
|
||||
return result.data
|
||||
}
|
||||
|
||||
const handleFunctionSelect = async (func: AnalysisFunction) => {
|
||||
if (!selectedGroup) return
|
||||
setSelectedFunction(func)
|
||||
await loadFunctionData(func)
|
||||
}
|
||||
|
||||
const loadFunctionData = async (func: AnalysisFunction) => {
|
||||
if (!selectedGroup) return
|
||||
const loadFunctionData = async (
|
||||
func: AnalysisFunction,
|
||||
targetGroup: GroupChatInfo | null = selectedGroup,
|
||||
preferredMemberUsername?: string
|
||||
) => {
|
||||
if (!targetGroup) return
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage: 'groupAnalytics',
|
||||
title: `群分析:${func}`,
|
||||
detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`,
|
||||
detail: `正在读取 ${targetGroup.displayName || targetGroup.username} 的分析数据`,
|
||||
progressText: func,
|
||||
cancelable: true
|
||||
})
|
||||
setFunctionLoading(true)
|
||||
|
||||
// 计算时间戳
|
||||
const startTime = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined
|
||||
const endTime = endDate ? Math.floor(new Date(endDate + 'T23:59:59').getTime() / 1000) : undefined
|
||||
const { startTime, endTime } = getSelectedTimeRange()
|
||||
|
||||
try {
|
||||
switch (func) {
|
||||
@@ -365,7 +511,7 @@ function GroupAnalyticsPage() {
|
||||
detail: '正在读取群成员列表',
|
||||
progressText: '成员列表'
|
||||
})
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群成员列表未继续写入' })
|
||||
return
|
||||
@@ -377,20 +523,46 @@ function GroupAnalyticsPage() {
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'memberExport': {
|
||||
case 'memberMessages': {
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: '正在读取导出成员列表',
|
||||
progressText: '成员导出'
|
||||
detail: '正在读取成员列表与消息',
|
||||
progressText: '成员消息'
|
||||
})
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员导出列表未继续写入' })
|
||||
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员消息未继续写入' })
|
||||
return
|
||||
}
|
||||
if (result.success && result.data) setMembers(result.data)
|
||||
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
|
||||
detail: result.success ? `成员导出列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取成员导出列表失败'),
|
||||
progressText: result.success ? `${result.data?.length || 0} 人` : '失败'
|
||||
if (!result.success || !result.data) {
|
||||
resetMemberMessageState()
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
detail: result.error || '读取群成员失败',
|
||||
progressText: '失败'
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
setMembers(result.data)
|
||||
const targetMember = result.data.find(member => member.username === (preferredMemberUsername || selectedMessageMemberUsername)) || result.data[0]
|
||||
|
||||
if (!targetMember) {
|
||||
resetMemberMessageState()
|
||||
finishBackgroundTask(taskId, 'completed', {
|
||||
detail: '当前群暂无可用成员数据',
|
||||
progressText: '0 条'
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
setSelectedMessageMemberUsername(targetMember.username)
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: `正在读取 ${targetMember.displayName || targetMember.username} 的发言记录`,
|
||||
progressText: '消息分页'
|
||||
})
|
||||
const page = await loadMemberMessagesPage(targetGroup, targetMember.username, { startTime, endTime })
|
||||
finishBackgroundTask(taskId, 'completed', {
|
||||
detail: `成员消息加载完成,已读取 ${page.messages.length} 条`,
|
||||
progressText: `${page.messages.length} 条`
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -399,7 +571,7 @@ function GroupAnalyticsPage() {
|
||||
detail: '正在计算群消息排行',
|
||||
progressText: '消息排行'
|
||||
})
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(targetGroup.username, 20, startTime, endTime)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息排行未继续写入' })
|
||||
return
|
||||
@@ -416,7 +588,7 @@ function GroupAnalyticsPage() {
|
||||
detail: '正在计算群活跃时段',
|
||||
progressText: '活跃时段'
|
||||
})
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime)
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(targetGroup.username, startTime, endTime)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群活跃时段未继续写入' })
|
||||
return
|
||||
@@ -433,7 +605,7 @@ function GroupAnalyticsPage() {
|
||||
detail: '正在统计群消息类型',
|
||||
progressText: '消息类型'
|
||||
})
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime)
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(targetGroup.username, startTime, endTime)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息类型统计未继续写入' })
|
||||
return
|
||||
@@ -523,12 +695,11 @@ function GroupAnalyticsPage() {
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (selectedFunction) {
|
||||
loadFunctionData(selectedFunction)
|
||||
void loadFunctionData(selectedFunction)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDateRangeComplete = () => {
|
||||
if (selectedFunction === 'memberExport') return
|
||||
setDateRangeReady(true)
|
||||
}
|
||||
|
||||
@@ -537,6 +708,69 @@ function GroupAnalyticsPage() {
|
||||
setCopiedField(null)
|
||||
}
|
||||
|
||||
const openSelectedGroupChat = () => {
|
||||
if (!selectedGroup) return
|
||||
void window.electronAPI.window.openSessionChatWindow(selectedGroup.username, {
|
||||
source: 'chat',
|
||||
initialDisplayName: selectedGroup.displayName || selectedGroup.username,
|
||||
initialAvatarUrl: selectedGroup.avatarUrl,
|
||||
initialContactType: 'group'
|
||||
})
|
||||
}
|
||||
|
||||
const handleMessageMemberSelect = async (memberUsername: string) => {
|
||||
if (!selectedGroup) return
|
||||
setSelectedMessageMemberUsername(memberUsername)
|
||||
setMessageMemberSearchKeyword('')
|
||||
setShowMessageMemberSelect(false)
|
||||
setFunctionLoading(true)
|
||||
try {
|
||||
const { startTime, endTime } = getSelectedTimeRange()
|
||||
await loadMemberMessagesPage(selectedGroup, memberUsername, { startTime, endTime })
|
||||
} catch (e) {
|
||||
console.error('读取成员消息失败:', e)
|
||||
alert(`读取成员消息失败:${String(e)}`)
|
||||
} finally {
|
||||
setFunctionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadMoreMemberMessages = async () => {
|
||||
if (!selectedGroup || !selectedMessageMemberUsername || !memberMessagesHasMore || memberMessagesLoadingMore) return
|
||||
setMemberMessagesLoadingMore(true)
|
||||
try {
|
||||
const { startTime, endTime } = getSelectedTimeRange()
|
||||
await loadMemberMessagesPage(selectedGroup, selectedMessageMemberUsername, {
|
||||
cursor: memberMessagesCursor,
|
||||
append: true,
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('加载更多成员消息失败:', e)
|
||||
alert(`加载更多成员消息失败:${String(e)}`)
|
||||
} finally {
|
||||
setMemberMessagesLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewMemberMessagesFromModal = async (member: GroupMember) => {
|
||||
if (!selectedGroup) return
|
||||
setSelectedMember(null)
|
||||
setSelectedFunction('memberMessages')
|
||||
setSelectedMessageMemberUsername(member.username)
|
||||
setMessageMemberSearchKeyword('')
|
||||
setShowMessageMemberSelect(false)
|
||||
await loadFunctionData('memberMessages', selectedGroup, member.username)
|
||||
}
|
||||
|
||||
const handleOpenMemberExportModal = () => {
|
||||
setShowMessageMemberSelect(false)
|
||||
setShowFormatSelect(false)
|
||||
setShowDisplayNameSelect(false)
|
||||
setShowMemberExportModal(true)
|
||||
}
|
||||
|
||||
const handleExportMembers = async () => {
|
||||
if (!selectedGroup || isExportingMembers) return
|
||||
setIsExportingMembers(true)
|
||||
@@ -554,13 +788,25 @@ function GroupAnalyticsPage() {
|
||||
|
||||
const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath)
|
||||
if (result.success) {
|
||||
alert(`导出成功,共 ${result.count ?? members.length} 人`)
|
||||
setExportResultDialog({
|
||||
title: '导出成功',
|
||||
message: `共导出 ${result.count ?? members.length} 人`,
|
||||
tone: 'success'
|
||||
})
|
||||
} else {
|
||||
alert(`导出失败:${result.error || '未知错误'}`)
|
||||
setExportResultDialog({
|
||||
title: '导出失败',
|
||||
message: result.error || '未知错误',
|
||||
tone: 'error'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('导出群成员失败:', e)
|
||||
alert(`导出失败:${String(e)}`)
|
||||
setExportResultDialog({
|
||||
title: '导出失败',
|
||||
message: String(e),
|
||||
tone: 'error'
|
||||
})
|
||||
} finally {
|
||||
setIsExportingMembers(false)
|
||||
}
|
||||
@@ -599,8 +845,8 @@ function GroupAnalyticsPage() {
|
||||
}
|
||||
|
||||
const handleExportMemberMessages = async () => {
|
||||
if (!selectedGroup || !selectedExportMemberUsername || !exportFolder || isExportingMemberMessages) return
|
||||
const member = members.find(item => item.username === selectedExportMemberUsername)
|
||||
if (!selectedGroup || !selectedMessageMemberUsername || !exportFolder || isExportingMemberMessages) return
|
||||
const member = members.find(item => item.username === selectedMessageMemberUsername)
|
||||
if (!member) {
|
||||
alert('请先选择成员')
|
||||
return
|
||||
@@ -634,13 +880,26 @@ function GroupAnalyticsPage() {
|
||||
}
|
||||
)
|
||||
if (result.success && (result.successCount ?? 0) > 0) {
|
||||
alert(`导出成功:${member.displayName || member.username}`)
|
||||
setShowMemberExportModal(false)
|
||||
setExportResultDialog({
|
||||
title: '导出成功',
|
||||
message: `已导出 ${member.displayName || member.username}`,
|
||||
tone: 'success'
|
||||
})
|
||||
} else {
|
||||
alert(`导出失败:${result.error || '未知错误'}`)
|
||||
setExportResultDialog({
|
||||
title: '导出失败',
|
||||
message: result.error || '未知错误',
|
||||
tone: 'error'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('导出成员消息失败:', e)
|
||||
alert(`导出失败:${String(e)}`)
|
||||
setExportResultDialog({
|
||||
title: '导出失败',
|
||||
message: String(e),
|
||||
tone: 'error'
|
||||
})
|
||||
} finally {
|
||||
setIsExportingMemberMessages(false)
|
||||
}
|
||||
@@ -719,6 +978,16 @@ function GroupAnalyticsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="member-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="member-modal-primary-btn"
|
||||
onClick={() => void handleViewMemberMessagesFromModal(selectedMember)}
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
<span>查看该成员消息</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -770,7 +1039,7 @@ function GroupAnalyticsPage() {
|
||||
filteredGroups.map(group => (
|
||||
<div
|
||||
key={group.username}
|
||||
className={`group-item ${selectedGroup?.username === group.username ? 'active' : ''}`}
|
||||
className={`group-item ${selectedGroupId === group.username ? 'active' : ''}`}
|
||||
onClick={() => handleGroupSelect(group)}
|
||||
>
|
||||
<div className="group-avatar">
|
||||
@@ -794,29 +1063,37 @@ function GroupAnalyticsPage() {
|
||||
<div className="group-avatar large">
|
||||
<Avatar src={selectedGroup?.avatarUrl} name={selectedGroup?.displayName} size={80} />
|
||||
</div>
|
||||
<h2>{selectedGroup?.displayName}</h2>
|
||||
<p>{selectedGroup?.memberCount} 位成员</p>
|
||||
<div className="selected-group-meta">
|
||||
<span className="group-summary-label">已选择群聊</span>
|
||||
<h2>{selectedGroup?.displayName}</h2>
|
||||
<p>{selectedGroup?.memberCount} 位成员</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="function-grid">
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('members')}>
|
||||
<Users size={32} />
|
||||
<span>群成员查看</span>
|
||||
<small>查看群成员列表和基础资料</small>
|
||||
</div>
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('memberExport')}>
|
||||
<Download size={32} />
|
||||
<span>成员消息导出</span>
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('memberMessages')}>
|
||||
<MessageSquare size={32} />
|
||||
<span>成员消息筛选与导出</span>
|
||||
<small>按成员查看群聊消息,并支持导出当前成员记录</small>
|
||||
</div>
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
||||
<BarChart3 size={32} />
|
||||
<span>群聊发言排行</span>
|
||||
<small>统计成员发言数量排行</small>
|
||||
</div>
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('activeHours')}>
|
||||
<Clock size={32} />
|
||||
<span>群聊活跃时段</span>
|
||||
<small>查看全天活跃时间分布</small>
|
||||
</div>
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('mediaStats')}>
|
||||
<Image size={32} />
|
||||
<span>媒体内容统计</span>
|
||||
<small>统计文本、图片、语音等类型</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -826,7 +1103,7 @@ function GroupAnalyticsPage() {
|
||||
const getFunctionTitle = () => {
|
||||
switch (selectedFunction) {
|
||||
case 'members': return '群成员查看'
|
||||
case 'memberExport': return '成员消息导出'
|
||||
case 'memberMessages': return '成员消息筛选与导出'
|
||||
case 'ranking': return '群聊发言排行'
|
||||
case 'activeHours': return '群聊活跃时段'
|
||||
case 'mediaStats': return '媒体内容统计'
|
||||
@@ -861,6 +1138,12 @@ function GroupAnalyticsPage() {
|
||||
<span>导出成员</span>
|
||||
</button>
|
||||
)}
|
||||
{selectedFunction === 'memberMessages' && (
|
||||
<button className="export-btn" onClick={openSelectedGroupChat}>
|
||||
<MessageSquare size={16} />
|
||||
<span>打开群聊</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
|
||||
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
@@ -882,58 +1165,57 @@ function GroupAnalyticsPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedFunction === 'memberExport' && (
|
||||
<div className="member-export-panel">
|
||||
{selectedFunction === 'memberMessages' && (
|
||||
<div className="member-messages-panel">
|
||||
{members.length === 0 ? (
|
||||
<div className="member-export-empty">暂无群成员数据,请先刷新。</div>
|
||||
<div className="member-message-empty">暂无群成员数据,请先刷新。</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="member-export-grid">
|
||||
<div className="member-export-field" ref={memberSelectDropdownRef}>
|
||||
<span>导出成员</span>
|
||||
<div className="member-message-summary-text">已加载 {memberMessages.length} 条消息</div>
|
||||
|
||||
<div className="member-message-toolbar">
|
||||
<div className="member-export-field" ref={messageMemberSelectDropdownRef}>
|
||||
<span>查看成员</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showMemberSelect ? 'open' : ''}`}
|
||||
className={`select-trigger member-message-select-trigger ${showMessageMemberSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowMemberSelect(prev => !prev)
|
||||
setShowMessageMemberSelect(prev => !prev)
|
||||
setShowFormatSelect(false)
|
||||
setShowDisplayNameSelect(false)
|
||||
}}
|
||||
>
|
||||
<div className="member-select-trigger-value">
|
||||
<Avatar
|
||||
src={selectedExportMember?.avatarUrl}
|
||||
name={selectedExportMember?.displayName || selectedExportMember?.username || '?'}
|
||||
src={selectedMessageMember?.avatarUrl}
|
||||
name={selectedMessageMember?.displayName || selectedMessageMember?.username || '?'}
|
||||
size={24}
|
||||
/>
|
||||
<span className="select-value">{selectedExportMember?.displayName || selectedExportMember?.username || '请选择成员'}</span>
|
||||
<span className="select-value">{selectedMessageMember?.displayName || selectedMessageMember?.username || '请选择成员'}</span>
|
||||
</div>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showMemberSelect && (
|
||||
{showMessageMemberSelect && (
|
||||
<div className="select-dropdown member-select-dropdown">
|
||||
<div className="member-select-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
value={memberSearchKeyword}
|
||||
onChange={e => setMemberSearchKeyword(e.target.value)}
|
||||
value={messageMemberSearchKeyword}
|
||||
onChange={e => setMessageMemberSearchKeyword(e.target.value)}
|
||||
placeholder="搜索 wxid / 昵称 / 备注 / 微信号"
|
||||
/>
|
||||
</div>
|
||||
<div className="member-select-options">
|
||||
{filteredMemberOptions.length === 0 ? (
|
||||
{filteredMessageMemberOptions.length === 0 ? (
|
||||
<div className="member-select-empty">无匹配成员</div>
|
||||
) : (
|
||||
filteredMemberOptions.map(member => (
|
||||
filteredMessageMemberOptions.map(member => (
|
||||
<button
|
||||
key={member.username}
|
||||
type="button"
|
||||
className={`select-option member-select-option ${selectedExportMemberUsername === member.username ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedExportMemberUsername(member.username)
|
||||
setShowMemberSelect(false)
|
||||
}}
|
||||
className={`select-option member-select-option ${selectedMessageMemberUsername === member.username ? 'active' : ''}`}
|
||||
onClick={() => void handleMessageMemberSelect(member.username)}
|
||||
>
|
||||
<Avatar src={member.avatarUrl} name={member.displayName} size={28} />
|
||||
<span className="member-option-main">{member.displayName || member.username}</span>
|
||||
@@ -942,6 +1224,7 @@ function GroupAnalyticsPage() {
|
||||
{member.alias ? ` · 微信号: ${member.alias}` : ''}
|
||||
{member.remark ? ` · 备注: ${member.remark}` : ''}
|
||||
{member.nickname ? ` · 昵称: ${member.nickname}` : ''}
|
||||
{member.groupNickname ? ` · 群昵称: ${member.groupNickname}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
@@ -950,162 +1233,51 @@ function GroupAnalyticsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="member-export-field" ref={formatDropdownRef}>
|
||||
<span>导出格式</span>
|
||||
<div className="member-message-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowFormatSelect(prev => !prev)
|
||||
setShowMemberSelect(false)
|
||||
setShowDisplayNameSelect(false)
|
||||
}}
|
||||
className="member-export-start-btn"
|
||||
onClick={handleOpenMemberExportModal}
|
||||
disabled={!selectedMessageMemberUsername}
|
||||
>
|
||||
<span className="select-value">{selectedFormatOption.label}</span>
|
||||
<ChevronDown size={16} />
|
||||
<Download size={16} />
|
||||
<span>导出</span>
|
||||
</button>
|
||||
{showFormatSelect && (
|
||||
<div className="select-dropdown">
|
||||
{memberExportFormatOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${memberExportOptions.format === option.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
handleMemberExportFormatChange(option.value)
|
||||
setShowFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="member-export-field member-export-folder">
|
||||
<span>导出目录</span>
|
||||
<div className="member-export-folder-row">
|
||||
<input value={exportFolder} readOnly placeholder="请选择导出目录" />
|
||||
<button type="button" onClick={handleChooseExportFolder}>
|
||||
选择目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-export-options">
|
||||
<div className="member-export-chip-group">
|
||||
<span className="chip-group-label">媒体导出</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportMedia ? 'active' : ''}`}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportMedia: !prev.exportMedia }))}
|
||||
>
|
||||
导出媒体文件
|
||||
</button>
|
||||
</div>
|
||||
<div className="member-export-chip-group">
|
||||
<span className="chip-group-label">媒体类型</span>
|
||||
<div className="member-export-chip-list">
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportImages ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||
disabled={!memberExportOptions.exportMedia}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportImages: !prev.exportImages }))}
|
||||
>
|
||||
图片
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportVoices ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||
disabled={!memberExportOptions.exportMedia}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoices: !prev.exportVoices }))}
|
||||
>
|
||||
语音
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportVideos ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||
disabled={!memberExportOptions.exportMedia}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVideos: !prev.exportVideos }))}
|
||||
>
|
||||
视频
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportEmojis ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||
disabled={!memberExportOptions.exportMedia}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportEmojis: !prev.exportEmojis }))}
|
||||
>
|
||||
表情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="member-export-chip-group">
|
||||
<span className="chip-group-label">附加选项</span>
|
||||
<div className="member-export-chip-list">
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportVoiceAsText ? 'active' : ''}`}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoiceAsText: !prev.exportVoiceAsText }))}
|
||||
>
|
||||
语音转文字
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportAvatars ? 'active' : ''}`}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportAvatars: !prev.exportAvatars }))}
|
||||
>
|
||||
导出头像
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="member-export-field" ref={displayNameDropdownRef}>
|
||||
<span>显示名称规则</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowDisplayNameSelect(prev => !prev)
|
||||
setShowMemberSelect(false)
|
||||
setShowFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{selectedDisplayNameOption.label}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showDisplayNameSelect && (
|
||||
<div className="select-dropdown">
|
||||
{displayNameOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${memberExportOptions.displayNamePreference === option.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setMemberExportOptions(prev => ({ ...prev, displayNamePreference: option.value }))
|
||||
setShowDisplayNameSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
{memberMessages.length === 0 ? (
|
||||
<div className="member-message-empty">当前时间范围内暂无该成员消息。</div>
|
||||
) : (
|
||||
<div className="member-message-list">
|
||||
{memberMessages.map(message => (
|
||||
<div key={message.messageKey || `${message.localId}-${message.createTime}`} className="member-message-item">
|
||||
<div className="member-message-meta">
|
||||
<span className="member-message-time">{formatMemberMessageTime(message.createTime)}</span>
|
||||
<span className="member-message-type">{getMemberMessageTypeLabel(message)}</span>
|
||||
</div>
|
||||
<div className="member-message-content">{getMemberMessagePreview(message)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(memberMessagesHasMore || memberMessages.length > 0) && (
|
||||
<div className="member-message-actions">
|
||||
{memberMessagesHasMore ? (
|
||||
<button
|
||||
type="button"
|
||||
className="member-message-load-more"
|
||||
disabled={memberMessagesLoadingMore}
|
||||
onClick={() => void handleLoadMoreMemberMessages()}
|
||||
>
|
||||
{memberMessagesLoadingMore ? <Loader2 size={16} className="spin" /> : null}
|
||||
<span>{memberMessagesLoadingMore ? '加载中...' : '加载更多'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="member-message-end">已显示当前可读取的全部消息</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-export-actions">
|
||||
<button
|
||||
className="member-export-start-btn"
|
||||
onClick={handleExportMemberMessages}
|
||||
disabled={isExportingMemberMessages || !selectedExportMemberUsername || !exportFolder}
|
||||
>
|
||||
{isExportingMemberMessages ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
|
||||
<span>{isExportingMemberMessages ? '导出中...' : '开始导出'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1171,18 +1343,226 @@ function GroupAnalyticsPage() {
|
||||
|
||||
|
||||
const renderDetailPanel = () => {
|
||||
if (selectedFunction) {
|
||||
return renderFunctionContent()
|
||||
}
|
||||
|
||||
if (!selectedGroup) {
|
||||
return (
|
||||
<div className="placeholder">
|
||||
<Users size={64} />
|
||||
<>
|
||||
<div className="detail-drag-region" aria-hidden="true" />
|
||||
<div className="placeholder">
|
||||
<Users size={64} />
|
||||
<p>请从左侧选择一个群聊进行分析</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (!selectedFunction) {
|
||||
return renderFunctionMenu()
|
||||
}
|
||||
return renderFunctionContent()
|
||||
return (
|
||||
<>
|
||||
<div className="detail-drag-region" aria-hidden="true" />
|
||||
{renderFunctionMenu()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderMemberExportModal = () => {
|
||||
if (!showMemberExportModal) return null
|
||||
|
||||
return (
|
||||
<div className="member-modal-overlay" onClick={() => setShowMemberExportModal(false)}>
|
||||
<div className="member-export-modal" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={() => setShowMemberExportModal(false)}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="member-export-modal-header">
|
||||
<h3>导出成员消息</h3>
|
||||
<p>{selectedMessageMember?.displayName || selectedMessageMember?.username || '未选择成员'}</p>
|
||||
</div>
|
||||
|
||||
<div className="member-export-panel">
|
||||
<div className="member-export-grid">
|
||||
<div className="member-export-field" ref={formatDropdownRef}>
|
||||
<span>导出格式</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowFormatSelect(prev => !prev)
|
||||
setShowDisplayNameSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{selectedFormatOption.label}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showFormatSelect && (
|
||||
<div className="select-dropdown">
|
||||
{memberExportFormatOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${memberExportOptions.format === option.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
handleMemberExportFormatChange(option.value)
|
||||
setShowFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="member-export-field member-export-folder">
|
||||
<span>导出目录</span>
|
||||
<div className="member-export-folder-row">
|
||||
<input value={exportFolder} readOnly placeholder="请选择导出目录" />
|
||||
<button type="button" onClick={handleChooseExportFolder}>
|
||||
选择目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-export-options">
|
||||
<div className="member-export-chip-group">
|
||||
<span className="chip-group-label">媒体导出</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportMedia ? 'active' : ''}`}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportMedia: !prev.exportMedia }))}
|
||||
>
|
||||
导出媒体文件
|
||||
</button>
|
||||
</div>
|
||||
<div className="member-export-chip-group">
|
||||
<span className="chip-group-label">媒体类型</span>
|
||||
<div className="member-export-chip-list">
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportImages ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||
disabled={!memberExportOptions.exportMedia}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportImages: !prev.exportImages }))}
|
||||
>
|
||||
图片
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportVoices ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||
disabled={!memberExportOptions.exportMedia}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoices: !prev.exportVoices }))}
|
||||
>
|
||||
语音
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportVideos ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||
disabled={!memberExportOptions.exportMedia}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVideos: !prev.exportVideos }))}
|
||||
>
|
||||
视频
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportEmojis ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||
disabled={!memberExportOptions.exportMedia}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportEmojis: !prev.exportEmojis }))}
|
||||
>
|
||||
表情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="member-export-chip-group">
|
||||
<span className="chip-group-label">附加选项</span>
|
||||
<div className="member-export-chip-list">
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportVoiceAsText ? 'active' : ''}`}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoiceAsText: !prev.exportVoiceAsText }))}
|
||||
>
|
||||
语音转文字
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`export-filter-chip ${memberExportOptions.exportAvatars ? 'active' : ''}`}
|
||||
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportAvatars: !prev.exportAvatars }))}
|
||||
>
|
||||
导出头像
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="member-export-field" ref={displayNameDropdownRef}>
|
||||
<span>显示名称规则</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowDisplayNameSelect(prev => !prev)
|
||||
setShowFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{selectedDisplayNameOption.label}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showDisplayNameSelect && (
|
||||
<div className="select-dropdown">
|
||||
{displayNameOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${memberExportOptions.displayNamePreference === option.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setMemberExportOptions(prev => ({ ...prev, displayNamePreference: option.value }))
|
||||
setShowDisplayNameSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-export-actions">
|
||||
<button
|
||||
className="member-export-start-btn"
|
||||
onClick={handleExportMemberMessages}
|
||||
disabled={isExportingMemberMessages || !selectedMessageMemberUsername || !exportFolder}
|
||||
>
|
||||
{isExportingMemberMessages ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
|
||||
<span>{isExportingMemberMessages ? '导出中...' : '开始导出'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderExportResultDialog = () => {
|
||||
if (!exportResultDialog) return null
|
||||
|
||||
return (
|
||||
<div className="member-modal-overlay" onClick={() => setExportResultDialog(null)}>
|
||||
<div className={`member-result-modal ${exportResultDialog.tone}`} onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={() => setExportResultDialog(null)}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="member-result-modal-body">
|
||||
<h3>{exportResultDialog.title}</h3>
|
||||
<p>{exportResultDialog.message}</p>
|
||||
</div>
|
||||
<div className="member-result-modal-actions">
|
||||
<button type="button" className="member-result-modal-btn" onClick={() => setExportResultDialog(null)}>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1196,8 +1576,11 @@ function GroupAnalyticsPage() {
|
||||
</div>
|
||||
</div>
|
||||
{renderMemberModal()}
|
||||
{renderMemberExportModal()}
|
||||
{renderExportResultDialog()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupAnalyticsPage
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes noti-enter-center {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px) scale(0.7);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes noti-exit {
|
||||
0% {
|
||||
opacity: 1;
|
||||
@@ -24,6 +36,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes noti-exit-center {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px) scale(0.7);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
// Ensure the body background is transparent to let the rounded corners show
|
||||
background: transparent;
|
||||
@@ -41,6 +65,10 @@ body {
|
||||
// New notification slides in
|
||||
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
will-change: transform, opacity;
|
||||
|
||||
&.anim-center {
|
||||
animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
#notification-prev {
|
||||
@@ -51,4 +79,8 @@ body {
|
||||
|
||||
// Ensure it stays behind
|
||||
z-index: 0 !important;
|
||||
|
||||
&.anim-center {
|
||||
animation: noti-exit-center 0.5s cubic-bezier(0.33, 1, 0.68, 1) forwards;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ import './NotificationWindow.scss'
|
||||
export default function NotificationWindow() {
|
||||
const [notification, setNotification] = useState<NotificationData | null>(null)
|
||||
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
||||
const [position, setPosition] = useState<string>('top-right')
|
||||
|
||||
// We need a ref to access the current notification inside the callback
|
||||
// We need a ref to access the current notification inside the callback
|
||||
// without satisfying the dependency array which would recreate the listener
|
||||
// Actually, setNotification(prev => ...) pattern is better, but we need the VALUE of current to set as prev.
|
||||
// So we use setNotification callback: setNotification(current => { ... return newNode })
|
||||
@@ -34,6 +35,11 @@ export default function NotificationWindow() {
|
||||
avatarUrl: data.avatarUrl
|
||||
}
|
||||
|
||||
// 获取位置配置
|
||||
if (data.position) {
|
||||
setPosition(data.position)
|
||||
}
|
||||
|
||||
// Set previous to current (ref)
|
||||
if (notificationRef.current) {
|
||||
setPrevNotification(notificationRef.current)
|
||||
@@ -117,6 +123,7 @@ export default function NotificationWindow() {
|
||||
<div
|
||||
id="notification-prev"
|
||||
key={prevNotification.id}
|
||||
className={position === 'top-center' ? 'anim-center' : ''}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2, // Match padding
|
||||
@@ -131,7 +138,7 @@ export default function NotificationWindow() {
|
||||
data={prevNotification}
|
||||
onClose={() => { }} // No-op for background item
|
||||
onClick={() => { }}
|
||||
position="top-right"
|
||||
position={position as any}
|
||||
isStatic={true}
|
||||
initialVisible={true}
|
||||
/>
|
||||
@@ -143,6 +150,7 @@ export default function NotificationWindow() {
|
||||
<div
|
||||
id="notification-current"
|
||||
key={notification.id}
|
||||
className={position === 'top-center' ? 'anim-center' : ''}
|
||||
style={{
|
||||
position: 'relative', // Takes up space
|
||||
zIndex: 2,
|
||||
@@ -154,7 +162,7 @@ export default function NotificationWindow() {
|
||||
data={notification}
|
||||
onClose={handleClose}
|
||||
onClick={handleClick}
|
||||
position="top-right"
|
||||
position={position as any}
|
||||
isStatic={true}
|
||||
initialVisible={true}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.settings-modal-overlay {
|
||||
position: fixed;
|
||||
top: 41px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
@@ -1705,7 +1705,7 @@
|
||||
|
||||
.wxid-dialog-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
@@ -1743,6 +1743,66 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.wxid-profile-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.wxid-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wxid-avatar-fallback {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.wxid-info-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-profile-mini {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.wxid-avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wxid-avatar-fallback {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.wxid-info-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
// 通知过滤双列表容器
|
||||
.notification-filter-container {
|
||||
display: grid;
|
||||
|
||||
+88
-14
@@ -10,7 +10,7 @@ import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
|
||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X
|
||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X, UserRound
|
||||
} from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import './SettingsPage.scss'
|
||||
@@ -34,6 +34,8 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
interface WxidOption {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
interface SettingsPageProps {
|
||||
@@ -102,12 +104,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(true)
|
||||
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
|
||||
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
|
||||
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
|
||||
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
||||
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
|
||||
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
|
||||
|
||||
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
|
||||
const [excludeWordsInput, setExcludeWordsInput] = useState('')
|
||||
@@ -251,15 +255,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
if (!target.closest('.custom-select')) {
|
||||
setFilterModeDropdownOpen(false)
|
||||
setPositionDropdownOpen(false)
|
||||
setCloseBehaviorDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
if (filterModeDropdownOpen || positionDropdownOpen) {
|
||||
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [filterModeDropdownOpen, positionDropdownOpen])
|
||||
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen])
|
||||
|
||||
|
||||
const loadConfig = async () => {
|
||||
@@ -281,6 +286,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedNotificationPosition = await configService.getNotificationPosition()
|
||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||
|
||||
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||
@@ -316,6 +322,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setNotificationPosition(savedNotificationPosition)
|
||||
setNotificationFilterMode(savedNotificationFilterMode)
|
||||
setNotificationFilterList(savedNotificationFilterList)
|
||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||
|
||||
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
||||
setWordCloudExcludeWords(savedExcludeWords)
|
||||
@@ -1022,6 +1029,61 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>关闭主窗口时</label>
|
||||
<span className="form-hint">设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。</span>
|
||||
<div className="custom-select">
|
||||
<div
|
||||
className={`custom-select-trigger ${closeBehaviorDropdownOpen ? 'open' : ''}`}
|
||||
onClick={() => setCloseBehaviorDropdownOpen(!closeBehaviorDropdownOpen)}
|
||||
>
|
||||
<span className="custom-select-value">
|
||||
{windowCloseBehavior === 'tray'
|
||||
? '最小化到系统托盘'
|
||||
: windowCloseBehavior === 'quit'
|
||||
? '完全关闭'
|
||||
: '每次询问'}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`custom-select-arrow ${closeBehaviorDropdownOpen ? 'rotate' : ''}`} />
|
||||
</div>
|
||||
<div className={`custom-select-dropdown ${closeBehaviorDropdownOpen ? 'open' : ''}`}>
|
||||
{[
|
||||
{
|
||||
value: 'ask' as const,
|
||||
label: '每次询问',
|
||||
successMessage: '已恢复关闭确认弹窗'
|
||||
},
|
||||
{
|
||||
value: 'tray' as const,
|
||||
label: '最小化到系统托盘',
|
||||
successMessage: '关闭按钮已改为最小化到托盘'
|
||||
},
|
||||
{
|
||||
value: 'quit' as const,
|
||||
label: '完全关闭',
|
||||
successMessage: '关闭按钮已改为完全关闭'
|
||||
}
|
||||
].map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-select-option ${windowCloseBehavior === option.value ? 'selected' : ''}`}
|
||||
onClick={async () => {
|
||||
setWindowCloseBehavior(option.value)
|
||||
setCloseBehaviorDropdownOpen(false)
|
||||
await configService.setWindowCloseBehavior(option.value)
|
||||
showMessage(option.successMessage, true)
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
{windowCloseBehavior === option.value && <Check size={14} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1102,12 +1164,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<span className="custom-select-value">
|
||||
{notificationPosition === 'top-right' ? '右上角' :
|
||||
notificationPosition === 'bottom-right' ? '右下角' :
|
||||
notificationPosition === 'top-left' ? '左上角' : '左下角'}
|
||||
notificationPosition === 'top-left' ? '左上角' :
|
||||
notificationPosition === 'top-center' ? '中间上方' : '左下角'}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`custom-select-arrow ${positionDropdownOpen ? 'rotate' : ''}`} />
|
||||
</div>
|
||||
<div className={`custom-select-dropdown ${positionDropdownOpen ? 'open' : ''}`}>
|
||||
{[
|
||||
{ value: 'top-center', label: '中间上方' },
|
||||
{ value: 'top-right', label: '右上角' },
|
||||
{ value: 'bottom-right', label: '右下角' },
|
||||
{ value: 'top-left', label: '左上角' },
|
||||
@@ -1117,7 +1181,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
key={option.value}
|
||||
className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`}
|
||||
onClick={async () => {
|
||||
const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
setNotificationPosition(val)
|
||||
setPositionDropdownOpen(false)
|
||||
await configService.setNotificationPosition(val)
|
||||
@@ -2130,14 +2194,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
<div className="wxid-dialog-list">
|
||||
{wxidOptions.map((opt) => (
|
||||
<div
|
||||
key={opt.wxid}
|
||||
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
|
||||
onClick={() => handleSelectWxid(opt.wxid)}
|
||||
>
|
||||
<span className="wxid-id">{opt.wxid}</span>
|
||||
<span className="wxid-date">最后修改 {new Date(opt.modifiedTime).toLocaleString()}</span>
|
||||
</div>
|
||||
<div
|
||||
key={opt.wxid}
|
||||
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
|
||||
onClick={() => handleSelectWxid(opt.wxid)}
|
||||
>
|
||||
<div className="wxid-profile-row">
|
||||
{opt.avatarUrl ? (
|
||||
<img src={opt.avatarUrl} alt="avatar" className="wxid-avatar" />
|
||||
) : (
|
||||
<div className="wxid-avatar-fallback"><UserRound size={18}/></div>
|
||||
)}
|
||||
<div className="wxid-info-col">
|
||||
<span className="wxid-id">{opt.nickname || opt.wxid}</span>
|
||||
{opt.nickname && <span className="wxid-date">{opt.wxid}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="wxid-date" style={{marginLeft: 'auto'}}>最后修改 {new Date(opt.modifiedTime).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="wxid-dialog-footer">
|
||||
|
||||
@@ -488,6 +488,48 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wxid-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wxid-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wxid-avatar-fallback {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.wxid-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wxid-nickname {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wxid-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.field-with-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
+32
-17
@@ -47,7 +47,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const [imageAesKey, setImageAesKey] = useState('')
|
||||
const [cachePath, setCachePath] = useState('')
|
||||
const [wxid, setWxid] = useState('')
|
||||
const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([])
|
||||
const [wxidOptions, setWxidOptions] = useState<Array<{
|
||||
avatarUrl?: string;
|
||||
nickname?: string;
|
||||
wxid: string;
|
||||
modifiedTime: number
|
||||
}>>([])
|
||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||
const wxidSelectRef = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState('')
|
||||
@@ -688,22 +693,32 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
onChange={(e) => setWxid(e.target.value)}
|
||||
/>
|
||||
{showWxidSelect && wxidOptions.length > 0 && (
|
||||
<div className="wxid-dropdown">
|
||||
{wxidOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.wxid}
|
||||
type="button"
|
||||
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setWxid(opt.wxid)
|
||||
setShowWxidSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="wxid-name">{opt.wxid}</span>
|
||||
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="wxid-dropdown">
|
||||
{wxidOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.wxid}
|
||||
type="button"
|
||||
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setWxid(opt.wxid)
|
||||
setShowWxidSelect(false)
|
||||
}}
|
||||
>
|
||||
<div className="wxid-profile">
|
||||
{opt.avatarUrl ? (
|
||||
<img src={opt.avatarUrl} alt="avatar" className="wxid-avatar" />
|
||||
) : (
|
||||
<div className="wxid-avatar-fallback"><UserRound size={14}/></div>
|
||||
)}
|
||||
<div className="wxid-info">
|
||||
<span className="wxid-nickname">{opt.nickname || opt.wxid}</span>
|
||||
{opt.nickname && <span className="wxid-sub">{opt.wxid}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export const CONFIG_KEYS = {
|
||||
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
||||
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
||||
EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP: 'exportSessionMutualFriendsCacheMap',
|
||||
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
||||
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
||||
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
||||
@@ -62,6 +63,7 @@ export const CONFIG_KEYS = {
|
||||
NOTIFICATION_POSITION: 'notificationPosition',
|
||||
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||
|
||||
// 词云
|
||||
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
|
||||
@@ -85,6 +87,8 @@ export interface ExportDefaultMediaConfig {
|
||||
emojis: boolean
|
||||
}
|
||||
|
||||
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||
|
||||
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
||||
images: true,
|
||||
videos: true,
|
||||
@@ -593,6 +597,34 @@ export interface ExportSnsUserPostCountsCacheItem {
|
||||
counts: Record<string, number>
|
||||
}
|
||||
|
||||
export type ExportSessionMutualFriendDirection = 'incoming' | 'outgoing' | 'bidirectional'
|
||||
export type ExportSessionMutualFriendBehavior = 'likes' | 'comments' | 'both'
|
||||
|
||||
export interface ExportSessionMutualFriendCacheItem {
|
||||
name: string
|
||||
incomingLikeCount: number
|
||||
incomingCommentCount: number
|
||||
outgoingLikeCount: number
|
||||
outgoingCommentCount: number
|
||||
totalCount: number
|
||||
latestTime: number
|
||||
direction: ExportSessionMutualFriendDirection
|
||||
behavior: ExportSessionMutualFriendBehavior
|
||||
}
|
||||
|
||||
export interface ExportSessionMutualFriendsCacheEntry {
|
||||
count: number
|
||||
items: ExportSessionMutualFriendCacheItem[]
|
||||
loadedPosts: number
|
||||
totalPosts: number | null
|
||||
computedAt: number
|
||||
}
|
||||
|
||||
export interface ExportSessionMutualFriendsCacheItem {
|
||||
updatedAt: number
|
||||
metrics: Record<string, ExportSessionMutualFriendsCacheEntry>
|
||||
}
|
||||
|
||||
export interface SnsPageOverviewCache {
|
||||
totalPosts: number
|
||||
totalFriends: number
|
||||
@@ -852,6 +884,148 @@ export async function setExportSnsUserPostCountsCache(
|
||||
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
|
||||
}
|
||||
|
||||
const normalizeMutualFriendDirection = (value: unknown): ExportSessionMutualFriendDirection | null => {
|
||||
if (value === 'incoming' || value === 'outgoing' || value === 'bidirectional') {
|
||||
return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizeMutualFriendBehavior = (value: unknown): ExportSessionMutualFriendBehavior | null => {
|
||||
if (value === 'likes' || value === 'comments' || value === 'both') {
|
||||
return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizeExportSessionMutualFriendsCacheEntry = (raw: unknown): ExportSessionMutualFriendsCacheEntry | null => {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
const count = Number(source.count)
|
||||
const loadedPosts = Number(source.loadedPosts)
|
||||
const computedAt = Number(source.computedAt)
|
||||
const itemsRaw = Array.isArray(source.items) ? source.items : []
|
||||
const totalPostsRaw = source.totalPosts
|
||||
const totalPosts = totalPostsRaw === null || totalPostsRaw === undefined
|
||||
? null
|
||||
: Number(totalPostsRaw)
|
||||
|
||||
if (!Number.isFinite(count) || count < 0 || !Number.isFinite(loadedPosts) || loadedPosts < 0 || !Number.isFinite(computedAt) || computedAt < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items: ExportSessionMutualFriendCacheItem[] = []
|
||||
for (const itemRaw of itemsRaw) {
|
||||
if (!itemRaw || typeof itemRaw !== 'object') continue
|
||||
const item = itemRaw as Record<string, unknown>
|
||||
const name = String(item.name || '').trim()
|
||||
const direction = normalizeMutualFriendDirection(item.direction)
|
||||
const behavior = normalizeMutualFriendBehavior(item.behavior)
|
||||
const incomingLikeCount = Number(item.incomingLikeCount)
|
||||
const incomingCommentCount = Number(item.incomingCommentCount)
|
||||
const outgoingLikeCount = Number(item.outgoingLikeCount)
|
||||
const outgoingCommentCount = Number(item.outgoingCommentCount)
|
||||
const totalCount = Number(item.totalCount)
|
||||
const latestTime = Number(item.latestTime)
|
||||
if (!name || !direction || !behavior) continue
|
||||
if (
|
||||
!Number.isFinite(incomingLikeCount) || incomingLikeCount < 0 ||
|
||||
!Number.isFinite(incomingCommentCount) || incomingCommentCount < 0 ||
|
||||
!Number.isFinite(outgoingLikeCount) || outgoingLikeCount < 0 ||
|
||||
!Number.isFinite(outgoingCommentCount) || outgoingCommentCount < 0 ||
|
||||
!Number.isFinite(totalCount) || totalCount < 0 ||
|
||||
!Number.isFinite(latestTime) || latestTime < 0
|
||||
) {
|
||||
continue
|
||||
}
|
||||
items.push({
|
||||
name,
|
||||
incomingLikeCount: Math.floor(incomingLikeCount),
|
||||
incomingCommentCount: Math.floor(incomingCommentCount),
|
||||
outgoingLikeCount: Math.floor(outgoingLikeCount),
|
||||
outgoingCommentCount: Math.floor(outgoingCommentCount),
|
||||
totalCount: Math.floor(totalCount),
|
||||
latestTime: Math.floor(latestTime),
|
||||
direction,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
count: Math.floor(count),
|
||||
items,
|
||||
loadedPosts: Math.floor(loadedPosts),
|
||||
totalPosts: totalPosts === null
|
||||
? null
|
||||
: (Number.isFinite(totalPosts) && totalPosts >= 0 ? Math.floor(totalPosts) : null),
|
||||
computedAt: Math.floor(computedAt)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExportSessionMutualFriendsCache(scopeKey: string): Promise<ExportSessionMutualFriendsCacheItem | null> {
|
||||
if (!scopeKey) return null
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const rawMap = value as Record<string, unknown>
|
||||
const rawItem = rawMap[scopeKey]
|
||||
if (!rawItem || typeof rawItem !== 'object') return null
|
||||
|
||||
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||
const rawMetrics = (rawItem as Record<string, unknown>).metrics
|
||||
if (!rawMetrics || typeof rawMetrics !== 'object') return null
|
||||
|
||||
const metrics: Record<string, ExportSessionMutualFriendsCacheEntry> = {}
|
||||
for (const [sessionIdRaw, metricRaw] of Object.entries(rawMetrics as Record<string, unknown>)) {
|
||||
const sessionId = String(sessionIdRaw || '').trim()
|
||||
if (!sessionId) continue
|
||||
const metric = normalizeExportSessionMutualFriendsCacheEntry(metricRaw)
|
||||
if (!metric) continue
|
||||
metrics[sessionId] = metric
|
||||
}
|
||||
|
||||
return {
|
||||
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||
metrics
|
||||
}
|
||||
}
|
||||
|
||||
export async function setExportSessionMutualFriendsCache(
|
||||
scopeKey: string,
|
||||
metrics: Record<string, ExportSessionMutualFriendsCacheEntry>
|
||||
): Promise<void> {
|
||||
if (!scopeKey) return
|
||||
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||
const map = current && typeof current === 'object'
|
||||
? { ...(current as Record<string, unknown>) }
|
||||
: {}
|
||||
|
||||
const normalized: Record<string, ExportSessionMutualFriendsCacheEntry> = {}
|
||||
for (const [sessionIdRaw, metricRaw] of Object.entries(metrics || {})) {
|
||||
const sessionId = String(sessionIdRaw || '').trim()
|
||||
if (!sessionId) continue
|
||||
const metric = normalizeExportSessionMutualFriendsCacheEntry(metricRaw)
|
||||
if (!metric) continue
|
||||
normalized[sessionId] = metric
|
||||
}
|
||||
|
||||
map[scopeKey] = {
|
||||
updatedAt: Date.now(),
|
||||
metrics: normalized
|
||||
}
|
||||
|
||||
await config.set(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP, map)
|
||||
}
|
||||
|
||||
export async function clearExportSessionMutualFriendsCache(scopeKey: string): Promise<void> {
|
||||
if (!scopeKey) return
|
||||
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||
if (!current || typeof current !== 'object') return
|
||||
const map = { ...(current as Record<string, unknown>) }
|
||||
if (!(scopeKey in map)) return
|
||||
delete map[scopeKey]
|
||||
await config.set(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP, map)
|
||||
}
|
||||
|
||||
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
||||
if (!scopeKey) return null
|
||||
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
||||
@@ -1188,6 +1362,16 @@ export async function setNotificationFilterList(list: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
|
||||
}
|
||||
|
||||
export async function getWindowCloseBehavior(): Promise<WindowCloseBehavior> {
|
||||
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
|
||||
if (value === 'tray' || value === 'quit') return value
|
||||
return 'ask'
|
||||
}
|
||||
|
||||
export async function setWindowCloseBehavior(behavior: WindowCloseBehavior): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR, behavior)
|
||||
}
|
||||
|
||||
// 获取词云排除词列表
|
||||
export async function getWordCloudExcludeWords(): Promise<string[]> {
|
||||
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)
|
||||
|
||||
@@ -81,10 +81,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
appendMessages: (newMessages, prepend = false) => set((state) => {
|
||||
// 强制去重逻辑
|
||||
const getMsgKey = (m: Message) => {
|
||||
if (m.localId && m.localId > 0) return `l:${m.localId}`
|
||||
return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}`
|
||||
if (m.messageKey) return m.messageKey
|
||||
return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}`
|
||||
}
|
||||
const currentMessages = state.messages || []
|
||||
const existingKeys = new Set(currentMessages.map(getMsgKey))
|
||||
|
||||
Vendored
+18
@@ -14,6 +14,8 @@ export interface ElectronAPI {
|
||||
isMaximized: () => Promise<boolean>
|
||||
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void
|
||||
close: () => void
|
||||
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => () => void
|
||||
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise<boolean>
|
||||
openAgreementWindow: () => Promise<boolean>
|
||||
completeOnboarding: () => Promise<boolean>
|
||||
openOnboardingWindow: () => Promise<boolean>
|
||||
@@ -183,12 +185,14 @@ export interface ElectronAPI {
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
hasMore?: boolean;
|
||||
nextOffset?: number;
|
||||
error?: string
|
||||
}>
|
||||
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
|
||||
success: boolean
|
||||
messages?: Message[]
|
||||
hasMore?: boolean
|
||||
nextOffset?: number
|
||||
error?: string
|
||||
}>
|
||||
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
|
||||
@@ -219,6 +223,7 @@ export interface ElectronAPI {
|
||||
}>
|
||||
getMyAvatarUrl: () => Promise<{ success: boolean; avatarUrl?: string; error?: string }>
|
||||
downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => Promise<{ success: boolean; messages?: Message[]; error?: string }>
|
||||
close: () => Promise<boolean>
|
||||
getSessionDetail: (sessionId: string) => Promise<{
|
||||
success: boolean
|
||||
@@ -489,6 +494,19 @@ export interface ElectronAPI {
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
getGroupMemberMessages: (
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
messages: Message[]
|
||||
hasMore: boolean
|
||||
nextCursor: number
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
|
||||
success: boolean
|
||||
count?: number
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ChatSession {
|
||||
selfWxid?: string // Helper field to avoid extra API calls
|
||||
isFolded?: boolean // 是否已折叠进"折叠的群聊"
|
||||
isMuted?: boolean // 是否开启免打扰
|
||||
alias?: string // 微信号
|
||||
matchedField?: 'wxid' | 'alias' | 'name' // 搜索匹配的字段
|
||||
}
|
||||
|
||||
// 联系人
|
||||
@@ -41,6 +43,7 @@ export interface ContactInfo {
|
||||
|
||||
// 消息
|
||||
export interface Message {
|
||||
messageKey: string
|
||||
localId: number
|
||||
serverId: number
|
||||
localType: number
|
||||
@@ -105,6 +108,10 @@ export interface Message {
|
||||
// 聊天记录
|
||||
chatRecordTitle?: string // 聊天记录标题
|
||||
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
||||
_db_path?: string
|
||||
// 运行时补充的发送者信息
|
||||
senderDisplayName?: string
|
||||
senderAvatarUrl?: string
|
||||
}
|
||||
|
||||
// 聊天记录项
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
export class AvatarLoadQueue {
|
||||
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
|
||||
private loading = new Map<string, Promise<void>>()
|
||||
private failed = new Map<string, number>()
|
||||
private activeCount = 0
|
||||
private readonly maxConcurrent = 3
|
||||
private readonly delayBetweenBatches = 10
|
||||
private readonly failedTtlMs = 10 * 60 * 1000
|
||||
|
||||
private static instance: AvatarLoadQueue
|
||||
|
||||
@@ -18,6 +20,9 @@ export class AvatarLoadQueue {
|
||||
|
||||
async enqueue(url: string): Promise<void> {
|
||||
if (!url) return Promise.resolve()
|
||||
if (this.hasFailed(url)) {
|
||||
return Promise.reject(new Error(`Failed: ${url}`))
|
||||
}
|
||||
|
||||
// 核心修复:防止重复并发请求同一个 URL
|
||||
const existingPromise = this.loading.get(url)
|
||||
@@ -31,13 +36,40 @@ export class AvatarLoadQueue {
|
||||
})
|
||||
|
||||
this.loading.set(url, loadPromise)
|
||||
loadPromise.finally(() => {
|
||||
this.loading.delete(url)
|
||||
})
|
||||
void loadPromise.then(
|
||||
() => {
|
||||
this.loading.delete(url)
|
||||
this.clearFailed(url)
|
||||
},
|
||||
() => {
|
||||
this.loading.delete(url)
|
||||
}
|
||||
)
|
||||
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
hasFailed(url: string): boolean {
|
||||
if (!url) return false
|
||||
const failedAt = this.failed.get(url)
|
||||
if (!failedAt) return false
|
||||
if (Date.now() - failedAt > this.failedTtlMs) {
|
||||
this.failed.delete(url)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
markFailed(url: string) {
|
||||
if (!url) return
|
||||
this.failed.set(url, Date.now())
|
||||
}
|
||||
|
||||
clearFailed(url: string) {
|
||||
if (!url) return
|
||||
this.failed.delete(url)
|
||||
}
|
||||
|
||||
private async processQueue() {
|
||||
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
|
||||
return
|
||||
@@ -49,13 +81,16 @@ export class AvatarLoadQueue {
|
||||
this.activeCount++
|
||||
|
||||
const img = new Image()
|
||||
img.referrerPolicy = 'no-referrer'
|
||||
img.onload = () => {
|
||||
this.activeCount--
|
||||
this.clearFailed(task.url)
|
||||
task.resolve()
|
||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||
}
|
||||
img.onerror = () => {
|
||||
this.activeCount--
|
||||
this.markFailed(task.url)
|
||||
task.reject(new Error(`Failed: ${task.url}`))
|
||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||
}
|
||||
@@ -67,6 +102,7 @@ export class AvatarLoadQueue {
|
||||
clear() {
|
||||
this.queue = []
|
||||
this.loading.clear()
|
||||
this.failed.clear()
|
||||
this.activeCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user