Merge pull request #468 from hicccc77/dev

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

59
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
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
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
blank_issues_enabled: false
contact_links:
- name: 🤔 找不到合适的模板?
about: 如果你只是想闲聊或者你的问题不属于上述任何分类请前往我们的的Telegram频道与我们交流。

24
.github/ISSUE_TEMPLATE/docs.yml vendored Normal file
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
.github/ISSUE_TEMPLATE/enhancement.yml vendored Normal file
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
.github/ISSUE_TEMPLATE/feature.yml vendored Normal file
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
.github/ISSUE_TEMPLATE/question.yml vendored Normal file
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

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 | | 会话 IDwxid 或群 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` 导出后才可访问。

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()
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)
})

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)

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)

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: []
}
})

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,11 +231,24 @@ 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;
}
/**

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 }>()

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()

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)

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)

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 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, verifyCiphertext)) continue
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
}

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 版本不支持获取朋友圈' }

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 })
}
/**
* 获取语音数据
*/

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) {

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
resources/libwcdb_api.dylib Executable file

Binary file not shown.

Binary file not shown.

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">

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);
}
}
}

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%" />}

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)

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;
}

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
}

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) {
displayName = userProfile.displayName || displayName
avatarUrl = userProfile.avatarUrl || avatarUrl
}
else if (cached) {
displayName = cached.displayName || displayName
avatarUrl = cached.avatarUrl || avatarUrl
}
return {
...option,
displayName: userProfile.displayName,
avatarUrl: userProfile.avatarUrl
displayName,
avatarUrl
}
}
if (cached) {
console.log('[切换账号] 使用缓存:', option.wxid, cached)
return {
...option,
displayName: cached.displayName,
avatarUrl: cached.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>

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;
}
}

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>
)
}

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);
}

File diff suppressed because it is too large Load Diff

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(() => {
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
}
void flushSessionMediaMetricCache()
if (sessionMutualFriendsPersistTimerRef.current) {
window.clearTimeout(sessionMutualFriendsPersistTimerRef.current)
sessionMutualFriendsPersistTimerRef.current = null
}
}, [flushSessionMediaMetricCache])
void flushSessionMediaMetricCache()
void flushSessionMutualFriendsCache()
}
}, [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>

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;
}
}

File diff suppressed because it is too large Load Diff

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;
}
}

View File

@@ -6,6 +6,7 @@ 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
// without satisfying the dependency array which would recreate the listener
@@ -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}
/>

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;

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)
@@ -2135,8 +2199,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
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 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>

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;
}

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('')
@@ -699,7 +704,17 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setShowWxidSelect(false)
}}
>
<span className="wxid-name">{opt.wxid}</span>
<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>
))}

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)

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))

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

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
}
// 聊天记录项

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(() => {
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
}
}