Merge pull request #468 from hicccc77/dev

Dev
This commit is contained in:
cc authored and GitHub committed 2026-03-16 21:30:00 +08:00
commit bb9b7bcf9f
48 files changed
+5394 -943

No files matched your search

+59
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
blank_issues_enabled: false
contact_links:
- name: 🤔 找不到合适的模板?
about: 如果你只是想闲聊或者你的问题不属于上述任何分类,请前往我们的的Telegram频道与我们交流。
+24
View File
@@ -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
+25
View File
@@ -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
+35
View File
@@ -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` 标签)
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
+14
View File
@@ -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
View File
@@ -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)
+3 -1
View File
@@ -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: []
}
})
+108 -12
View File
@@ -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;
}
/**
+35 -6
View File
@@ -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 }>()
+134 -7
View File
@@ -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()
+72 -2
View File
@@ -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)
+6 -1
View File
@@ -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)
+167 -49
View File
@@ -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
}
+48 -1
View File
@@ -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 版本不支持获取朋友圈' }
+4
View File
@@ -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 })
}
/**
* 获取语音数据
*/
+3
View File
@@ -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) {
+6 -2
View File
@@ -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' 来隐藏窗口
BIN
View File
Binary file not shown.
Binary file not shown.
+39
View File
@@ -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">
+26 -1
View File
@@ -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
View File
@@ -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%" />}
+10 -5
View File
@@ -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)
+19
View File
@@ -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;
}
+1 -1
View File
@@ -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
View File
@@ -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>
+306
View File
@@ -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;
}
}
+115
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+397 -25
View File
@@ -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
View File
@@ -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
+32
View File
@@ -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;
}
}
+11 -3
View File
@@ -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}
/>
+62 -2
View File
@@ -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
View File
@@ -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">
+42
View File
@@ -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
View File
@@ -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>
+184
View File
@@ -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)
+2 -3
View File
@@ -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))
+18
View File
@@ -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
+7
View File
@@ -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
}
// 聊天记录项
+39 -3
View File
@@ -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
}
}