diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..6eba0d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0cd257e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +blank_issues_enabled: false +contact_links: + - name: 🤔 找不到合适的模板? + about: 如果你只是想闲聊或者你的问题不属于上述任何分类,请前往我们的的Telegram频道与我们交流。 diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 0000000..d310823 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml new file mode 100644 index 0000000..6773294 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..bb6f26a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -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` 标签) diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..5cd894c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -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 diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index c7b1aab..c82725b 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -1,33 +1,43 @@ -# WeFlow HTTP API 接口文档 +# WeFlow HTTP API 文档 -WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。 +WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件。 -## 启用 API 服务 +## 启用方式 -在设置页面 → API 服务 → 点击「启动服务」按钮。 +在应用设置页启用 `API 服务`。 -默认端口:`5031` - -## 基础地址 - -``` -http://127.0.0.1:5031 -``` - ---- +- 默认监听地址:`127.0.0.1` +- 默认端口:`5031` +- 基础地址:`http://127.0.0.1:5031` ## 接口列表 -### 1. 健康检查 +- `GET /health` +- `GET /api/v1/health` +- `GET /api/v1/messages` +- `GET /api/v1/sessions` +- `GET /api/v1/contacts` +- `GET /api/v1/group-members` +- `GET /api/v1/media/*` -检查 API 服务是否正常运行。 +--- + +## 1. 健康检查 **请求** -``` + +```http GET /health ``` +或 + +```http +GET /api/v1/health +``` + **响应** + ```json { "status": "ok" @@ -36,211 +46,180 @@ GET /health --- -### 2. 获取消息列表 +## 2. 获取消息 -获取指定会话的消息,支持 ChatLab 格式输出。 +读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。 **请求** -``` + +```http GET /api/v1/messages ``` -**参数** +### 参数 -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) | -| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` | -| `offset` | number | ❌ | 偏移量,用于分页,默认 0 | -| `start` | string | ❌ | 开始时间,格式 YYYYMMDD | -| `end` | string | ❌ | 结束时间,格式 YYYYMMDD | -| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) | -| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 | -| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` | -| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 | -| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) | -| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) | -| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` | -| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` | +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` | +| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` | +| `offset` | number | 否 | 分页偏移,默认 `0` | +| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 | +| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 | +| `keyword` | string | 否 | 基于消息显示文本过滤 | +| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 | +| `format` | string | 否 | `json` 或 `chatlab` | +| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` | +| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` | +| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` | +| `video` | string | 否 | 在 `media=1` 时控制视频导出 | +| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 | -默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media` - -**示例请求** +### 示例 ```bash -# 获取消息(原始格式) -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50 - -# 获取消息(ChatLab 格式) -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1 - -# 带时间范围查询 -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100 - -# 开启媒体导出(只导出图片和语音) -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0 - -# 关键词过滤 -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50 +curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20" +curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&chatlab=1" +curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260131" +curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0" ``` -**响应(原始格式)** +### JSON 响应字段 + +顶层字段: + +- `success` +- `talker` +- `count` +- `hasMore` +- `media.enabled` +- `media.exportPath` +- `media.count` +- `messages` + +单条消息字段: + +- `localId` +- `serverId` +- `localType` +- `createTime` +- `isSend` +- `senderUsername` +- `content` +- `rawContent` +- `parsedContent` +- `mediaType` +- `mediaFileName` +- `mediaUrl` +- `mediaLocalPath` + +**示例响应** + ```json { "success": true, - "talker": "wxid_xxx", - "count": 50, + "talker": "xxx@chatroom", + "count": 2, "hasMore": true, "media": { "enabled": true, "exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media", - "count": 12 + "count": 1 }, "messages": [ { "localId": 123, + "serverId": "456", + "localType": 1, + "createTime": 1738713600, + "isSend": 0, + "senderUsername": "wxid_member", + "content": "你好", + "rawContent": "你好", + "parsedContent": "你好" + }, + { + "localId": 124, "localType": 3, + "createTime": 1738713660, + "isSend": 0, + "senderUsername": "wxid_member", "content": "[图片]", - "createTime": 1738713600000, - "senderUsername": "wxid_sender", "mediaType": "image", - "mediaFileName": "image_123.jpg", - "mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg", - "mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg" + "mediaFileName": "abc123.jpg", + "mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg", + "mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg" } ] } ``` -**响应(ChatLab 格式)** -```json -{ - "chatlab": { - "version": "0.0.2", - "exportedAt": 1738713600000, - "generator": "WeFlow", - "description": "Exported from WeFlow" - }, - "meta": { - "name": "会话名称", - "platform": "wechat", - "type": "private", - "ownerId": "wxid_me" - }, - "members": [ - { - "platformId": "wxid_xxx", - "accountName": "用户名", - "groupNickname": "群昵称" - } - ], - "messages": [ - { - "sender": "wxid_xxx", - "accountName": "用户名", - "timestamp": 1738713600000, - "type": 0, - "content": "消息内容", - "mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg" - } - ], - "media": { - "enabled": true, - "exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media", - "count": 12 - } -} -``` +### ChatLab 响应 + +当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构: + +- `chatlab.version` +- `chatlab.exportedAt` +- `chatlab.generator` +- `meta.name` +- `meta.platform` +- `meta.type` +- `meta.groupId` +- `meta.groupAvatar` +- `meta.ownerId` +- `members[].platformId` +- `members[].accountName` +- `members[].groupNickname` +- `members[].avatar` +- `messages[].sender` +- `messages[].accountName` +- `messages[].groupNickname` +- `messages[].timestamp` +- `messages[].type` +- `messages[].content` +- `messages[].platformMessageId` +- `messages[].mediaPath` + +群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。 --- -### 3. 访问导出媒体文件 - -通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。 +## 3. 获取会话列表 **请求** -``` -GET /api/v1/media/{relativePath} -``` -**路径参数** - -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` | - -**支持的媒体类型** - -| 扩展名 | Content-Type | -|--------|-------------| -| `.png` | image/png | -| `.jpg` / `.jpeg` | image/jpeg | -| `.gif` | image/gif | -| `.webp` | image/webp | -| `.wav` | audio/wav | -| `.mp3` | audio/mpeg | -| `.mp4` | video/mp4 | - -**示例请求** -```bash -# 访问导出的图片 -GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg - -# 访问导出的语音 -GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav - -# 访问导出的视频 -GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4 -``` - -**响应** - -成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。 - -失败时返回: -```json -{ "error": "Media not found" } -``` - -> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。 - ---- - -### 4. 获取会话列表 - -获取所有会话列表。 - -**请求** -``` +```http GET /api/v1/sessions ``` -**参数** +### 参数 -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID | -| `limit` | number | ❌ | 返回数量限制,默认 100 | +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `keyword` | string | 否 | 匹配 `username` 或 `displayName` | +| `limit` | number | 否 | 默认 `100` | -**示例请求** -```bash -GET http://127.0.0.1:5031/api/v1/sessions +### 响应字段 -GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20 -``` +- `success` +- `count` +- `sessions[].username` +- `sessions[].displayName` +- `sessions[].type` +- `sessions[].lastTimestamp` +- `sessions[].unreadCount` + +**示例响应** -**响应** ```json { "success": true, - "count": 50, - "total": 100, + "count": 1, "sessions": [ { - "username": "wxid_xxx", - "displayName": "用户名", - "lastMessage": "最后一条消息", - "lastTime": 1738713600000, + "username": "xxx@chatroom", + "displayName": "项目群", + "type": 2, + "lastTimestamp": 1738713600, "unreadCount": 0 } ] @@ -249,40 +228,48 @@ GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20 --- -### 4. 获取联系人列表 - -获取所有联系人信息。 +## 4. 获取联系人列表 **请求** -``` + +```http GET /api/v1/contacts ``` -**参数** +### 参数 -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `keyword` | string | ❌ | 搜索关键词 | -| `limit` | number | ❌ | 返回数量限制,默认 100 | +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` | +| `limit` | number | 否 | 默认 `100` | -**示例请求** -```bash -GET http://127.0.0.1:5031/api/v1/contacts +### 响应字段 -GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三 -``` +- `success` +- `count` +- `contacts[].username` +- `contacts[].displayName` +- `contacts[].remark` +- `contacts[].nickname` +- `contacts[].alias` +- `contacts[].avatarUrl` +- `contacts[].type` + +**示例响应** -**响应** ```json { "success": true, - "count": 50, + "count": 1, "contacts": [ { - "userName": "wxid_xxx", - "alias": "微信号", - "nickName": "昵称", - "remark": "备注名" + "username": "wxid_xxx", + "displayName": "张三", + "remark": "客户张三", + "nickname": "张三", + "alias": "zhangsan", + "avatarUrl": "https://example.com/avatar.jpg", + "type": "friend" } ] } @@ -290,60 +277,157 @@ GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三 --- -## ChatLab 格式说明 +## 5. 获取群成员列表 -ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。 +返回群成员的 `wxid`、群昵称、备注、微信号等信息。 -### 消息类型映射 +**请求** -| ChatLab Type | 值 | 说明 | -|--------------|-----|------| -| TEXT | 0 | 文本消息 | -| IMAGE | 1 | 图片 | -| VOICE | 2 | 语音 | -| VIDEO | 3 | 视频 | -| FILE | 4 | 文件 | -| EMOJI | 5 | 表情 | -| LINK | 7 | 链接 | -| LOCATION | 8 | 位置 | -| RED_PACKET | 20 | 红包 | -| TRANSFER | 21 | 转账 | -| CALL | 23 | 通话 | -| SYSTEM | 80 | 系统消息 | -| RECALL | 81 | 撤回消息 | -| OTHER | 99 | 其他 | +```http +GET /api/v1/group-members +``` + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 | +| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 | +| `withCounts` | string | 否 | `includeMessageCounts` 的别名 | +| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 | + +### 响应字段 + +- `success` +- `chatroomId` +- `count` +- `fromCache` +- `updatedAt` +- `members[].wxid` +- `members[].displayName` +- `members[].nickname` +- `members[].remark` +- `members[].alias` +- `members[].groupNickname` +- `members[].avatarUrl` +- `members[].isOwner` +- `members[].isFriend` +- `members[].messageCount` + +**示例请求** + +```bash +curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom" +curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=1" +``` + +**示例响应** + +```json +{ + "success": true, + "chatroomId": "xxx@chatroom", + "count": 2, + "fromCache": false, + "updatedAt": 1760000000000, + "members": [ + { + "wxid": "wxid_member_a", + "displayName": "客户A", + "nickname": "阿甲", + "remark": "客户A", + "alias": "kehua", + "groupNickname": "甲方", + "avatarUrl": "https://example.com/a.jpg", + "isOwner": true, + "isFriend": true, + "messageCount": 128 + }, + { + "wxid": "wxid_member_b", + "displayName": "李四", + "nickname": "李四", + "remark": "", + "alias": "", + "groupNickname": "", + "avatarUrl": "", + "isOwner": false, + "isFriend": false, + "messageCount": 0 + } + ] +} +``` + +说明: + +- `displayName` 是当前应用内的主展示名。 +- `groupNickname` 是成员在该群里的群昵称。 +- `remark` 是你对该联系人的备注。 +- `alias` 是微信号。 +- 当微信源数据里没有群昵称时,`groupNickname` 会为空。 --- -## 使用示例 +## 6. 访问导出媒体 + +通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。 + +**请求** + +```http +GET /api/v1/media/{relativePath} +``` + +### 示例 + +```bash +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg" +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/voices/voice_100.wav" +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/videos/video_200.mp4" +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" +``` + +### 支持的 Content-Type + +| 扩展名 | Content-Type | +| --- | --- | +| `.png` | `image/png` | +| `.jpg` / `.jpeg` | `image/jpeg` | +| `.gif` | `image/gif` | +| `.webp` | `image/webp` | +| `.wav` | `audio/wav` | +| `.mp3` | `audio/mpeg` | +| `.mp4` | `video/mp4` | + +常见错误响应: + +```json +{ + "error": "Media not found" +} +``` + +--- + +## 7. 使用示例 ### PowerShell ```powershell -# 健康检查 Invoke-RestMethod http://127.0.0.1:5031/health - -# 获取会话列表 Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions - -# 获取消息 Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10" - -# 获取 ChatLab 格式 -Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10 +Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1" ``` ### cURL ```bash -# 健康检查 curl http://127.0.0.1:5031/health - -# 获取会话列表 -curl http://127.0.0.1:5031/api/v1/sessions - -# 获取消息(ChatLab 格式) curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" +curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三" +curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom" ``` ### Python @@ -353,39 +437,26 @@ import requests BASE_URL = "http://127.0.0.1:5031" -# 获取会话列表 -sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json() -print(sessions) +messages = requests.get( + f"{BASE_URL}/api/v1/messages", + params={"talker": "xxx@chatroom", "limit": 50} +).json() + +members = requests.get( + f"{BASE_URL}/api/v1/group-members", + params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1} +).json() -# 获取消息 -messages = requests.get(f"{BASE_URL}/api/v1/messages", params={ - "talker": "wxid_xxx", - "limit": 100, - "chatlab": 1 -}).json() print(messages) -``` - -### JavaScript / Node.js - -```javascript -const BASE_URL = "http://127.0.0.1:5031"; - -// 获取会话列表 -const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json()); -console.log(sessions); - -// 获取消息(ChatLab 格式) -const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`) - .then(r => r.json()); -console.log(messages); +print(members) ``` --- -## 注意事项 +## 8. 注意事项 -1. API 仅监听本地地址 `127.0.0.1`,不对外网开放 -2. 需要先连接数据库才能查询数据 -3. 时间参数格式为 `YYYYMMDD`(如 20260205) -4. 支持 CORS,可从浏览器前端直接调用 +1. API 仅监听本机 `127.0.0.1`,不对外网开放。 +2. 使用前需要先在 WeFlow 中完成数据库连接。 +3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。 +4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。 +5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。 diff --git a/electron/main.ts b/electron/main.ts index d636dd5..6ba867a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -97,6 +97,9 @@ let mainWindowReady = false let shouldShowMain = true let isAppQuitting = false let tray: Tray | null = null +let isClosePromptVisible = false + +type WindowCloseBehavior = 'ask' | 'tray' | 'quit' // 更新下载状态管理(Issue #294 修复) let isDownloadInProgress = false @@ -253,6 +256,19 @@ const setupCustomTitleBarWindow = (win: BrowserWindow): void => { win.webContents.on('did-finish-load', emitMaximizeState) } +const getWindowCloseBehavior = (): WindowCloseBehavior => { + const behavior = configService?.get('windowCloseBehavior') + return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask' +} + +const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => { + if (isClosePromptVisible) return + isClosePromptVisible = true + win.webContents.send('window:confirmCloseRequested', { + canMinimizeToTray: Boolean(tray) + }) +} + function createWindow(options: { autoShow?: boolean } = {}) { // 获取图标路径 - 打包后在 resources 目录 const { autoShow = true } = options @@ -354,10 +370,22 @@ function createWindow(options: { autoShow?: boolean } = {}) { }) win.on('close', (e) => { - if (isAppQuitting) return - // 关闭主窗口时隐藏到状态栏而不是退出 + if (isAppQuitting || win !== mainWindow) return e.preventDefault() - win.hide() + const closeBehavior = getWindowCloseBehavior() + + if (closeBehavior === 'quit') { + isAppQuitting = true + app.quit() + return + } + + if (closeBehavior === 'tray' && tray) { + win.hide() + return + } + + requestMainWindowCloseConfirmation(win) }) win.on('closed', () => { @@ -365,6 +393,7 @@ function createWindow(options: { autoShow?: boolean } = {}) { mainWindow = null mainWindowReady = false + isClosePromptVisible = false if (process.platform !== 'darwin' && !isAppQuitting) { destroyNotificationWindow() @@ -1154,6 +1183,33 @@ function registerIpcHandlers() { BrowserWindow.fromWebContents(event.sender)?.close() }) + ipcMain.handle('window:respondCloseConfirm', async (_event, action: 'tray' | 'quit' | 'cancel') => { + if (!mainWindow || mainWindow.isDestroyed()) { + isClosePromptVisible = false + return false + } + + try { + if (action === 'tray') { + if (tray) { + mainWindow.hide() + return true + } + return false + } + + if (action === 'quit') { + isAppQuitting = true + app.quit() + return true + } + + return true + } finally { + isClosePromptVisible = false + } + }) + // 更新窗口控件主题色 ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => { const win = BrowserWindow.fromWebContents(event.sender) @@ -1563,6 +1619,10 @@ function registerIpcHandlers() { return chatService.getMessageById(sessionId, localId) }) + ipcMain.handle('chat:searchMessages', async (_, keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => { + return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp) + }) + ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => { return chatService.execQuery(kind, path, sql) }) @@ -1889,6 +1949,18 @@ function registerIpcHandlers() { return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime) }) + ipcMain.handle( + 'groupAnalytics:getGroupMemberMessages', + async ( + _, + chatroomId: string, + memberUsername: string, + options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number } + ) => { + return groupAnalyticsService.getGroupMemberMessages(chatroomId, memberUsername, options) + } + ) + ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => { return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath) }) diff --git a/electron/preload.ts b/electron/preload.ts index 2dcc561..4cce51c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 0b81fe2..f2f508d 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -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>() private messageTableColumnsCache = new Map; updatedAt: number }>() + private messageName2IdTableCache = new Map() + private messageSenderIdCache = new Map() private readonly sessionTablesCacheTtl = 300000 // 5分钟 private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000 private sessionMessageCountCache = new Map() @@ -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[] | 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[] = [] - 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[])) - - // 并发检查并修复缺失 CDN URL 的表情包 - const fixPromises: Promise[] = [] - 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): { 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 { + const fixPromises: Promise[] = [] + 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[] = [] + ): Promise<{ + success: boolean + messages?: Message[] + hasMore?: boolean + error?: string + rawRowsConsumed?: number + filteredOut?: number + bufferedRows?: Record[] + }> { + 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[] : [] + 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, 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[]): 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 { + 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 { + 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, + rawContent: string + ): Promise { + 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), + 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 { + 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) diff --git a/electron/services/config.ts b/electron/services/config.ts index 6ec8270..d783c49 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -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: [] } }) diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index b199b85..b6fb973 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -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 - try { - stat = statSync(entryPath) - } catch { - continue - } - + try { stat = statSync(entryPath) } catch { continue } if (!stat.isDirectory()) continue const lower = entry.toLowerCase() if (lower === 'all_users') continue if (!entry.includes('_')) continue - wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs }) } } + if (wxids.length === 0) { const rootName = basename(rootPath) if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') { @@ -159,12 +231,25 @@ export class DbPathService { } } catch { } - return wxids.sort((a, b) => { + const sorted = wxids.sort((a, b) => { if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime return a.wxid.localeCompare(b.wxid) - }) + }); + + const globalInfo = this.parseGlobalConfig(rootPath); + if (globalInfo) { + for (const w of sorted) { + if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) { + w.nickname = globalInfo.nickname; + w.avatarUrl = globalInfo.avatarUrl; + } + } + } + + return sorted; } + /** * 扫描 wxid 列表 */ @@ -187,10 +272,21 @@ export class DbPathService { } } catch { } - return wxids.sort((a, b) => { + const sorted = wxids.sort((a, b) => { if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime return a.wxid.localeCompare(b.wxid) - }) + }); + + const globalInfo = this.parseGlobalConfig(rootPath); + if (globalInfo) { + for (const w of sorted) { + if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) { + w.nickname = globalInfo.nickname; + w.avatarUrl = globalInfo.avatarUrl; + } + } + } + return sorted; } /** diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index dcf3956..0b8b5bc 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -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> { + const nicknameMap = new Map() + + 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() + return nicknameMap } const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) - if (!extBuffer) return new Map() - 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() + return nicknameMap + } + } + + private mergeGroupNicknameEntries( + target: Map, + 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() diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index fbdb32e..7a03d37 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -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> { + const nicknameMap = new Map() + 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() + return nicknameMap } const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) - if (!extBuffer) return new Map() - 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() + return nicknameMap + } + } + + private mergeGroupNicknameEntries( + target: Map, + 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() diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 6e3423f..47f3f8c 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -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 { + 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, 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) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index a78b7ed..9ad2c25 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -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) diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index 5f1e34f..f87e8d0 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -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 { try { + // 检测 SIP 状态 + const sipStatus = await this.checkSipStatus() + if (sipStatus.enabled) { + return { + success: false, + error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. 重启 Mac 并按住 Command + R 进入恢复模式\n2. 打开终端,输入: csrutil disable\n3. 重启电脑' + } + } + onStatus?.('正在获取数据库密钥...', 0) onStatus?.('正在请求管理员授权并执行 helper...', 0) let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string } @@ -488,26 +507,39 @@ export class KeyServiceMac { const wxidCandidates = this.collectWxidCandidates(accountPath, wxid) if (wxidCandidates.length === 0) { - return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' } + return { success: false, error: '未找到可用的账号候选,请先选择正确的账号目录' } } + const accountPathCandidates = this.collectAccountPathCandidates(accountPath) + // 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错 - let verifyCiphertext: Buffer | null = null - if (accountPath && existsSync(accountPath)) { - const template = await this._findTemplateData(accountPath, 32) - verifyCiphertext = template.ciphertext - } - if (verifyCiphertext) { + if (accountPathCandidates.length > 0) { onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`) - for (const candidateWxid of wxidCandidates) { - for (const code of codes) { - const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) - if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue - onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) - return { success: true, xorKey, aesKey } + for (const candidateAccountPath of accountPathCandidates) { + if (!existsSync(candidateAccountPath)) continue + const template = await this._findTemplateData(candidateAccountPath, 32) + if (!template.ciphertext) continue + + const accountDirWxid = basename(candidateAccountPath) + const orderedWxids: string[] = [] + this.pushAccountIdCandidates(orderedWxids, accountDirWxid) + for (const candidate of wxidCandidates) { + this.pushAccountIdCandidates(orderedWxids, candidate) + } + + for (const candidateWxid of orderedWxids) { + for (const code of codes) { + const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) + if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue + onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) + return { success: true, xorKey, aesKey } + } } } - return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' } + return { + success: false, + error: '缓存 code 与当前账号 wxid 未匹配。若数据库密钥获取后微信刚刚崩溃并重启,可能当前选中的账号目录已经不是最新会话;请先重新扫描 wxid,或直接使用内存扫描。' + } } // 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code) @@ -542,16 +574,21 @@ export class KeyServiceMac { onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) - // 2. 找微信 PID - const pid = await this.findWeChatPid() - if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' } - - onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) - - // 3. 持续轮询内存扫描 + // 2. 持续轮询微信 PID 与内存扫描,兼容微信崩溃后重启 PID 变化 const deadline = Date.now() + 60_000 let scanCount = 0 + let lastPid: number | null = null while (Date.now() < deadline) { + const pid = await this.findWeChatPid() + if (!pid) { + onProgress?.('暂未检测到微信主进程,请确认微信已经重新打开...') + await new Promise(r => setTimeout(r, 2000)) + continue + } + if (lastPid !== pid) { + lastPid = pid + onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) + } scanCount++ onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) @@ -764,7 +801,7 @@ export class KeyServiceMac { } const current = chunk.subarray(0, bytesRead) - const data = trailing ? Buffer.concat([trailing, current]) : current + const data: Buffer = trailing ? Buffer.concat([trailing, current]) : current const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext) if (key) return key // 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中) @@ -793,8 +830,8 @@ export class KeyServiceMac { } const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]' let stdout = '', stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) - child.stderr.on('data', (chunk: Buffer) => { + child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() console.log(tag, chunk.toString().trim()) }) @@ -819,11 +856,8 @@ export class KeyServiceMac { } private async findWeChatPid(): Promise { - 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 } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 7e69caa..a9b99dd 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -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 版本不支持获取朋友圈' } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 286ddae..b5fcb24 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -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 }) + } + /** * 获取语音数据 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 8a49cad..5d02904 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -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) { diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts index 1642924..fc31ccc 100644 --- a/electron/windows/notificationWindow.ts +++ b/electron/windows/notificationWindow.ts @@ -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' 来隐藏窗口 diff --git a/resources/libwcdb_api.dylib b/resources/libwcdb_api.dylib new file mode 100755 index 0000000..c4db20f Binary files /dev/null and b/resources/libwcdb_api.dylib differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 3a58257..31aa4a2 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/App.tsx b/src/App.tsx index e287a68..6f41759 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} /> + handleWindowCloseAction(action, rememberChoice)} + onCancel={() => handleWindowCloseAction('cancel')} + /> +
diff --git a/src/components/Avatar.scss b/src/components/Avatar.scss index 34b11b2..6a15310 100644 --- a/src/components/Avatar.scss +++ b/src/components/Avatar.scss @@ -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; } } -} \ No newline at end of file + + @keyframes avatar-spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } + } +} diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 7406bd5..69020f1 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -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(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 (
{ - 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 ? ( +
+ +
) : (
{name ? {getAvatarLetter()} : } diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx index a1abf71..a8f65b0 100644 --- a/src/components/GlobalSessionMonitor.tsx +++ b/src/components/GlobalSessionMonitor.tsx @@ -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) diff --git a/src/components/NotificationToast.scss b/src/components/NotificationToast.scss index a01ab73..57dc558 100644 --- a/src/components/NotificationToast.scss +++ b/src/components/NotificationToast.scss @@ -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; } diff --git a/src/components/NotificationToast.tsx b/src/components/NotificationToast.tsx index 886a878..f394f6c 100644 --- a/src/components/NotificationToast.tsx +++ b/src/components/NotificationToast.tsx @@ -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 } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index e6d0147..6b14cb4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { useChatStore } from '../stores/chatStore' import { useAnalyticsStore } from '../stores/analyticsStore' import * as configService from '../services/config' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' +import { UserRound } from 'lucide-react' import './Sidebar.scss' @@ -35,6 +36,7 @@ interface AccountProfilesCache { interface WxidOption { wxid: string modifiedTime: number + nickname?: string displayName?: string avatarUrl?: string } @@ -280,26 +282,28 @@ function Sidebar({ collapsed }: SidebarProps) { const accountsCache = readAccountProfilesCache() console.log('[切换账号] 账号缓存:', accountsCache) - const enrichedWxids = wxids.map(option => { + const enrichedWxids = wxids.map((option: WxidOption) => { const normalizedWxid = normalizeAccountId(option.wxid) const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid] + let displayName = option.nickname || option.wxid + let avatarUrl = option.avatarUrl + if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) { - return { - ...option, - displayName: userProfile.displayName, - avatarUrl: userProfile.avatarUrl - } + displayName = userProfile.displayName || displayName + avatarUrl = userProfile.avatarUrl || avatarUrl } - if (cached) { - console.log('[切换账号] 使用缓存:', option.wxid, cached) - return { - ...option, - displayName: cached.displayName, - avatarUrl: cached.avatarUrl - } + + else if (cached) { + displayName = cached.displayName || displayName + avatarUrl = cached.avatarUrl || avatarUrl + } + + return { + ...option, + displayName, + avatarUrl } - return { ...option, displayName: option.wxid } }) setWxidOptions(enrichedWxids) @@ -553,11 +557,17 @@ function Sidebar({ collapsed }: SidebarProps) { type="button" >
- {option.avatarUrl ? : {getAvatarLetter(option.displayName || option.wxid)}} + {option.avatarUrl ? ( + + ) : ( +
+ +
+ )}
-
{option.displayName || option.wxid}
-
{option.wxid}
+
{option.displayName}
+ {option.displayName !== option.wxid &&
{option.wxid}
}
{userProfile.wxid === option.wxid && 当前} diff --git a/src/components/WindowCloseDialog.scss b/src/components/WindowCloseDialog.scss new file mode 100644 index 0000000..ecc6907 --- /dev/null +++ b/src/components/WindowCloseDialog.scss @@ -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; + } +} diff --git a/src/components/WindowCloseDialog.tsx b/src/components/WindowCloseDialog.tsx new file mode 100644 index 0000000..ea838ea --- /dev/null +++ b/src/components/WindowCloseDialog.tsx @@ -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 ( +
+
event.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="window-close-dialog-title" + > + + +
+ 退出行为 +

关闭 WeFlow

+

+ {canMinimizeToTray + ? '你可以保留后台进程与本地 API,或者直接完全退出应用。' + : '当前系统托盘不可用,本次只能完全退出应用。'} +

+
+ +
+ {canMinimizeToTray && ( + + )} + + +
+ + + +
+ +
+
+
+ ) +} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index e00feb5..be4d15f 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -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); +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 50e1534..36e784b 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -34,6 +34,122 @@ const SYSTEM_MESSAGE_TYPES = [ 266287972401, // 拍一拍 ] +interface PendingInSessionSearchPayload { + sessionId: string + keyword: string + firstMsgTime: number + results: Message[] +} + +function sortMessagesByCreateTimeDesc>(items: T[]): T[] { + return [...items].sort((a, b) => { + const timeDiff = (b.createTime || 0) - (a.createTime || 0) + if (timeDiff !== 0) return timeDiff + return (b.localId || 0) - (a.localId || 0) + }) +} + +function normalizeSearchIdentityText(value?: string | null): string | undefined { + const normalized = String(value || '').trim() + if (!normalized) return undefined + const lower = normalized.toLowerCase() + if (normalized === '未知' || lower === 'unknown' || lower === 'null' || lower === 'undefined') { + return undefined + } + if (lower.startsWith('unknown_sender_')) { + return undefined + } + return normalized +} + +function normalizeSearchAvatarUrl(value?: string | null): string | undefined { + const normalized = String(value || '').trim() + if (!normalized) return undefined + const lower = normalized.toLowerCase() + if (lower === 'null' || lower === 'undefined') { + return undefined + } + return normalized +} + +function isWxidLikeSearchIdentity(value?: string | null): boolean { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized) return false + if (normalized.startsWith('wxid_')) return true + const suffixMatch = normalized.match(/^(.+)_([a-z0-9]{4})$/i) + return Boolean(suffixMatch && suffixMatch[1].startsWith('wxid_')) +} + +function resolveSearchSenderDisplayName( + displayName?: string | null, + senderUsername?: string | null, + sessionId?: string | null +): string | undefined { + const normalizedDisplayName = normalizeSearchIdentityText(displayName) + if (!normalizedDisplayName) return undefined + + const normalizedSenderUsername = normalizeSearchIdentityText(senderUsername) + const normalizedSessionId = normalizeSearchIdentityText(sessionId) + + if (normalizedSessionId && normalizedDisplayName === normalizedSessionId) { + return undefined + } + if (isWxidLikeSearchIdentity(normalizedDisplayName)) { + return undefined + } + if ( + normalizedSenderUsername && + normalizedDisplayName === normalizedSenderUsername && + isWxidLikeSearchIdentity(normalizedSenderUsername) + ) { + return undefined + } + + return normalizedDisplayName +} + +function resolveSearchSenderUsernameFallback(value?: string | null): string | undefined { + const normalized = normalizeSearchIdentityText(value) + if (!normalized || isWxidLikeSearchIdentity(normalized)) { + return undefined + } + return normalized +} + +function buildSearchIdentityCandidates(value?: string | null): string[] { + const normalized = normalizeSearchIdentityText(value) + if (!normalized) return [] + const lower = normalized.toLowerCase() + const candidates = new Set([lower]) + if (lower.startsWith('wxid_')) { + const match = lower.match(/^(wxid_[^_]+)/i) + if (match?.[1]) { + candidates.add(match[1]) + } + } + return [...candidates] +} + +function isCurrentUserSearchIdentity( + senderUsername?: string | null, + myWxid?: string | null +): boolean { + const senderCandidates = buildSearchIdentityCandidates(senderUsername) + const selfCandidates = buildSearchIdentityCandidates(myWxid) + if (senderCandidates.length === 0 || selfCandidates.length === 0) { + return false + } + + for (const sender of senderCandidates) { + for (const self of selfCandidates) { + if (sender === self) return true + if (sender.startsWith(self + '_')) return true + if (self.startsWith(sender + '_')) return true + } + } + return false +} + interface XmlField { key: string; value: string; @@ -327,23 +443,99 @@ interface LoadMessagesOptions { switchRequestSeq?: number } -// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts import { avatarLoadQueue } from '../utils/AvatarLoadQueue' import { Avatar } from '../components/Avatar' // 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染) +// 高亮搜索关键词组件 +const HighlightText = React.memo(({ text, keyword }: { text: string; keyword: string }) => { + if (!keyword) return <>{text} + + const lowerText = text.toLowerCase() + const lowerKeyword = keyword.toLowerCase() + const matchIndex = lowerText.indexOf(lowerKeyword) + + if (matchIndex === -1) return <>{text} + + // 如果匹配位置在后面且文本过长,截断前面部分 + const maxLength = 50 + let displayText = text + + if (text.length > maxLength && matchIndex > 20) { + const start = Math.max(0, matchIndex - 15) + displayText = '...' + text.slice(start) + } + + const parts = displayText.split(new RegExp(`(${keyword})`, 'gi')) + return ( + <> + {parts.map((part, i) => + part.toLowerCase() === lowerKeyword ? + {part} : part + )} + + ) +}) + +const HighlightTextNoTruncate = React.memo(({ text, keyword }: { text: string; keyword: string }) => { + if (!keyword) return <>{text} + + const lowerText = text.toLowerCase() + const lowerKeyword = keyword.toLowerCase() + const matchIndex = lowerText.indexOf(lowerKeyword) + + if (matchIndex === -1) return <>{text} + + const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const matchEnd = matchIndex + keyword.length + const maxDisplayLength = 25 + + // 如果匹配位置不在开头,或文本过长,则居中显示 + if (matchIndex > 5 || text.length > maxDisplayLength) { + const start = Math.max(0, matchIndex - 8) + const end = Math.min(text.length, matchEnd + 15) + const prefix = start > 0 ? '...' : '' + const suffix = end < text.length ? '...' : '' + const middleText = text.slice(start, end) + + const parts = middleText.split(new RegExp(`(${escapedKeyword})`, 'gi')) + return ( + <> + {prefix} + {parts.map((part, i) => + part.toLowerCase() === lowerKeyword ? + {part} : part + )} + {suffix} + + ) + } + + const parts = text.split(new RegExp(`(${escapedKeyword})`, 'gi')) + return ( + <> + {parts.map((part, i) => + part.toLowerCase() === lowerKeyword ? + {part} : part + )} + + ) +}) + // 会话项组件(使用 memo 优化,避免不必要的重渲染) const SessionItem = React.memo(function SessionItem({ session, isActive, onSelect, - formatTime + formatTime, + searchKeyword }: { session: ChatSession isActive: boolean onSelect: (session: ChatSession) => void formatTime: (timestamp: number) => string + searchKeyword?: string }) { const timeText = useMemo(() => formatTime(session.lastTimestamp || session.sortTimestamp), @@ -375,6 +567,16 @@ const SessionItem = React.memo(function SessionItem({ ) } + // 根据匹配字段显示不同的 summary + const summaryContent = useMemo(() => { + if (session.matchedField === 'wxid') { + return wxid: + } else if (session.matchedField === 'alias' && session.alias) { + return 微信号: + } + return {session.summary || '暂无消息'} + }, [session.matchedField, session.username, session.alias, session.summary, searchKeyword]) + return (
- {session.displayName || session.username} + + {(() => { + const shouldHighlight = (session.matchedField as any) === 'name' && searchKeyword + if (shouldHighlight) { + console.log('高亮名字:', session.displayName, 'keyword:', searchKeyword) + } + return shouldHighlight ? ( + + ) : ( + session.displayName || session.username + ) + })()} + {timeText}
- {session.summary || '暂无消息'} + {summaryContent}
{session.isMuted && } {session.unreadCount > 0 && ( @@ -411,11 +625,14 @@ const SessionItem = React.memo(function SessionItem({ prevProps.session.displayName === nextProps.session.displayName && prevProps.session.avatarUrl === nextProps.session.avatarUrl && prevProps.session.summary === nextProps.session.summary && + prevProps.session.matchedField === nextProps.session.matchedField && + prevProps.session.alias === nextProps.session.alias && prevProps.session.unreadCount === nextProps.session.unreadCount && prevProps.session.lastTimestamp === nextProps.session.lastTimestamp && prevProps.session.sortTimestamp === nextProps.session.sortTimestamp && prevProps.session.isMuted === nextProps.session.isMuted && - prevProps.isActive === nextProps.isActive + prevProps.isActive === nextProps.isActive && + prevProps.searchKeyword === nextProps.searchKeyword ) }) @@ -473,8 +690,8 @@ function ChatPage(props: ChatPageProps) { const sidebarRef = useRef(null) const getMessageKey = useCallback((msg: Message): string => { - if (msg.localId && msg.localId > 0) return `l:${msg.localId}` - return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}` + if (msg.messageKey) return msg.messageKey + return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}` }, []) const initialRevealTimerRef = useRef(null) const sessionListRef = useRef(null) @@ -539,7 +756,7 @@ function ChatPage(props: ChatPageProps) { // 多选模式 const [isSelectionMode, setIsSelectionMode] = useState(false) - const [selectedMessages, setSelectedMessages] = useState>(new Set()) + const [selectedMessages, setSelectedMessages] = useState>(new Set()) // 编辑消息额外状态 const [editMode, setEditMode] = useState<'raw' | 'fields'>('raw') @@ -564,6 +781,22 @@ function ChatPage(props: ChatPageProps) { const [isDeleting, setIsDeleting] = useState(false) const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0 }) const [cancelDeleteRequested, setCancelDeleteRequested] = useState(false) + // 会话内搜索 + const [showInSessionSearch, setShowInSessionSearch] = useState(false) + const [inSessionQuery, setInSessionQuery] = useState('') + const [inSessionResults, setInSessionResults] = useState([]) + const [inSessionSearching, setInSessionSearching] = useState(false) + const [inSessionEnriching, setInSessionEnriching] = useState(false) + const [inSessionSearchError, setInSessionSearchError] = useState(null) + const inSessionSearchRef = useRef(null) + // 全局消息搜索 + const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false) + const [globalMsgQuery, setGlobalMsgQuery] = useState('') + const [globalMsgResults, setGlobalMsgResults] = useState>([]) + const [globalMsgSearching, setGlobalMsgSearching] = useState(false) + const [globalMsgSearchError, setGlobalMsgSearchError] = useState(null) + const pendingInSessionSearchRef = useRef(null) + const pendingGlobalMsgSearchReplayRef = useRef(null) // 自定义删除确认对话框 const [deleteConfirm, setDeleteConfirm] = useState<{ @@ -2063,7 +2296,7 @@ function ChatPage(props: ChatPageProps) { } // 联系人信息更新队列(防抖批量更新,避免频繁重渲染) - const contactUpdateQueueRef = useRef>(new Map()) + const contactUpdateQueueRef = useRef>(new Map()) const contactUpdateTimerRef = useRef(null) const lastUpdateTimeRef = useRef(0) @@ -2097,12 +2330,14 @@ function ChatPage(props: ChatPageProps) { if (update) { const newDisplayName = update.displayName || session.displayName || session.username const newAvatarUrl = update.avatarUrl || session.avatarUrl - if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl) { + const newAlias = update.alias || session.alias + if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl || newAlias !== session.alias) { hasChanges = true return { ...session, displayName: newDisplayName, - avatarUrl: newAvatarUrl + avatarUrl: newAvatarUrl, + alias: newAlias } } } @@ -2134,7 +2369,7 @@ function ChatPage(props: ChatPageProps) { const dllStart = performance.now() const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) as { success: boolean - contacts?: Record + contacts?: Record error?: string } const dllTime = performance.now() - dllStart @@ -2357,6 +2592,7 @@ function ChatPage(props: ChatPageProps) { success: boolean; messages?: Message[]; hasMore?: boolean; + nextOffset?: number; error?: string } if (options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current) { @@ -2433,7 +2669,10 @@ function ChatPage(props: ChatPageProps) { } } } - setCurrentOffset(offset + result.messages.length) + const nextOffset = typeof result.nextOffset === 'number' + ? result.nextOffset + : offset + result.messages.length + setCurrentOffset(nextOffset) } else if (!result.success) { setNoMessageTable(true) setHasMoreMessages(false) @@ -2453,13 +2692,21 @@ function ChatPage(props: ChatPageProps) { pendingSessionLoadRef.current = null initialLoadRequestedSessionRef.current = null setIsSessionSwitching(false) + + // 处理从全局搜索跳转过来的情况 + const pendingSearch = pendingInSessionSearchRef.current + if (pendingSearch?.sessionId === sessionId) { + pendingInSessionSearchRef.current = null + void applyPendingInSessionSearch(sessionId, pendingSearch, options.switchRequestSeq) + } } } } } - const handleJumpDateSelect = useCallback((date: Date) => { - if (!currentSessionId) return + const handleJumpDateSelect = useCallback((date: Date, options: { sessionId?: string; switchRequestSeq?: number } = {}) => { + const targetSessionId = String(options.sessionId || currentSessionRef.current || currentSessionId || '').trim() + if (!targetSessionId) return const targetDate = new Date(date) const end = Math.floor(targetDate.setHours(23, 59, 59, 999) / 1000) // 日期跳转采用“锚点定位”而非“当天过滤”: @@ -2469,9 +2716,421 @@ function ChatPage(props: ChatPageProps) { setJumpStartTime(0) setJumpEndTime(end) setShowJumpPopover(false) - void loadMessages(currentSessionId, 0, 0, end, false) + void loadMessages(targetSessionId, 0, 0, end, false, { + switchRequestSeq: options.switchRequestSeq + }) }, [currentSessionId, loadMessages]) + const cancelInSessionSearchTasks = useCallback(() => { + inSessionSearchGenRef.current += 1 + if (inSessionSearchTimerRef.current) { + clearTimeout(inSessionSearchTimerRef.current) + inSessionSearchTimerRef.current = null + } + setInSessionSearching(false) + setInSessionEnriching(false) + }, []) + + const resolveSearchSessionContext = useCallback((sessionId?: string) => { + const normalizedSessionId = String(sessionId || currentSessionRef.current || currentSessionId || '').trim() + const currentSearchSession = normalizedSessionId && Array.isArray(sessions) + ? sessions.find(session => session.username === normalizedSessionId) + : undefined + const resolvedSession = currentSearchSession + ? ( + standaloneSessionWindow && + normalizedInitialSessionId && + currentSearchSession.username === normalizedInitialSessionId + ? { + ...currentSearchSession, + displayName: currentSearchSession.displayName || fallbackDisplayName || currentSearchSession.username, + avatarUrl: currentSearchSession.avatarUrl || fallbackAvatarUrl || undefined + } + : currentSearchSession + ) + : ( + normalizedSessionId + ? { + username: normalizedSessionId, + displayName: fallbackDisplayName || normalizedSessionId, + avatarUrl: fallbackAvatarUrl || undefined + } as ChatSession + : undefined + ) + const isGroupSearchSession = Boolean( + resolvedSession && ( + isGroupChatSession(resolvedSession.username) || + ( + standaloneSessionWindow && + resolvedSession.username === normalizedInitialSessionId && + normalizedStandaloneInitialContactType === 'group' + ) + ) + ) + const isDirectSearchSession = Boolean( + resolvedSession && + isSingleContactSession(resolvedSession.username) && + !isGroupSearchSession + ) + return { + normalizedSessionId, + resolvedSession, + isDirectSearchSession, + isGroupSearchSession, + resolvedSessionDisplayName: normalizeSearchIdentityText(resolvedSession?.displayName) || normalizedSessionId || undefined, + resolvedSessionAvatarUrl: normalizeSearchAvatarUrl(resolvedSession?.avatarUrl) + } + }, [ + currentSessionId, + fallbackAvatarUrl, + fallbackDisplayName, + normalizedInitialSessionId, + normalizedStandaloneInitialContactType, + sessions, + standaloneSessionWindow, + isGroupChatSession + ]) + + const hydrateInSessionSearchResults = useCallback((rawMessages: Message[], sessionId?: string) => { + const sortedMessages = sortMessagesByCreateTimeDesc(rawMessages || []) + if (sortedMessages.length === 0) return [] + + const { + normalizedSessionId, + isDirectSearchSession, + isGroupSearchSession, + resolvedSessionDisplayName, + resolvedSessionAvatarUrl + } = resolveSearchSessionContext(sessionId) + const resolvedSessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId) + + return sortedMessages.map((message) => { + const senderUsername = normalizeSearchIdentityText(message.senderUsername) || message.senderUsername + const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(senderUsername, myWxid) + const senderDisplayName = resolveSearchSenderDisplayName( + message.senderDisplayName, + senderUsername, + normalizedSessionId + ) + const senderUsernameFallback = resolveSearchSenderUsernameFallback(senderUsername) + const senderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl) + const nextIsSend = inferredSelfFromSender ? 1 : message.isSend + const nextSenderDisplayName = nextIsSend === 1 + ? (senderDisplayName || '我') + : ( + senderDisplayName || + (isDirectSearchSession ? resolvedSessionDisplayName : undefined) || + senderUsernameFallback || + (isDirectSearchSession ? resolvedSessionUsernameFallback : undefined) || + '未知' + ) + const nextSenderAvatarUrl = nextIsSend === 1 + ? (senderAvatarUrl || myAvatarUrl) + : (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)) + + if (inferredSelfFromSender) { + console.info('[InSessionSearch][GroupSelfHit][hydrate]', { + sessionId: normalizedSessionId, + localId: message.localId, + senderUsername, + rawIsSend: message.isSend, + nextIsSend, + rawSenderDisplayName: message.senderDisplayName, + nextSenderDisplayName, + rawSenderAvatarUrl: message.senderAvatarUrl, + nextSenderAvatarUrl, + myWxid, + hasMyAvatarUrl: Boolean(myAvatarUrl) + }) + } + + if ( + senderUsername === message.senderUsername && + nextIsSend === message.isSend && + nextSenderDisplayName === message.senderDisplayName && + nextSenderAvatarUrl === message.senderAvatarUrl + ) { + return message + } + + return { + ...message, + isSend: nextIsSend, + senderUsername, + senderDisplayName: nextSenderDisplayName, + senderAvatarUrl: nextSenderAvatarUrl + } + }) + }, [currentSessionId, myAvatarUrl, myWxid, resolveSearchSessionContext]) + + const enrichMessagesWithSenderProfiles = useCallback(async (rawMessages: Message[], sessionId?: string) => { + let messages = hydrateInSessionSearchResults(rawMessages, sessionId) + if (messages.length === 0) return [] + + const sessionContext = resolveSearchSessionContext(sessionId) + const { normalizedSessionId, isDirectSearchSession, isGroupSearchSession } = sessionContext + let resolvedSessionDisplayName = sessionContext.resolvedSessionDisplayName + let resolvedSessionAvatarUrl = sessionContext.resolvedSessionAvatarUrl + + if ( + normalizedSessionId && + isDirectSearchSession && + ( + !resolvedSessionAvatarUrl || + !resolvedSessionDisplayName || + resolvedSessionDisplayName === normalizedSessionId + ) + ) { + try { + const result = await window.electronAPI.chat.enrichSessionsContactInfo([normalizedSessionId]) + const profile = result.success && result.contacts ? result.contacts[normalizedSessionId] : undefined + const profileDisplayName = resolveSearchSenderDisplayName( + profile?.displayName, + normalizedSessionId, + normalizedSessionId + ) + const profileAvatarUrl = normalizeSearchAvatarUrl(profile?.avatarUrl) + if (profileDisplayName) { + resolvedSessionDisplayName = profileDisplayName + } + if (profileAvatarUrl) { + resolvedSessionAvatarUrl = profileAvatarUrl + } + if (profileDisplayName || profileAvatarUrl) { + messages = messages.map((message) => { + if (message.isSend === 1) return message + const preservedDisplayName = resolveSearchSenderDisplayName( + message.senderDisplayName, + message.senderUsername, + normalizedSessionId + ) + return { + ...message, + senderDisplayName: preservedDisplayName || + profileDisplayName || + resolvedSessionDisplayName || + resolveSearchSenderUsernameFallback(message.senderUsername) || + message.senderDisplayName, + senderAvatarUrl: normalizeSearchAvatarUrl(message.senderAvatarUrl) || profileAvatarUrl || resolvedSessionAvatarUrl || message.senderAvatarUrl + } + }) + } + } catch { + // ignore session profile enrichment errors and keep raw search results usable + } + } + + if (normalizedSessionId && isGroupSearchSession) { + const missingSenderMessages = messages.filter((message) => { + if (message.localId <= 0) return false + if (message.isSend === 1) return false + return !normalizeSearchIdentityText(message.senderUsername) + }) + + if (missingSenderMessages.length > 0) { + const messageByLocalId = new Map() + for (let index = 0; index < missingSenderMessages.length; index += 8) { + const batch = missingSenderMessages.slice(index, index + 8) + const detailResults = await Promise.allSettled( + batch.map(async (message) => { + const result = await window.electronAPI.chat.getMessage(normalizedSessionId, message.localId) + if (!result.success || !result.message) return null + return { + localId: message.localId, + message: hydrateInSessionSearchResults([{ + ...message, + ...result.message, + parsedContent: message.parsedContent || result.message.parsedContent, + rawContent: message.rawContent || result.message.rawContent, + content: message.content || result.message.content + } as Message], normalizedSessionId)[0] + } + }) + ) + + for (const detail of detailResults) { + if (detail.status !== 'fulfilled' || !detail.value?.message) continue + messageByLocalId.set(detail.value.localId, detail.value.message) + } + } + + if (messageByLocalId.size > 0) { + messages = messages.map(message => messageByLocalId.get(message.localId) || message) + } + } + } + + const profileMap = new Map() + const pendingLoads: Array> = [] + const missingUsernames: string[] = [] + + const usernames = [...new Set( + messages + .map((message) => normalizeSearchIdentityText(message.senderUsername)) + .filter((username): username is string => Boolean(username)) + )] + + for (const username of usernames) { + const cached = senderAvatarCache.get(username) + if (cached) { + profileMap.set(username, cached) + continue + } + + const pending = senderAvatarLoading.get(username) + if (pending) { + pendingLoads.push( + pending.then((profile) => { + if (profile) { + profileMap.set(username, profile) + } + }).catch(() => {}) + ) + continue + } + + missingUsernames.push(username) + } + + if (pendingLoads.length > 0) { + await Promise.allSettled(pendingLoads) + } + + if (missingUsernames.length > 0) { + try { + const result = await window.electronAPI.chat.enrichSessionsContactInfo(missingUsernames) + if (result.success && result.contacts) { + for (const [username, profile] of Object.entries(result.contacts)) { + const normalizedProfile = { + avatarUrl: profile.avatarUrl, + displayName: profile.displayName + } + profileMap.set(username, normalizedProfile) + senderAvatarCache.set(username, normalizedProfile) + } + } + } catch { + // ignore sender enrichment errors and keep raw search results usable + } + } + + return messages.map((message) => { + const sender = normalizeSearchIdentityText(message.senderUsername) + const profile = sender ? profileMap.get(sender) : undefined + const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(sender, myWxid) + const profileDisplayName = resolveSearchSenderDisplayName( + profile?.displayName, + sender, + normalizedSessionId + ) + const currentSenderDisplayName = resolveSearchSenderDisplayName( + message.senderDisplayName, + sender, + normalizedSessionId + ) + const senderUsernameFallback = resolveSearchSenderUsernameFallback(sender) + const sessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId) + const currentSenderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl) + const nextIsSend = inferredSelfFromSender ? 1 : message.isSend + const nextSenderDisplayName = nextIsSend === 1 + ? (currentSenderDisplayName || profileDisplayName || '我') + : ( + profileDisplayName || + currentSenderDisplayName || + (isDirectSearchSession ? resolvedSessionDisplayName : undefined) || + senderUsernameFallback || + (isDirectSearchSession ? sessionUsernameFallback : undefined) || + '未知' + ) + const nextSenderAvatarUrl = nextIsSend === 1 + ? (currentSenderAvatarUrl || myAvatarUrl || normalizeSearchAvatarUrl(profile?.avatarUrl)) + : ( + currentSenderAvatarUrl || + normalizeSearchAvatarUrl(profile?.avatarUrl) || + (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined) + ) + + if (inferredSelfFromSender) { + console.info('[InSessionSearch][GroupSelfHit][enrich]', { + sessionId: normalizedSessionId, + localId: message.localId, + senderUsername: sender, + rawIsSend: message.isSend, + nextIsSend, + profileDisplayName, + currentSenderDisplayName, + nextSenderDisplayName, + profileAvatarUrl: normalizeSearchAvatarUrl(profile?.avatarUrl), + currentSenderAvatarUrl, + nextSenderAvatarUrl, + myWxid, + hasMyAvatarUrl: Boolean(myAvatarUrl) + }) + } + + if ( + sender === message.senderUsername && + nextIsSend === message.isSend && + nextSenderDisplayName === message.senderDisplayName && + nextSenderAvatarUrl === message.senderAvatarUrl + ) { + return message + } + + return { + ...message, + isSend: nextIsSend, + senderUsername: sender || message.senderUsername, + senderDisplayName: nextSenderDisplayName, + senderAvatarUrl: nextSenderAvatarUrl + } + }) + }, [ + currentSessionId, + hydrateInSessionSearchResults, + myAvatarUrl, + myWxid, + resolveSearchSessionContext + ]) + + const applyPendingInSessionSearch = useCallback(async ( + sessionId: string, + payload: PendingInSessionSearchPayload, + switchRequestSeq?: number + ) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + if (payload.sessionId !== normalizedSessionId) return + if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return + if (currentSessionRef.current !== normalizedSessionId) return + + const immediateResults = hydrateInSessionSearchResults(payload.results || [], normalizedSessionId) + setShowInSessionSearch(true) + setInSessionQuery(payload.keyword) + setInSessionSearchError(null) + setInSessionResults(immediateResults) + + if (payload.firstMsgTime > 0) { + handleJumpDateSelect(new Date(payload.firstMsgTime * 1000), { + sessionId: normalizedSessionId, + switchRequestSeq + }) + } + + setInSessionEnriching(true) + void enrichMessagesWithSenderProfiles(immediateResults, normalizedSessionId).then((enrichedResults) => { + if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return + if (currentSessionRef.current !== normalizedSessionId) return + setInSessionResults(enrichedResults) + }).catch((error) => { + console.warn('[InSessionSearch] 恢复全局搜索结果发送者信息失败:', error) + }).finally(() => { + if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return + if (currentSessionRef.current !== normalizedSessionId) return + setInSessionEnriching(false) + }) + }, [enrichMessagesWithSenderProfiles, handleJumpDateSelect, hydrateInSessionSearchResults]) + // 加载更晚的消息 const loadLaterMessages = useCallback(async () => { if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return @@ -2537,6 +3196,20 @@ function ChatPage(props: ChatPageProps) { if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1 sessionSwitchRequestSeqRef.current = switchRequestSeq + currentSessionRef.current = normalizedSessionId + + const pendingSearch = pendingInSessionSearchRef.current + const shouldPreservePendingSearch = pendingSearch?.sessionId === normalizedSessionId + cancelInSessionSearchTasks() + + // 清空会话内搜索状态(除非是从全局搜索跳转过来) + if (!shouldPreservePendingSearch) { + pendingInSessionSearchRef.current = null + setShowInSessionSearch(false) + setInSessionQuery('') + setInSessionResults([]) + setInSessionSearchError(null) + } setCurrentSession(normalizedSessionId, { preserveMessages: false }) setNoMessageTable(false) @@ -2546,6 +3219,13 @@ function ChatPage(props: ChatPageProps) { pendingSessionLoadRef.current = null initialLoadRequestedSessionRef.current = null setIsSessionSwitching(false) + + // 处理从全局搜索跳转过来的情况 + if (pendingSearch?.sessionId === normalizedSessionId) { + pendingInSessionSearchRef.current = null + void applyPendingInSessionSearch(normalizedSessionId, pendingSearch, switchRequestSeq) + } + void refreshSessionIncrementally(normalizedSessionId, switchRequestSeq) } else { pendingSessionLoadRef.current = normalizedSessionId @@ -2581,7 +3261,9 @@ function ChatPage(props: ChatPageProps) { restoreSessionWindowCache, refreshSessionIncrementally, hydrateSessionPreview, - loadMessages + loadMessages, + cancelInSessionSearchTasks, + applyPendingInSessionSearch ]) // 选择会话 @@ -2604,6 +3286,166 @@ function ChatPage(props: ChatPageProps) { setSearchKeyword('') } + // 会话内搜索 + const inSessionSearchTimerRef = useRef | null>(null) + const inSessionSearchGenRef = useRef(0) + const handleInSessionSearch = useCallback(async (keyword: string) => { + setInSessionQuery(keyword) + if (inSessionSearchTimerRef.current) clearTimeout(inSessionSearchTimerRef.current) + inSessionSearchTimerRef.current = null + inSessionSearchGenRef.current += 1 + if (!keyword.trim() || !currentSessionId) { + setInSessionResults([]) + setInSessionSearchError(null) + setInSessionSearching(false) + setInSessionEnriching(false) + return + } + setInSessionSearchError(null) + const gen = inSessionSearchGenRef.current + const sid = currentSessionId + inSessionSearchTimerRef.current = setTimeout(async () => { + if (gen !== inSessionSearchGenRef.current) return + setInSessionSearching(true) + try { + const res = await window.electronAPI.chat.searchMessages(keyword.trim(), sid, 50, 0) + if (!res?.success) { + throw new Error(res?.error || '搜索失败') + } + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return + const messages = hydrateInSessionSearchResults(res?.messages || [], sid) + setInSessionResults(messages) + setInSessionSearchError(null) + + setInSessionEnriching(true) + void enrichMessagesWithSenderProfiles(messages, sid).then((enriched) => { + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return + setInSessionResults(enriched) + }).catch((error) => { + console.warn('[InSessionSearch] 补充发送者信息失败:', error) + }).finally(() => { + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return + setInSessionEnriching(false) + }) + } catch (error) { + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return + setInSessionResults([]) + setInSessionSearchError(error instanceof Error ? error.message : String(error)) + setInSessionEnriching(false) + } finally { + if (gen === inSessionSearchGenRef.current) setInSessionSearching(false) + } + }, 500) + }, [currentSessionId, enrichMessagesWithSenderProfiles, hydrateInSessionSearchResults]) + + const handleToggleInSessionSearch = useCallback(() => { + setShowInSessionSearch(v => { + if (v) { + cancelInSessionSearchTasks() + setInSessionQuery('') + setInSessionResults([]) + setInSessionSearchError(null) + } else { + setTimeout(() => inSessionSearchRef.current?.focus(), 50) + } + return !v + }) + }, [cancelInSessionSearchTasks]) + + // 全局消息搜索 + const globalMsgSearchTimerRef = useRef | null>(null) + const globalMsgSearchGenRef = useRef(0) + const handleGlobalMsgSearch = useCallback(async (keyword: string) => { + const normalizedKeyword = keyword.trim() + setGlobalMsgQuery(keyword) + if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current) + globalMsgSearchTimerRef.current = null + globalMsgSearchGenRef.current += 1 + if (!normalizedKeyword) { + pendingGlobalMsgSearchReplayRef.current = null + setGlobalMsgResults([]) + setGlobalMsgSearchError(null) + setShowGlobalMsgSearch(false) + setGlobalMsgSearching(false) + return + } + setShowGlobalMsgSearch(true) + setGlobalMsgSearchError(null) + + const sessionList = Array.isArray(sessionsRef.current) ? sessionsRef.current.filter((session) => String(session.username || '').trim()) : [] + if (!isConnectedRef.current || sessionList.length === 0) { + pendingGlobalMsgSearchReplayRef.current = normalizedKeyword + setGlobalMsgResults([]) + setGlobalMsgSearchError(null) + setGlobalMsgSearching(false) + return + } + + pendingGlobalMsgSearchReplayRef.current = null + const gen = globalMsgSearchGenRef.current + globalMsgSearchTimerRef.current = setTimeout(async () => { + if (gen !== globalMsgSearchGenRef.current) return + setGlobalMsgSearching(true) + try { + const results: Array = [] + const concurrency = 6 + + for (let index = 0; index < sessionList.length; index += concurrency) { + const chunk = sessionList.slice(index, index + concurrency) + const chunkResults = await Promise.allSettled( + chunk.map(async (session) => { + const res = await window.electronAPI.chat.searchMessages(normalizedKeyword, session.username, 10, 0) + if (!res?.success) { + throw new Error(res?.error || `搜索失败: ${session.username}`) + } + if (!res?.messages?.length) return [] + return res.messages.map((msg) => ({ ...msg, sessionId: session.username })) + }) + ) + + if (gen !== globalMsgSearchGenRef.current) return + + for (const item of chunkResults) { + if (item.status === 'rejected') { + throw item.reason instanceof Error ? item.reason : new Error(String(item.reason)) + } + if (item.value.length > 0) { + results.push(...item.value) + } + } + } + + results.sort((a, b) => { + const timeDiff = (b.createTime || 0) - (a.createTime || 0) + if (timeDiff !== 0) return timeDiff + return (b.localId || 0) - (a.localId || 0) + }) + + if (gen !== globalMsgSearchGenRef.current) return + setGlobalMsgResults(results) + setGlobalMsgSearchError(null) + } catch (error) { + if (gen !== globalMsgSearchGenRef.current) return + setGlobalMsgResults([]) + setGlobalMsgSearchError(error instanceof Error ? error.message : String(error)) + } finally { + if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false) + } + }, 500) + }, []) + + const handleCloseGlobalMsgSearch = useCallback(() => { + globalMsgSearchGenRef.current += 1 + if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current) + globalMsgSearchTimerRef.current = null + pendingGlobalMsgSearchReplayRef.current = null + setShowGlobalMsgSearch(false) + setGlobalMsgQuery('') + setGlobalMsgResults([]) + setGlobalMsgSearchError(null) + setGlobalMsgSearching(false) + }, []) + // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) const scrollTimeoutRef = useRef(null) const handleScroll = useCallback(() => { @@ -2884,6 +3726,28 @@ function ChatPage(props: ChatPageProps) { isConnectedRef.current = isConnected }, [isConnected]) + useEffect(() => { + const replayKeyword = pendingGlobalMsgSearchReplayRef.current + if (!replayKeyword || !isConnected || sessions.length === 0) return + pendingGlobalMsgSearchReplayRef.current = null + void handleGlobalMsgSearch(replayKeyword) + }, [isConnected, sessions.length, handleGlobalMsgSearch]) + + useEffect(() => { + return () => { + inSessionSearchGenRef.current += 1 + if (inSessionSearchTimerRef.current) { + clearTimeout(inSessionSearchTimerRef.current) + inSessionSearchTimerRef.current = null + } + globalMsgSearchGenRef.current += 1 + if (globalMsgSearchTimerRef.current) { + clearTimeout(globalMsgSearchTimerRef.current) + globalMsgSearchTimerRef.current = null + } + } + }, []) + useEffect(() => { searchKeywordRef.current = searchKeyword }, [searchKeyword]) @@ -3017,11 +3881,32 @@ function ChatPage(props: ChatPageProps) { return } const lower = searchKeyword.toLowerCase() - setFilteredSessions(visible.filter(s => - s.displayName?.toLowerCase().includes(lower) || - s.username.toLowerCase().includes(lower) || - s.summary.toLowerCase().includes(lower) - )) + setFilteredSessions(visible + .filter(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) + return matchedByName || matchedByUsername || matchedByAlias + }) + .map(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) + + let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined + + if (matchedByUsername && !matchedByName && !matchedByAlias) { + matchedField = 'wxid' + } else if (matchedByAlias && !matchedByName && !matchedByUsername) { + matchedField = 'alias' + } else if (matchedByName && !matchedByUsername && !matchedByAlias) { + matchedField = 'name' + } + + // ✅ 关键点:返回一个新对象,解耦全局状态 + return { ...s, matchedField } + }) + ) }, [sessions, searchKeyword, setFilteredSessions]) // 折叠群列表(独立计算,供折叠 panel 使用) @@ -3030,11 +3915,34 @@ function ChatPage(props: ChatPageProps) { const folded = sessions.filter(s => s.isFolded) if (!searchKeyword.trim() || !foldedView) return folded const lower = searchKeyword.toLowerCase() - return folded.filter(s => - s.displayName?.toLowerCase().includes(lower) || - s.username.toLowerCase().includes(lower) || - s.summary.toLowerCase().includes(lower) - ) + return folded + // 1. 先过滤 + .filter(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) + const matchedBySummary = s.summary?.toLowerCase().includes(lower) // 注意:这里有个 summary + + return matchedByName || matchedByUsername || matchedByAlias || matchedBySummary + }) + // 2. 后映射 + .map(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) + const matchedBySummary = s.summary?.toLowerCase().includes(lower) + + let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined + + if (matchedByUsername && !matchedByName && !matchedBySummary && !matchedByAlias) { + matchedField = 'wxid' + } else if (matchedByAlias && !matchedByName && !matchedBySummary && !matchedByUsername) { + matchedField = 'alias' + } + + // ✅ 同样返回新对象 + return { ...s, matchedField } + }) }, [sessions, searchKeyword, foldedView]) const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0 @@ -3571,38 +4479,38 @@ function ChatPage(props: ChatPageProps) { const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates]) const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), []) - const lastSelectedIdRef = useRef(null) + const lastSelectedKeyRef = useRef(null) - const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => { + const handleToggleSelection = useCallback((messageKey: string, isShiftKey: boolean = false) => { setSelectedMessages(prev => { const next = new Set(prev) // Range selection with Shift key - if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) { + if (isShiftKey && lastSelectedKeyRef.current !== null && lastSelectedKeyRef.current !== messageKey) { const currentMsgs = useChatStore.getState().messages || [] - const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current) - const idx2 = currentMsgs.findIndex(m => m.localId === localId) + const idx1 = currentMsgs.findIndex(m => getMessageKey(m) === lastSelectedKeyRef.current) + const idx2 = currentMsgs.findIndex(m => getMessageKey(m) === messageKey) if (idx1 !== -1 && idx2 !== -1) { const start = Math.min(idx1, idx2) const end = Math.max(idx1, idx2) for (let i = start; i <= end; i++) { - next.add(currentMsgs[i].localId) + next.add(getMessageKey(currentMsgs[i])) } } } else { // Normal toggle - if (next.has(localId)) { - next.delete(localId) - lastSelectedIdRef.current = null // Reset last selection on uncheck? Or keep? Usually keep last interaction. + if (next.has(messageKey)) { + next.delete(messageKey) + lastSelectedKeyRef.current = null } else { - next.add(localId) - lastSelectedIdRef.current = localId + next.add(messageKey) + lastSelectedKeyRef.current = messageKey } } return next }) - }, []) + }, [getMessageKey]) const formatBatchDateLabel = useCallback((dateStr: string) => { const [y, m, d] = dateStr.split('-').map(Number) @@ -3646,11 +4554,12 @@ function ChatPage(props: ChatPageProps) { // 执行单条删除动作 const performSingleDelete = async (msg: Message) => { try { - const dbPathHint = (msg as any)._db_path + const targetMessageKey = getMessageKey(msg) + const dbPathHint = msg._db_path const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint) if (result.success) { const currentMessages = useChatStore.getState().messages || [] - const newMessages = currentMessages.filter(m => m.localId !== msg.localId) + const newMessages = currentMessages.filter(m => getMessageKey(m) !== targetMessageKey) useChatStore.getState().setMessages(newMessages) } else { alert('删除失败: ' + (result.error || '原因未知')) @@ -3712,7 +4621,7 @@ function ChatPage(props: ChatPageProps) { if (result.success) { const currentMessages = useChatStore.getState().messages || [] const newMessages = currentMessages.map(m => { - if (m.localId === editingMessage.message.localId) { + if (getMessageKey(m) === getMessageKey(editingMessage.message)) { return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent } } return m @@ -3753,37 +4662,44 @@ function ChatPage(props: ChatPageProps) { try { const currentMessages = useChatStore.getState().messages || [] - const selectedIds = Array.from(selectedMessages) - const deletedIds = new Set() + const selectedKeys = Array.from(selectedMessages) + const deletedKeys = new Set() - for (let i = 0; i < selectedIds.length; i++) { + for (let i = 0; i < selectedKeys.length; i++) { if (cancelDeleteRef.current) break - const id = selectedIds[i] - const msgObj = currentMessages.find(m => m.localId === id) - const dbPathHint = (msgObj as any)?._db_path + const key = selectedKeys[i] + const msgObj = currentMessages.find(m => getMessageKey(m) === key) + const dbPathHint = msgObj?._db_path const createTime = msgObj?.createTime || 0 + const localId = msgObj?.localId || 0 - try { - const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, id, createTime, dbPathHint) - if (result.success) { - deletedIds.add(id) - } - } catch (err) { - console.error(`删除消息 ${id} 失败:`, err) + if (!msgObj) { + setDeleteProgress({ current: i + 1, total: selectedKeys.length }) + continue } - setDeleteProgress({ current: i + 1, total: selectedIds.length }) + try { + const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, localId, createTime, dbPathHint) + if (result.success) { + deletedKeys.add(key) + } + } catch (err) { + console.error(`删除消息 ${localId} 失败:`, err) + } + + setDeleteProgress({ current: i + 1, total: selectedKeys.length }) } - const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedIds.has(m.localId)) + const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedKeys.has(getMessageKey(m))) useChatStore.getState().setMessages(finalMessages) setIsSelectionMode(false) - setSelectedMessages(new Set()) + setSelectedMessages(new Set()) + lastSelectedKeyRef.current = null if (cancelDeleteRef.current) { - alert(`操作已中止。已删除 ${deletedIds.size} 条,剩余记录保留。`) + alert(`操作已中止。已删除 ${deletedKeys.size} 条,剩余记录保留。`) } } catch (e) { alert('批量删除出现错误: ' + String(e)) @@ -3885,10 +4801,13 @@ function ChatPage(props: ChatPageProps) { type="text" placeholder="搜索" value={searchKeyword} - onChange={(e) => handleSearch(e.target.value)} + onChange={(e) => { + handleSearch(e.target.value) + handleGlobalMsgSearch(e.target.value) + }} /> {searchKeyword && ( - )} @@ -3920,6 +4839,80 @@ function ChatPage(props: ChatPageProps) {
)} + {/* 全局消息搜索结果 */} + {globalMsgQuery && ( +
+ {globalMsgSearching ? ( +
+ + 搜索中... +
+ ) : globalMsgSearchError ? ( +
+ +

{globalMsgSearchError}

+
+ ) : globalMsgResults.length > 0 ? ( + <> +
聊天记录:
+
+ {Object.entries( + globalMsgResults.reduce((acc, msg) => { + const sessionId = (msg as any).sessionId || '未知'; + if (!acc[sessionId]) acc[sessionId] = []; + acc[sessionId].push(msg); + return acc; + }, {} as Record) + ).map(([sessionId, messages]) => { + const session = sessions.find(s => s.username === sessionId); + const firstMsg = messages[0]; + const count = messages.length; + return ( +
{ + if (session) { + pendingInSessionSearchRef.current = { + sessionId, + keyword: globalMsgQuery, + firstMsgTime: firstMsg.createTime || 0, + results: messages + } + handleSelectSession(session) + } + }} + > + +
+
+ {session?.displayName || sessionId} +
+
+ +
+ {count > 1 && ( +
共 {count} 条相关聊天记录
+ )} +
+
+ ); + })} +
+ + ) : ( +
+ +

未找到相关消息

+
+ )} +
+ )} + {/* ... (previous content) ... */} {shouldShowSessionsSkeleton ? (
@@ -3938,30 +4931,36 @@ function ChatPage(props: ChatPageProps) { {/* 普通会话列表 */}
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( -
{ - isScrollingRef.current = true - if (sessionScrollTimeoutRef.current) { - clearTimeout(sessionScrollTimeoutRef.current) - } - sessionScrollTimeoutRef.current = window.setTimeout(() => { - isScrollingRef.current = false - sessionScrollTimeoutRef.current = null - }, 200) - }} - > - {filteredSessions.map(session => ( + <> + {searchKeyword && ( +
联系人:
+ )} +
{ + isScrollingRef.current = true + if (sessionScrollTimeoutRef.current) { + clearTimeout(sessionScrollTimeoutRef.current) + } + sessionScrollTimeoutRef.current = window.setTimeout(() => { + isScrollingRef.current = false + sessionScrollTimeoutRef.current = null + }, 200) + }} + > + {filteredSessions.map(session => ( ))}
+ ) : (
@@ -3982,6 +4981,7 @@ function ChatPage(props: ChatPageProps) { isActive={currentSessionId === session.username} onSelect={handleSelectSession} formatTime={formatSessionTime} + searchKeyword={searchKeyword} /> ))}
@@ -3994,12 +4994,9 @@ function ChatPage(props: ChatPageProps) {
)} - -
)} - {/* 拖动调节条 */} {!standaloneSessionWindow &&
} {/* 右侧消息区域 */} @@ -4138,6 +5135,14 @@ function ChatPage(props: ChatPageProps) {
, document.body )} + +
+ {inSessionQuery && ( +
+ {inSessionSearching + ? '搜索中...' + : inSessionSearchError + ? '搜索失败' + : `找到 ${inSessionResults.length} 条结果`} +
+ )} + {inSessionQuery && !inSessionSearching && inSessionSearchError && ( +
+ +

{inSessionSearchError}

+
+ )} + {inSessionResults.length > 0 && ( +
+ {inSessionResults.map((msg, i) => { + const resolvedSenderDisplayName = resolveSearchSenderDisplayName( + msg.senderDisplayName, + msg.senderUsername, + currentSessionId + ) + const resolvedSenderUsername = resolveSearchSenderUsernameFallback(msg.senderUsername) + const resolvedSenderAvatarUrl = normalizeSearchAvatarUrl(msg.senderAvatarUrl) + const resolvedCurrentSessionName = normalizeSearchIdentityText(currentSession?.displayName) || + resolveSearchSenderUsernameFallback(currentSession?.username) || + resolveSearchSenderUsernameFallback(currentSessionId) + const senderName = resolvedSenderDisplayName || ( + msg.isSend === 1 + ? '我' + : (isCurrentSessionPrivateSnsSupported + ? resolvedCurrentSessionName || (inSessionEnriching ? '加载中...' : '未知') + : resolvedSenderUsername || (inSessionEnriching ? '加载中...' : '未知成员')) + ) + const senderAvatar = resolvedSenderAvatarUrl || ( + msg.isSend === 1 + ? myAvatarUrl + : (isCurrentSessionPrivateSnsSupported ? normalizeSearchAvatarUrl(currentSession?.avatarUrl) : undefined) + ) + const senderAvatarLoading = inSessionEnriching && !senderAvatar + const previewText = (msg.parsedContent || msg.content || '').slice(0, 80) + const displayTime = msg.createTime + ? new Date(msg.createTime * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + : '' + + return ( +
{ + const ts = msg.createTime + if (ts && currentSessionId) { + setCurrentOffset(0) + setJumpStartTime(0) + setJumpEndTime(0) + void loadMessages(currentSessionId, 0, ts - 1, ts + 1, false) + } + }}> +
+ +
+
+ {senderName} + {previewText} +
+ {displayTime} +
+ ) + })} +
+ )} + {inSessionQuery && !inSessionSearching && !inSessionSearchError && inSessionResults.length === 0 && ( +
+ +

未找到相关消息

+
+ )} +
+ )} +
{standaloneSessionWindow && standaloneLoadStage !== 'ready' && (
@@ -4238,7 +5339,8 @@ function ChatPage(props: ChatPageProps) { onRequireModelDownload={handleRequireModelDownload} onContextMenu={handleContextMenu} isSelectionMode={isSelectionMode} - isSelected={selectedMessages.has(msg.localId)} + messageKey={messageKey} + isSelected={selectedMessages.has(messageKey)} onToggleSelection={handleToggleSelection} />
@@ -4328,12 +5430,12 @@ function ChatPage(props: ChatPageProps) {
{member.isOwner && ( - + 群主 )} {member.isFriend && ( - + 好友 )}
@@ -4813,7 +5915,8 @@ function ChatPage(props: ChatPageProps) {
{ setIsSelectionMode(true) - setSelectedMessages(new Set([contextMenu.message.localId])) + setSelectedMessages(new Set([getMessageKey(contextMenu.message)])) + lastSelectedKeyRef.current = getMessageKey(contextMenu.message) setContextMenu(null) }}> @@ -5089,7 +6192,8 @@ function ChatPage(props: ChatPageProps) { className="btn-secondary" onClick={() => { setIsSelectionMode(false) - setSelectedMessages(new Set()) + setSelectedMessages(new Set()) + lastSelectedKeyRef.current = null }} style={{ padding: '6px 16px', @@ -5167,6 +6271,7 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) { // 消息气泡组件 function MessageBubble({ message, + messageKey, session, showTime, myAvatarUrl, @@ -5178,6 +6283,7 @@ function MessageBubble({ onToggleSelection }: { message: Message; + messageKey: string; session: ChatSession; showTime?: boolean; myAvatarUrl?: string; @@ -5186,7 +6292,7 @@ function MessageBubble({ onContextMenu?: (e: React.MouseEvent, message: Message) => void; isSelectionMode?: boolean; isSelected?: boolean; - onToggleSelection?: (localId: number, isShiftKey?: boolean) => void; + onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void; }) { const isSystem = isSystemMessage(message.localType) const isEmoji = message.localType === 47 @@ -5964,7 +7070,7 @@ function MessageBubble({ onClick={(e) => { if (isSelectionMode) { e.stopPropagation() - onToggleSelection?.(message.localId, e.shiftKey) + onToggleSelection?.(messageKey, e.shiftKey) } }} > @@ -7125,7 +8231,7 @@ function MessageBubble({ onClick={(e) => { if (isSelectionMode) { e.stopPropagation() - onToggleSelection?.(message.localId, e.shiftKey) + onToggleSelection?.(messageKey, e.shiftKey) } }} > diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index cd5183f..0b228ea 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1596,6 +1596,7 @@ function ExportPage() { const sessionMutualFriendsRunIdRef = useRef(0) const sessionMutualFriendsWorkerRunningRef = useRef(false) const sessionMutualFriendsBackgroundFeedTimerRef = useRef(null) + const sessionMutualFriendsPersistTimerRef = useRef(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 = {} + 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([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>((acc, [sessionIdRaw, metricRaw]) => { + const sessionId = String(sessionIdRaw || '').trim() + if (!exportableSessionIdSet.has(sessionId) || !isSingleContactSession(sessionId)) return acc + const metric = metricRaw as SessionMutualFriendsMetric | undefined + if (!metric || !Array.isArray(metric.items) || !Number.isFinite(metric.count)) return acc + acc[sessionId] = metric + return acc + }, {}) + const cachedMutualFriendSessionIds = Object.keys(cachedMutualFriendDirectMetrics) if (isStale()) return if (Object.keys(cachedMessageCounts).length > 0) { @@ -3422,6 +3483,13 @@ function ExportPage() { if (Object.keys(cachedContentMetrics).length > 0) { mergeSessionContentMetrics(cachedContentMetrics) } + if (cachedMutualFriendSessionIds.length > 0) { + sessionMutualFriendsDirectMetricsRef.current = cachedMutualFriendDirectMetrics + rebuildSessionMutualFriendsStateFromDirectMetrics(cachedMutualFriendSessionIds) + } else { + sessionMutualFriendsMetricsRef.current = {} + setSessionMutualFriendsMetrics({}) + } setSessions(baseSessions) sessionsHydratedAtRef.current = Date.now() void (async () => { @@ -3622,7 +3690,7 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts]) + }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, rebuildSessionMutualFriendsStateFromDirectMetrics, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return @@ -3630,10 +3698,7 @@ function ExportPage() { const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current && sessionsRef.current.length > 0 && now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS - const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current && - now - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS - - void loadBaseConfig() + const baseConfigPromise = loadBaseConfig() void ensureSharedTabCountsLoaded() if (!hasFreshSessionSnapshot) { void loadSessions() @@ -3641,9 +3706,14 @@ function ExportPage() { // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { - if (!hasFreshSnsSnapshot) { - void loadSnsStats({ full: true }) - } + void (async () => { + await baseConfigPromise + const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current && + Date.now() - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS + if (!hasFreshSnsSnapshot) { + void loadSnsStats({ full: true }) + } + })() }, 120) return () => window.clearTimeout(timer) @@ -4988,9 +5058,14 @@ function ExportPage() { window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current) sessionMutualFriendsBackgroundFeedTimerRef.current = null } + if (sessionMutualFriendsPersistTimerRef.current) { + window.clearTimeout(sessionMutualFriendsPersistTimerRef.current) + sessionMutualFriendsPersistTimerRef.current = null + } void flushSessionMediaMetricCache() + void flushSessionMutualFriendsCache() } - }, [flushSessionMediaMetricCache]) + }, [flushSessionMediaMetricCache, flushSessionMutualFriendsCache]) const contactByUsername = useMemo(() => { const map = new Map() @@ -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() { )}
- @@ -6468,7 +6634,7 @@ function ExportPage() {
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • - diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index d7f5184..b1b0eab 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -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; + } +} diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index ae372ad..db14c4d 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,11 +1,12 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useLocation } from 'react-router-dom' -import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown } from 'lucide-react' +import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare } from 'lucide-react' import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' import ChatAnalysisHeader from '../components/ChatAnalysisHeader' import * as configService from '../services/config' +import type { Message } from '../types/models' import { finishBackgroundTask, isBackgroundTaskCancelRequested, @@ -36,7 +37,7 @@ interface GroupMessageRank { messageCount: number } -type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats' +type AnalysisFunction = 'members' | 'memberMessages' | 'ranking' | 'activeHours' | 'mediaStats' type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' interface MemberMessageExportOptions { @@ -57,14 +58,105 @@ interface MemberExportFormatOption { desc: string } +interface GroupMemberMessagesPage { + messages: Message[] + hasMore: boolean + nextCursor: number +} + +const MEMBER_MESSAGE_PAGE_SIZE = 40 + +const filterMembersByKeyword = (members: GroupMember[], keyword: string) => { + const normalizedKeyword = keyword.trim().toLowerCase() + if (!normalizedKeyword) return members + return members.filter(member => { + const fields = [ + member.username, + member.displayName, + member.nickname, + member.remark, + member.alias, + member.groupNickname + ] + return fields.some(field => String(field || '').toLowerCase().includes(normalizedKeyword)) + }) +} + +const formatMemberMessageTime = (createTime: number) => { + if (!createTime) return '-' + return new Date(createTime * 1000).toLocaleString('zh-CN', { hour12: false }) +} + +const getMemberMessageTypeLabel = (message: Message) => { + switch (message.localType) { + case 1: + return '文本' + case 3: + return '图片' + case 34: + return '语音' + case 42: + return '名片' + case 43: + return '视频' + case 47: + return '表情' + case 48: + return '位置' + case 49: + return message.fileName ? '文件' : '链接' + case 50: + return '通话' + case 10000: + case 10002: + return '系统' + default: + return `类型 ${message.localType}` + } +} + +const getMemberMessagePreview = (message: Message) => { + const text = (message.parsedContent || message.content || message.rawContent || '').trim() + switch (message.localType) { + case 1: + case 10000: + case 10002: + return text || '[空文本]' + case 3: + return text || '[图片]' + case 34: + return message.voiceDurationSeconds ? `[语音] ${message.voiceDurationSeconds} 秒` : '[语音]' + case 42: + return `[名片] ${message.cardNickname || message.cardUsername || text || '联系人名片'}` + case 43: + return text || '[视频]' + case 47: + return text || '[表情]' + case 48: + return `[位置] ${message.locationPoiname || message.locationLabel || text || '位置消息'}` + case 49: + if (message.fileName) return `[文件] ${message.fileName}` + if (message.linkTitle) return `[链接] ${message.linkTitle}` + return text || '[链接/文件]' + case 50: + return text || '[通话]' + default: + return text || `[消息类型 ${message.localType}]` + } +} + function GroupAnalyticsPage() { const location = useLocation() const [groups, setGroups] = useState([]) const [filteredGroups, setFilteredGroups] = useState([]) const [isLoading, setIsLoading] = useState(true) - const [selectedGroup, setSelectedGroup] = useState(null) + const [selectedGroupId, setSelectedGroupId] = useState(null) const [selectedFunction, setSelectedFunction] = useState(null) const [searchQuery, setSearchQuery] = useState('') + const selectedGroup = useMemo( + () => (selectedGroupId ? groups.find(group => group.username === selectedGroupId) || null : null), + [groups, selectedGroupId] + ) // 功能数据 const [members, setMembers] = useState([]) @@ -74,7 +166,11 @@ function GroupAnalyticsPage() { const [functionLoading, setFunctionLoading] = useState(false) const [isExportingMembers, setIsExportingMembers] = useState(false) const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) - const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('') + const [memberMessages, setMemberMessages] = useState([]) + const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false) + const [memberMessagesCursor, setMemberMessagesCursor] = useState(0) + const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false) + const [selectedMessageMemberUsername, setSelectedMessageMemberUsername] = useState('') const [exportFolder, setExportFolder] = useState('') const [memberExportOptions, setMemberExportOptions] = useState({ format: 'excel', @@ -91,11 +187,17 @@ function GroupAnalyticsPage() { // 成员详情弹框 const [selectedMember, setSelectedMember] = useState(null) const [copiedField, setCopiedField] = useState(null) - const [showMemberSelect, setShowMemberSelect] = useState(false) + const [showMemberExportModal, setShowMemberExportModal] = useState(false) + const [exportResultDialog, setExportResultDialog] = useState<{ + title: string + message: string + tone: 'success' | 'error' + } | null>(null) + const [showMessageMemberSelect, setShowMessageMemberSelect] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) - const [memberSearchKeyword, setMemberSearchKeyword] = useState('') - const memberSelectDropdownRef = useRef(null) + const [messageMemberSearchKeyword, setMessageMemberSearchKeyword] = useState('') + const messageMemberSelectDropdownRef = useRef(null) const formatDropdownRef = useRef(null) const displayNameDropdownRef = useRef(null) @@ -141,9 +243,9 @@ function GroupAnalyticsPage() { { value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' }, { value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' } ]), []) - const selectedExportMember = useMemo( - () => members.find(member => member.username === selectedExportMemberUsername) || null, - [members, selectedExportMemberUsername] + const selectedMessageMember = useMemo( + () => members.find(member => member.username === selectedMessageMemberUsername) || null, + [members, selectedMessageMemberUsername] ) const selectedFormatOption = useMemo( () => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0], @@ -153,20 +255,26 @@ function GroupAnalyticsPage() { () => displayNameOptions.find(option => option.value === memberExportOptions.displayNamePreference) || displayNameOptions[0], [displayNameOptions, memberExportOptions.displayNamePreference] ) - const filteredMemberOptions = useMemo(() => { - const keyword = memberSearchKeyword.trim().toLowerCase() - if (!keyword) return members - return members.filter(member => { - const fields = [ - member.username, - member.displayName, - member.nickname, - member.remark, - member.alias - ] - return fields.some(field => String(field || '').toLowerCase().includes(keyword)) - }) - }, [memberSearchKeyword, members]) + const filteredMessageMemberOptions = useMemo(() => { + return filterMembersByKeyword(members, messageMemberSearchKeyword) + }, [members, messageMemberSearchKeyword]) + + const resetMemberMessageState = useCallback((clearSelection = true) => { + setMemberMessages([]) + setMemberMessagesHasMore(false) + setMemberMessagesCursor(0) + setMemberMessagesLoadingMore(false) + setShowMessageMemberSelect(false) + if (clearSelection) { + setSelectedMessageMemberUsername('') + setMessageMemberSearchKeyword('') + } + }, []) + + const getSelectedTimeRange = () => ({ + startTime: startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined, + endTime: endDate ? Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000) : undefined + }) const loadExportPath = useCallback(async () => { try { @@ -240,20 +348,20 @@ function GroupAnalyticsPage() { useEffect(() => { if (members.length === 0) { - setSelectedExportMemberUsername('') + setSelectedMessageMemberUsername('') return } - const exists = members.some(member => member.username === selectedExportMemberUsername) - if (!exists) { - setSelectedExportMemberUsername(members[0].username) + const messageExists = members.some(member => member.username === selectedMessageMemberUsername) + if (!messageExists) { + setSelectedMessageMemberUsername(members[0].username) } - }, [members, selectedExportMemberUsername]) + }, [members, selectedMessageMemberUsername]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node - if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) { - setShowMemberSelect(false) + if (showMessageMemberSelect && messageMemberSelectDropdownRef.current && !messageMemberSelectDropdownRef.current.contains(target)) { + setShowMessageMemberSelect(false) } if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) { setShowFormatSelect(false) @@ -264,7 +372,7 @@ function GroupAnalyticsPage() { } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showDisplayNameSelect, showFormatSelect, showMemberSelect]) + }, [showDisplayNameSelect, showFormatSelect, showMessageMemberSelect]) useEffect(() => { if (preselectAppliedRef.current) return @@ -274,7 +382,7 @@ function GroupAnalyticsPage() { preselectAppliedRef.current = true if (matchedGroup) { - setSelectedGroup(matchedGroup) + setSelectedGroupId(matchedGroup.username) setSelectedFunction(null) setSearchQuery('') } @@ -301,7 +409,7 @@ function GroupAnalyticsPage() { // 日期范围变化时自动刷新 useEffect(() => { - if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') { + if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') { setDateRangeReady(false) loadFunctionData(selectedFunction) } @@ -311,9 +419,11 @@ function GroupAnalyticsPage() { const handleChange = () => { setGroups([]) setFilteredGroups([]) - setSelectedGroup(null) + setSelectedGroupId(null) setSelectedFunction(null) setMembers([]) + resetMemberMessageState() + setShowMemberExportModal(false) setRankings([]) setActiveHours({}) setMediaStats(null) @@ -322,41 +432,77 @@ function GroupAnalyticsPage() { } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadExportPath, loadGroups]) + }, [loadExportPath, loadGroups, resetMemberMessageState]) const handleGroupSelect = (group: GroupChatInfo) => { - if (selectedGroup?.username !== group.username) { - setSelectedGroup(group) - setSelectedFunction(null) - setSelectedExportMemberUsername('') - setMemberSearchKeyword('') - setShowMemberSelect(false) - setShowFormatSelect(false) - setShowDisplayNameSelect(false) - } + setSelectedGroupId(group.username) + setSelectedFunction(null) + setSelectedMember(null) + setShowMemberExportModal(false) + resetMemberMessageState() + setShowFormatSelect(false) + setShowDisplayNameSelect(false) } + const loadMemberMessagesPage = async ( + targetGroup: GroupChatInfo, + memberUsername: string, + options?: { + cursor?: number + append?: boolean + startTime?: number + endTime?: number + } + ): Promise => { + const result = await window.electronAPI.groupAnalytics.getGroupMemberMessages(targetGroup.username, memberUsername, { + startTime: options?.startTime, + endTime: options?.endTime, + limit: MEMBER_MESSAGE_PAGE_SIZE, + cursor: options?.cursor && options.cursor > 0 ? options.cursor : undefined + }) + if (!result.success || !result.data) { + throw new Error(result.error || '读取成员消息失败') + } + + setMemberMessages(prev => { + if (!options?.append) return result.data!.messages + const next = [...prev] + const seen = new Set(prev.map(message => message.messageKey)) + for (const message of result.data!.messages) { + if (seen.has(message.messageKey)) continue + seen.add(message.messageKey) + next.push(message) + } + return next + }) + setMemberMessagesHasMore(result.data.hasMore) + setMemberMessagesCursor(result.data.nextCursor || 0) + return result.data + } + const handleFunctionSelect = async (func: AnalysisFunction) => { if (!selectedGroup) return setSelectedFunction(func) await loadFunctionData(func) } - const loadFunctionData = async (func: AnalysisFunction) => { - if (!selectedGroup) return + const loadFunctionData = async ( + func: AnalysisFunction, + targetGroup: GroupChatInfo | null = selectedGroup, + preferredMemberUsername?: string + ) => { + if (!targetGroup) return const taskId = registerBackgroundTask({ sourcePage: 'groupAnalytics', title: `群分析:${func}`, - detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`, + detail: `正在读取 ${targetGroup.displayName || targetGroup.username} 的分析数据`, progressText: func, cancelable: true }) setFunctionLoading(true) - // 计算时间戳 - const startTime = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined - const endTime = endDate ? Math.floor(new Date(endDate + 'T23:59:59').getTime() / 1000) : undefined + const { startTime, endTime } = getSelectedTimeRange() try { switch (func) { @@ -365,7 +511,7 @@ function GroupAnalyticsPage() { detail: '正在读取群成员列表', progressText: '成员列表' }) - const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群成员列表未继续写入' }) return @@ -377,20 +523,46 @@ function GroupAnalyticsPage() { }) break } - case 'memberExport': { + case 'memberMessages': { updateBackgroundTask(taskId, { - detail: '正在读取导出成员列表', - progressText: '成员导出' + detail: '正在读取成员列表与消息', + progressText: '成员消息' }) - const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username) if (isBackgroundTaskCancelRequested(taskId)) { - finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员导出列表未继续写入' }) + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员消息未继续写入' }) return } - if (result.success && result.data) setMembers(result.data) - finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { - detail: result.success ? `成员导出列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取成员导出列表失败'), - progressText: result.success ? `${result.data?.length || 0} 人` : '失败' + if (!result.success || !result.data) { + resetMemberMessageState() + finishBackgroundTask(taskId, 'failed', { + detail: result.error || '读取群成员失败', + progressText: '失败' + }) + break + } + + setMembers(result.data) + const targetMember = result.data.find(member => member.username === (preferredMemberUsername || selectedMessageMemberUsername)) || result.data[0] + + if (!targetMember) { + resetMemberMessageState() + finishBackgroundTask(taskId, 'completed', { + detail: '当前群暂无可用成员数据', + progressText: '0 条' + }) + break + } + + setSelectedMessageMemberUsername(targetMember.username) + updateBackgroundTask(taskId, { + detail: `正在读取 ${targetMember.displayName || targetMember.username} 的发言记录`, + progressText: '消息分页' + }) + const page = await loadMemberMessagesPage(targetGroup, targetMember.username, { startTime, endTime }) + finishBackgroundTask(taskId, 'completed', { + detail: `成员消息加载完成,已读取 ${page.messages.length} 条`, + progressText: `${page.messages.length} 条` }) break } @@ -399,7 +571,7 @@ function GroupAnalyticsPage() { detail: '正在计算群消息排行', progressText: '消息排行' }) - const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime) + const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(targetGroup.username, 20, startTime, endTime) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息排行未继续写入' }) return @@ -416,7 +588,7 @@ function GroupAnalyticsPage() { detail: '正在计算群活跃时段', progressText: '活跃时段' }) - const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime) + const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(targetGroup.username, startTime, endTime) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群活跃时段未继续写入' }) return @@ -433,7 +605,7 @@ function GroupAnalyticsPage() { detail: '正在统计群消息类型', progressText: '消息类型' }) - const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime) + const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(targetGroup.username, startTime, endTime) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息类型统计未继续写入' }) return @@ -523,12 +695,11 @@ function GroupAnalyticsPage() { const handleRefresh = () => { if (selectedFunction) { - loadFunctionData(selectedFunction) + void loadFunctionData(selectedFunction) } } const handleDateRangeComplete = () => { - if (selectedFunction === 'memberExport') return setDateRangeReady(true) } @@ -537,6 +708,69 @@ function GroupAnalyticsPage() { setCopiedField(null) } + const openSelectedGroupChat = () => { + if (!selectedGroup) return + void window.electronAPI.window.openSessionChatWindow(selectedGroup.username, { + source: 'chat', + initialDisplayName: selectedGroup.displayName || selectedGroup.username, + initialAvatarUrl: selectedGroup.avatarUrl, + initialContactType: 'group' + }) + } + + const handleMessageMemberSelect = async (memberUsername: string) => { + if (!selectedGroup) return + setSelectedMessageMemberUsername(memberUsername) + setMessageMemberSearchKeyword('') + setShowMessageMemberSelect(false) + setFunctionLoading(true) + try { + const { startTime, endTime } = getSelectedTimeRange() + await loadMemberMessagesPage(selectedGroup, memberUsername, { startTime, endTime }) + } catch (e) { + console.error('读取成员消息失败:', e) + alert(`读取成员消息失败:${String(e)}`) + } finally { + setFunctionLoading(false) + } + } + + const handleLoadMoreMemberMessages = async () => { + if (!selectedGroup || !selectedMessageMemberUsername || !memberMessagesHasMore || memberMessagesLoadingMore) return + setMemberMessagesLoadingMore(true) + try { + const { startTime, endTime } = getSelectedTimeRange() + await loadMemberMessagesPage(selectedGroup, selectedMessageMemberUsername, { + cursor: memberMessagesCursor, + append: true, + startTime, + endTime + }) + } catch (e) { + console.error('加载更多成员消息失败:', e) + alert(`加载更多成员消息失败:${String(e)}`) + } finally { + setMemberMessagesLoadingMore(false) + } + } + + const handleViewMemberMessagesFromModal = async (member: GroupMember) => { + if (!selectedGroup) return + setSelectedMember(null) + setSelectedFunction('memberMessages') + setSelectedMessageMemberUsername(member.username) + setMessageMemberSearchKeyword('') + setShowMessageMemberSelect(false) + await loadFunctionData('memberMessages', selectedGroup, member.username) + } + + const handleOpenMemberExportModal = () => { + setShowMessageMemberSelect(false) + setShowFormatSelect(false) + setShowDisplayNameSelect(false) + setShowMemberExportModal(true) + } + const handleExportMembers = async () => { if (!selectedGroup || isExportingMembers) return setIsExportingMembers(true) @@ -554,13 +788,25 @@ function GroupAnalyticsPage() { const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath) if (result.success) { - alert(`导出成功,共 ${result.count ?? members.length} 人`) + setExportResultDialog({ + title: '导出成功', + message: `共导出 ${result.count ?? members.length} 人`, + tone: 'success' + }) } else { - alert(`导出失败:${result.error || '未知错误'}`) + setExportResultDialog({ + title: '导出失败', + message: result.error || '未知错误', + tone: 'error' + }) } } catch (e) { console.error('导出群成员失败:', e) - alert(`导出失败:${String(e)}`) + setExportResultDialog({ + title: '导出失败', + message: String(e), + tone: 'error' + }) } finally { setIsExportingMembers(false) } @@ -599,8 +845,8 @@ function GroupAnalyticsPage() { } const handleExportMemberMessages = async () => { - if (!selectedGroup || !selectedExportMemberUsername || !exportFolder || isExportingMemberMessages) return - const member = members.find(item => item.username === selectedExportMemberUsername) + if (!selectedGroup || !selectedMessageMemberUsername || !exportFolder || isExportingMemberMessages) return + const member = members.find(item => item.username === selectedMessageMemberUsername) if (!member) { alert('请先选择成员') return @@ -634,13 +880,26 @@ function GroupAnalyticsPage() { } ) if (result.success && (result.successCount ?? 0) > 0) { - alert(`导出成功:${member.displayName || member.username}`) + setShowMemberExportModal(false) + setExportResultDialog({ + title: '导出成功', + message: `已导出 ${member.displayName || member.username}`, + tone: 'success' + }) } else { - alert(`导出失败:${result.error || '未知错误'}`) + setExportResultDialog({ + title: '导出失败', + message: result.error || '未知错误', + tone: 'error' + }) } } catch (e) { console.error('导出成员消息失败:', e) - alert(`导出失败:${String(e)}`) + setExportResultDialog({ + title: '导出失败', + message: String(e), + tone: 'error' + }) } finally { setIsExportingMemberMessages(false) } @@ -719,6 +978,16 @@ function GroupAnalyticsPage() {
    )}
    +
    + +
    @@ -770,7 +1039,7 @@ function GroupAnalyticsPage() { filteredGroups.map(group => (
    handleGroupSelect(group)} >
    @@ -794,29 +1063,37 @@ function GroupAnalyticsPage() {
    -

    {selectedGroup?.displayName}

    -

    {selectedGroup?.memberCount} 位成员

    +
    + 已选择群聊 +

    {selectedGroup?.displayName}

    +

    {selectedGroup?.memberCount} 位成员

    +
    handleFunctionSelect('members')}> 群成员查看 + 查看群成员列表和基础资料
    -
    handleFunctionSelect('memberExport')}> - - 成员消息导出 +
    handleFunctionSelect('memberMessages')}> + + 成员消息筛选与导出 + 按成员查看群聊消息,并支持导出当前成员记录
    handleFunctionSelect('ranking')}> 群聊发言排行 + 统计成员发言数量排行
    handleFunctionSelect('activeHours')}> 群聊活跃时段 + 查看全天活跃时间分布
    handleFunctionSelect('mediaStats')}> 媒体内容统计 + 统计文本、图片、语音等类型
    @@ -826,7 +1103,7 @@ function GroupAnalyticsPage() { const getFunctionTitle = () => { switch (selectedFunction) { case 'members': return '群成员查看' - case 'memberExport': return '成员消息导出' + case 'memberMessages': return '成员消息筛选与导出' case 'ranking': return '群聊发言排行' case 'activeHours': return '群聊活跃时段' case 'mediaStats': return '媒体内容统计' @@ -861,6 +1138,12 @@ function GroupAnalyticsPage() { 导出成员 )} + {selectedFunction === 'memberMessages' && ( + + )} @@ -882,58 +1165,57 @@ function GroupAnalyticsPage() { ))}
    )} - {selectedFunction === 'memberExport' && ( -
    + {selectedFunction === 'memberMessages' && ( +
    {members.length === 0 ? ( -
    暂无群成员数据,请先刷新。
    +
    暂无群成员数据,请先刷新。
    ) : ( <> -
    -
    - 导出成员 +
    已加载 {memberMessages.length} 条消息
    + +
    +
    + 查看成员 - {showMemberSelect && ( + {showMessageMemberSelect && (
    setMemberSearchKeyword(e.target.value)} + value={messageMemberSearchKeyword} + onChange={e => setMessageMemberSearchKeyword(e.target.value)} placeholder="搜索 wxid / 昵称 / 备注 / 微信号" />
    - {filteredMemberOptions.length === 0 ? ( + {filteredMessageMemberOptions.length === 0 ? (
    无匹配成员
    ) : ( - filteredMemberOptions.map(member => ( + filteredMessageMemberOptions.map(member => ( )) @@ -950,162 +1233,51 @@ function GroupAnalyticsPage() {
    )}
    -
    - 导出格式 +
    - {showFormatSelect && ( -
    - {memberExportFormatOptions.map(option => ( - - ))} -
    - )} -
    -
    - 导出目录 -
    - - -
    -
    -
    - 媒体导出 - -
    -
    - 媒体类型 -
    - - - - -
    -
    -
    - 附加选项 -
    - - -
    -
    -
    - 显示名称规则 - - {showDisplayNameSelect && ( -
    - {displayNameOptions.map(option => ( - - ))} + {memberMessages.length === 0 ? ( +
    当前时间范围内暂无该成员消息。
    + ) : ( +
    + {memberMessages.map(message => ( +
    +
    + {formatMemberMessageTime(message.createTime)} + {getMemberMessageTypeLabel(message)} +
    +
    {getMemberMessagePreview(message)}
    + ))} +
    + )} + + {(memberMessagesHasMore || memberMessages.length > 0) && ( +
    + {memberMessagesHasMore ? ( + + ) : ( + 已显示当前可读取的全部消息 )}
    -
    - -
    - -
    + )} )}
    @@ -1171,18 +1343,226 @@ function GroupAnalyticsPage() { const renderDetailPanel = () => { + if (selectedFunction) { + return renderFunctionContent() + } + if (!selectedGroup) { return ( -
    - + <> + + ) } - if (!selectedFunction) { - return renderFunctionMenu() - } - return renderFunctionContent() + return ( + <> +
    {renderMemberModal()} + {renderMemberExportModal()} + {renderExportResultDialog()}
    ) } export default GroupAnalyticsPage + diff --git a/src/pages/NotificationWindow.scss b/src/pages/NotificationWindow.scss index 3e1515d..5c92a13 100644 --- a/src/pages/NotificationWindow.scss +++ b/src/pages/NotificationWindow.scss @@ -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; + } } \ No newline at end of file diff --git a/src/pages/NotificationWindow.tsx b/src/pages/NotificationWindow.tsx index deb6616..62cdb72 100644 --- a/src/pages/NotificationWindow.tsx +++ b/src/pages/NotificationWindow.tsx @@ -6,8 +6,9 @@ import './NotificationWindow.scss' export default function NotificationWindow() { const [notification, setNotification] = useState(null) const [prevNotification, setPrevNotification] = useState(null) + const [position, setPosition] = useState('top-right') - // We need a ref to access the current notification inside the callback + // We need a ref to access the current notification inside the callback // without satisfying the dependency array which would recreate the listener // Actually, setNotification(prev => ...) pattern is better, but we need the VALUE of current to set as prev. // So we use setNotification callback: setNotification(current => { ... return newNode }) @@ -34,6 +35,11 @@ export default function NotificationWindow() { avatarUrl: data.avatarUrl } + // 获取位置配置 + if (data.position) { + setPosition(data.position) + } + // Set previous to current (ref) if (notificationRef.current) { setPrevNotification(notificationRef.current) @@ -117,6 +123,7 @@ export default function NotificationWindow() {
    { }} // 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() {
    diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 04a5751..6b8bf8c 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -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; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index d963b14..e52e3cb 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -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(['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([]) + const [windowCloseBehavior, setWindowCloseBehavior] = useState('ask') const [filterSearchKeyword, setFilterSearchKeyword] = useState('') const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) + const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false) const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState([]) 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 = {}) {
    ))}
    + +
    + +
    + + 设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。 +
    +
    setCloseBehaviorDropdownOpen(!closeBehaviorDropdownOpen)} + > + + {windowCloseBehavior === 'tray' + ? '最小化到系统托盘' + : windowCloseBehavior === 'quit' + ? '完全关闭' + : '每次询问'} + + +
    +
    + {[ + { + value: 'ask' as const, + label: '每次询问', + successMessage: '已恢复关闭确认弹窗' + }, + { + value: 'tray' as const, + label: '最小化到系统托盘', + successMessage: '关闭按钮已改为最小化到托盘' + }, + { + value: 'quit' as const, + label: '完全关闭', + successMessage: '关闭按钮已改为完全关闭' + } + ].map(option => ( +
    { + setWindowCloseBehavior(option.value) + setCloseBehaviorDropdownOpen(false) + await configService.setWindowCloseBehavior(option.value) + showMessage(option.successMessage, true) + }} + > + {option.label} + {windowCloseBehavior === option.value && } +
    + ))} +
    +
    +
    ) @@ -1102,12 +1164,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { {notificationPosition === 'top-right' ? '右上角' : notificationPosition === 'bottom-right' ? '右下角' : - notificationPosition === 'top-left' ? '左上角' : '左下角'} + notificationPosition === 'top-left' ? '左上角' : + notificationPosition === 'top-center' ? '中间上方' : '左下角'}
    {[ + { value: 'top-center', label: '中间上方' }, { value: 'top-right', label: '右上角' }, { value: 'bottom-right', label: '右下角' }, { value: 'top-left', label: '左上角' }, @@ -1117,7 +1181,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { key={option.value} className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`} onClick={async () => { - const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' + const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' setNotificationPosition(val) setPositionDropdownOpen(false) await configService.setNotificationPosition(val) @@ -2130,14 +2194,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
    {wxidOptions.map((opt) => ( -
    handleSelectWxid(opt.wxid)} - > - {opt.wxid} - 最后修改 {new Date(opt.modifiedTime).toLocaleString()} -
    +
    handleSelectWxid(opt.wxid)} + > +
    + {opt.avatarUrl ? ( + avatar + ) : ( +
    + )} +
    + {opt.nickname || opt.wxid} + {opt.nickname && {opt.wxid}} +
    +
    + 最后修改 {new Date(opt.modifiedTime).toLocaleString()} +
    ))}
    diff --git a/src/pages/WelcomePage.scss b/src/pages/WelcomePage.scss index ad8358a..fb5012d 100644 --- a/src/pages/WelcomePage.scss +++ b/src/pages/WelcomePage.scss @@ -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; } diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 2a3745c..185b23b 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -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>([]) + const [wxidOptions, setWxidOptions] = useState>([]) const [showWxidSelect, setShowWxidSelect] = useState(false) const wxidSelectRef = useRef(null) const [error, setError] = useState('') @@ -688,22 +693,32 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { onChange={(e) => setWxid(e.target.value)} /> {showWxidSelect && wxidOptions.length > 0 && ( -
    - {wxidOptions.map((opt) => ( - - ))} -
    +
    + {wxidOptions.map((opt) => ( + + ))} +
    )}
    diff --git a/src/services/config.ts b/src/services/config.ts index 2cd8787..76d4b62 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -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 } +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 +} + 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 + 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 + 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 { + 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 + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const rawUpdatedAt = (rawItem as Record).updatedAt + const rawMetrics = (rawItem as Record).metrics + if (!rawMetrics || typeof rawMetrics !== 'object') return null + + const metrics: Record = {} + for (const [sessionIdRaw, metricRaw] of Object.entries(rawMetrics as Record)) { + 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 +): Promise { + 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) } + : {} + + const normalized: Record = {} + 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 { + 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) } + 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 { 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 { await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list) } +export async function getWindowCloseBehavior(): Promise { + 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 { + await config.set(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR, behavior) +} + // 获取词云排除词列表 export async function getWordCloudExcludeWords(): Promise { const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS) diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index 164fa5c..691ae57 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -81,10 +81,9 @@ export const useChatStore = create((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)) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index efe7735..a035f50 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -14,6 +14,8 @@ export interface ElectronAPI { isMaximized: () => Promise onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void close: () => void + onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => () => void + respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise openAgreementWindow: () => Promise completeOnboarding: () => Promise openOnboardingWindow: () => Promise @@ -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 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 diff --git a/src/types/models.ts b/src/types/models.ts index 7a154f1..92d6506 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -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 } // 聊天记录项 diff --git a/src/utils/AvatarLoadQueue.ts b/src/utils/AvatarLoadQueue.ts index 85e297b..a497f52 100644 --- a/src/utils/AvatarLoadQueue.ts +++ b/src/utils/AvatarLoadQueue.ts @@ -3,9 +3,11 @@ export class AvatarLoadQueue { private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = [] private loading = new Map>() + private failed = new Map() 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 { if (!url) return Promise.resolve() + if (this.hasFailed(url)) { + return Promise.reject(new Error(`Failed: ${url}`)) + } // 核心修复:防止重复并发请求同一个 URL const existingPromise = this.loading.get(url) @@ -31,13 +36,40 @@ export class AvatarLoadQueue { }) this.loading.set(url, loadPromise) - loadPromise.finally(() => { - this.loading.delete(url) - }) + void loadPromise.then( + () => { + this.loading.delete(url) + this.clearFailed(url) + }, + () => { + this.loading.delete(url) + } + ) return loadPromise } + hasFailed(url: string): boolean { + if (!url) return false + const failedAt = this.failed.get(url) + if (!failedAt) return false + if (Date.now() - failedAt > this.failedTtlMs) { + this.failed.delete(url) + return false + } + return true + } + + markFailed(url: string) { + if (!url) return + this.failed.set(url, Date.now()) + } + + clearFailed(url: string) { + if (!url) return + this.failed.delete(url) + } + private async processQueue() { if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) { return @@ -49,13 +81,16 @@ export class AvatarLoadQueue { this.activeCount++ const img = new Image() + img.referrerPolicy = 'no-referrer' img.onload = () => { this.activeCount-- + this.clearFailed(task.url) task.resolve() setTimeout(() => this.processQueue(), this.delayBetweenBatches) } img.onerror = () => { this.activeCount-- + this.markFailed(task.url) task.reject(new Error(`Failed: ${task.url}`)) setTimeout(() => this.processQueue(), this.delayBetweenBatches) } @@ -67,6 +102,7 @@ export class AvatarLoadQueue { clear() { this.queue = [] this.loading.clear() + this.failed.clear() this.activeCount = 0 } }