mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-02 15:08:22 +00:00
59
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: " 报告 Bug"
|
||||||
|
description: "代码出现了非预期的问题、崩溃或报错"
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["type: bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
请提供尽可能多的细节,这将帮助我们更快定位 `type: bug`。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前自测
|
||||||
|
description: 请务必确认以下事项:
|
||||||
|
options:
|
||||||
|
- label: 我已经查阅了文档,并且搜索过现有的 Issues,确认这不是一个重复问题。
|
||||||
|
required: true
|
||||||
|
- label: 我使用的是当前最新的版本。
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 请清晰、明确地描述你遇到了什么问题。
|
||||||
|
placeholder: 当我执行...操作时,程序崩溃了,或者输出了不正确的结果...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: 复现步骤
|
||||||
|
description: 提供精确的步骤,帮助我们复现该问题。如果可能,请提供最小复现代码协助我们的定位问题。
|
||||||
|
placeholder: |
|
||||||
|
1. 运行命令 '...'
|
||||||
|
2. 点击 '...'
|
||||||
|
3. 发现问题 '...'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: 预期行为
|
||||||
|
description: 你期望看到什么结果?
|
||||||
|
placeholder: 程序应该正常输出...而不是崩溃。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: 报错日志或截图
|
||||||
|
description: 请提供终端报错信息、浏览器 Console 日志,或直接将截图拖入此处。
|
||||||
|
render: shell
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: 运行环境
|
||||||
|
description: 你的操作系统、软件版本、依赖环境、系统架构等(如:Windows 11, Node v18.16.0)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
4
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 🤔 找不到合适的模板?
|
||||||
|
about: 如果你只是想闲聊或者你的问题不属于上述任何分类,请前往我们的的Telegram频道与我们交流。
|
||||||
24
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal file
24
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: " 文档反馈"
|
||||||
|
description: "文档存在错别字、描述不清晰或缺少必要的示例"
|
||||||
|
title: "[Docs]: "
|
||||||
|
labels: ["type: docs"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
优秀的文档和代码一样重要。感谢你帮助我们完善文档!
|
||||||
|
- type: input
|
||||||
|
id: doc-link
|
||||||
|
attributes:
|
||||||
|
label: 相关文档链接
|
||||||
|
description: 请提供存在问题的文档 URL 或具体文件路径。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: issue-desc
|
||||||
|
attributes:
|
||||||
|
label: 问题描述与建议
|
||||||
|
description: 文档哪里写错了?或者你建议补充什么内容?
|
||||||
|
placeholder: 在 "快速开始" 章节中,第三步的命令拼写错误,应该改为...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
25
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
25
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: "功能与体验优化"
|
||||||
|
description: "对现有的功能逻辑进行优化,或改进用户体验"
|
||||||
|
title: "[Enhancement]: "
|
||||||
|
labels: ["type: enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。
|
||||||
|
- type: textarea
|
||||||
|
id: target
|
||||||
|
attributes:
|
||||||
|
label: 目标功能
|
||||||
|
description: 你希望优化的功能或模块是哪一个?
|
||||||
|
placeholder: 例如:当前的导出功能 / 某个页面的交互...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: improvement
|
||||||
|
attributes:
|
||||||
|
label: 优化建议
|
||||||
|
description: 你认为应该怎么改?这会带来什么好处(如:性能提升、减少点击次数、视觉更美观)?
|
||||||
|
placeholder: 我建议将...改成...,这样可以将处理速度提升一倍。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
35
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
35
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: "全新功能请求"
|
||||||
|
description: "提议一个目前项目中完全没有的新特性"
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["type: feature"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢你为项目提供新想法!详细的需求描述能极大地增加该功能被采纳的几率。
|
||||||
|
- type: textarea
|
||||||
|
id: motivation
|
||||||
|
attributes:
|
||||||
|
label: 需求背景 / 动机
|
||||||
|
description: 为什么需要这个功能?你目前遇到了什么痛点?
|
||||||
|
placeholder: 目前我在做...事情时,必须手动处理,非常不方便。如果能有...功能就太好了。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: 期望的解决方案
|
||||||
|
description: 请详细描述你希望这个新功能是什么样的,它是如何工作的?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: 备选方案 (可选)
|
||||||
|
description: 你目前在使用什么临时的替代方案?或者你有没有考虑过其他的实现方式?
|
||||||
|
- type: checkboxes
|
||||||
|
id: help-wanted
|
||||||
|
attributes:
|
||||||
|
label: 参与贡献
|
||||||
|
options:
|
||||||
|
- label: 我有能力且愿意提交 Pull Request 来实现这个功能!(我们会为你打上 `help wanted` 标签)
|
||||||
29
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
29
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: "使用答疑"
|
||||||
|
description: "关于如何配置、如何使用项目的求助"
|
||||||
|
title: "[Question]: "
|
||||||
|
labels: ["type: question"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
在提问之前,请确保你已经仔细阅读过我们的官方文档。
|
||||||
|
- type: textarea
|
||||||
|
id: question
|
||||||
|
attributes:
|
||||||
|
label: 你的问题是什么?
|
||||||
|
description: 请清晰地描述你的疑问。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: attempts
|
||||||
|
attributes:
|
||||||
|
label: 你做过哪些尝试?
|
||||||
|
description: 请告诉我们你已经试过了什么方法,避免我们提供重复的建议。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: code-snippet
|
||||||
|
attributes:
|
||||||
|
label: 相关代码配置
|
||||||
|
description: 请粘贴你当前的配置文件或调用的代码片段。
|
||||||
|
render: javascript
|
||||||
581
docs/HTTP-API.md
581
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`
|
- 默认监听地址:`127.0.0.1`
|
||||||
|
- 默认端口:`5031`
|
||||||
## 基础地址
|
- 基础地址:`http://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
|
GET /health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
或
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
**响应**
|
**响应**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok"
|
"status": "ok"
|
||||||
@@ -36,211 +46,180 @@ GET /health
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. 获取消息列表
|
## 2. 获取消息
|
||||||
|
|
||||||
获取指定会话的消息,支持 ChatLab 格式输出。
|
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
```
|
|
||||||
|
```http
|
||||||
GET /api/v1/messages
|
GET /api/v1/messages
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --- | --- | --- | --- |
|
||||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` |
|
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||||
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
|
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||||
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 |
|
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||||
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) |
|
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||||
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) |
|
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||||
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
|
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||||
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
|
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||||
|
|
||||||
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
|
### 示例
|
||||||
|
|
||||||
**示例请求**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 获取消息(原始格式)
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20"
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
|
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"
|
||||||
# 获取消息(ChatLab 格式)
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0"
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应(原始格式)**
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"talker": "wxid_xxx",
|
"talker": "xxx@chatroom",
|
||||||
"count": 50,
|
"count": 2,
|
||||||
"hasMore": true,
|
"hasMore": true,
|
||||||
"media": {
|
"media": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||||
"count": 12
|
"count": 1
|
||||||
},
|
},
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"localId": 123,
|
"localId": 123,
|
||||||
|
"serverId": "456",
|
||||||
|
"localType": 1,
|
||||||
|
"createTime": 1738713600,
|
||||||
|
"isSend": 0,
|
||||||
|
"senderUsername": "wxid_member",
|
||||||
|
"content": "你好",
|
||||||
|
"rawContent": "你好",
|
||||||
|
"parsedContent": "你好"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"localId": 124,
|
||||||
"localType": 3,
|
"localType": 3,
|
||||||
|
"createTime": 1738713660,
|
||||||
|
"isSend": 0,
|
||||||
|
"senderUsername": "wxid_member",
|
||||||
"content": "[图片]",
|
"content": "[图片]",
|
||||||
"createTime": 1738713600000,
|
|
||||||
"senderUsername": "wxid_sender",
|
|
||||||
"mediaType": "image",
|
"mediaType": "image",
|
||||||
"mediaFileName": "image_123.jpg",
|
"mediaFileName": "abc123.jpg",
|
||||||
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
|
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg",
|
||||||
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应(ChatLab 格式)**
|
### ChatLab 响应
|
||||||
```json
|
|
||||||
{
|
当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构:
|
||||||
"chatlab": {
|
|
||||||
"version": "0.0.2",
|
- `chatlab.version`
|
||||||
"exportedAt": 1738713600000,
|
- `chatlab.exportedAt`
|
||||||
"generator": "WeFlow",
|
- `chatlab.generator`
|
||||||
"description": "Exported from WeFlow"
|
- `meta.name`
|
||||||
},
|
- `meta.platform`
|
||||||
"meta": {
|
- `meta.type`
|
||||||
"name": "会话名称",
|
- `meta.groupId`
|
||||||
"platform": "wechat",
|
- `meta.groupAvatar`
|
||||||
"type": "private",
|
- `meta.ownerId`
|
||||||
"ownerId": "wxid_me"
|
- `members[].platformId`
|
||||||
},
|
- `members[].accountName`
|
||||||
"members": [
|
- `members[].groupNickname`
|
||||||
{
|
- `members[].avatar`
|
||||||
"platformId": "wxid_xxx",
|
- `messages[].sender`
|
||||||
"accountName": "用户名",
|
- `messages[].accountName`
|
||||||
"groupNickname": "群昵称"
|
- `messages[].groupNickname`
|
||||||
}
|
- `messages[].timestamp`
|
||||||
],
|
- `messages[].type`
|
||||||
"messages": [
|
- `messages[].content`
|
||||||
{
|
- `messages[].platformMessageId`
|
||||||
"sender": "wxid_xxx",
|
- `messages[].mediaPath`
|
||||||
"accountName": "用户名",
|
|
||||||
"timestamp": 1738713600000,
|
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. 访问导出媒体文件
|
## 3. 获取会话列表
|
||||||
|
|
||||||
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
|
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
```
|
|
||||||
GET /api/v1/media/{relativePath}
|
|
||||||
```
|
|
||||||
|
|
||||||
**路径参数**
|
```http
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
|--------|------|------|------|
|
|
||||||
| `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. 获取会话列表
|
|
||||||
|
|
||||||
获取所有会话列表。
|
|
||||||
|
|
||||||
**请求**
|
|
||||||
```
|
|
||||||
GET /api/v1/sessions
|
GET /api/v1/sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --- | --- | --- | --- |
|
||||||
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
|
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
| `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
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"count": 50,
|
"count": 1,
|
||||||
"total": 100,
|
|
||||||
"sessions": [
|
"sessions": [
|
||||||
{
|
{
|
||||||
"username": "wxid_xxx",
|
"username": "xxx@chatroom",
|
||||||
"displayName": "用户名",
|
"displayName": "项目群",
|
||||||
"lastMessage": "最后一条消息",
|
"type": 2,
|
||||||
"lastTime": 1738713600000,
|
"lastTimestamp": 1738713600,
|
||||||
"unreadCount": 0
|
"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
|
GET /api/v1/contacts
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --- | --- | --- | --- |
|
||||||
| `keyword` | string | ❌ | 搜索关键词 |
|
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
| `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
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"count": 50,
|
"count": 1,
|
||||||
"contacts": [
|
"contacts": [
|
||||||
{
|
{
|
||||||
"userName": "wxid_xxx",
|
"username": "wxid_xxx",
|
||||||
"alias": "微信号",
|
"displayName": "张三",
|
||||||
"nickName": "昵称",
|
"remark": "客户张三",
|
||||||
"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 | 值 | 说明 |
|
```http
|
||||||
|--------------|-----|------|
|
GET /api/v1/group-members
|
||||||
| TEXT | 0 | 文本消息 |
|
```
|
||||||
| IMAGE | 1 | 图片 |
|
|
||||||
| VOICE | 2 | 语音 |
|
### 参数
|
||||||
| VIDEO | 3 | 视频 |
|
|
||||||
| FILE | 4 | 文件 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
| EMOJI | 5 | 表情 |
|
| --- | --- | --- | --- |
|
||||||
| LINK | 7 | 链接 |
|
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||||
| LOCATION | 8 | 位置 |
|
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||||
| RED_PACKET | 20 | 红包 |
|
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||||
| TRANSFER | 21 | 转账 |
|
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||||
| CALL | 23 | 通话 |
|
|
||||||
| SYSTEM | 80 | 系统消息 |
|
### 响应字段
|
||||||
| RECALL | 81 | 撤回消息 |
|
|
||||||
| OTHER | 99 | 其他 |
|
- `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
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 健康检查
|
|
||||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
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/sessions
|
||||||
|
|
||||||
# 获取消息
|
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
|
||||||
# 获取 ChatLab 格式
|
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### cURL
|
### cURL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 健康检查
|
|
||||||
curl http://127.0.0.1:5031/health
|
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/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
|
### Python
|
||||||
@@ -353,39 +437,26 @@ import requests
|
|||||||
|
|
||||||
BASE_URL = "http://127.0.0.1:5031"
|
BASE_URL = "http://127.0.0.1:5031"
|
||||||
|
|
||||||
# 获取会话列表
|
messages = requests.get(
|
||||||
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
|
f"{BASE_URL}/api/v1/messages",
|
||||||
print(sessions)
|
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)
|
print(messages)
|
||||||
```
|
print(members)
|
||||||
|
|
||||||
### 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);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 注意事项
|
## 8. 注意事项
|
||||||
|
|
||||||
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
|
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||||
2. 需要先连接数据库才能查询数据
|
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||||
3. 时间参数格式为 `YYYYMMDD`(如 20260205)
|
3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。
|
||||||
4. 支持 CORS,可从浏览器前端直接调用
|
4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。
|
||||||
|
5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ let mainWindowReady = false
|
|||||||
let shouldShowMain = true
|
let shouldShowMain = true
|
||||||
let isAppQuitting = false
|
let isAppQuitting = false
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
|
let isClosePromptVisible = false
|
||||||
|
|
||||||
|
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||||
|
|
||||||
// 更新下载状态管理(Issue #294 修复)
|
// 更新下载状态管理(Issue #294 修复)
|
||||||
let isDownloadInProgress = false
|
let isDownloadInProgress = false
|
||||||
@@ -253,6 +256,19 @@ const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
|
|||||||
win.webContents.on('did-finish-load', emitMaximizeState)
|
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 } = {}) {
|
function createWindow(options: { autoShow?: boolean } = {}) {
|
||||||
// 获取图标路径 - 打包后在 resources 目录
|
// 获取图标路径 - 打包后在 resources 目录
|
||||||
const { autoShow = true } = options
|
const { autoShow = true } = options
|
||||||
@@ -354,10 +370,22 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
win.on('close', (e) => {
|
win.on('close', (e) => {
|
||||||
if (isAppQuitting) return
|
if (isAppQuitting || win !== mainWindow) return
|
||||||
// 关闭主窗口时隐藏到状态栏而不是退出
|
|
||||||
e.preventDefault()
|
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', () => {
|
win.on('closed', () => {
|
||||||
@@ -365,6 +393,7 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
|
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
mainWindowReady = false
|
mainWindowReady = false
|
||||||
|
isClosePromptVisible = false
|
||||||
|
|
||||||
if (process.platform !== 'darwin' && !isAppQuitting) {
|
if (process.platform !== 'darwin' && !isAppQuitting) {
|
||||||
destroyNotificationWindow()
|
destroyNotificationWindow()
|
||||||
@@ -1154,6 +1183,33 @@ function registerIpcHandlers() {
|
|||||||
BrowserWindow.fromWebContents(event.sender)?.close()
|
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 }) => {
|
ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => {
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
@@ -1563,6 +1619,10 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getMessageById(sessionId, localId)
|
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) => {
|
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
|
||||||
return chatService.execQuery(kind, path, sql)
|
return chatService.execQuery(kind, path, sql)
|
||||||
})
|
})
|
||||||
@@ -1889,6 +1949,18 @@ function registerIpcHandlers() {
|
|||||||
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
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) => {
|
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
|
||||||
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
||||||
},
|
},
|
||||||
close: () => ipcRenderer.send('window:close'),
|
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'),
|
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||||
@@ -218,6 +225,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
||||||
getMessage: (sessionId: string, localId: number) =>
|
getMessage: (sessionId: string, localId: number) =>
|
||||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
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) => {
|
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
||||||
ipcRenderer.on('wcdb-change', callback)
|
ipcRenderer.on('wcdb-change', callback)
|
||||||
return () => ipcRenderer.removeListener('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),
|
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),
|
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),
|
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),
|
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||||
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface ChatSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
messageKey: string
|
||||||
localId: number
|
localId: number
|
||||||
serverId: number
|
serverId: number
|
||||||
localType: number
|
localType: number
|
||||||
@@ -233,6 +234,8 @@ class ChatService {
|
|||||||
// 缓存会话表信息,避免每次查询
|
// 缓存会话表信息,避免每次查询
|
||||||
private sessionTablesCache = new Map<string, Array<{ tableName: string; dbPath: string }>>()
|
private sessionTablesCache = new Map<string, Array<{ tableName: string; dbPath: string }>>()
|
||||||
private messageTableColumnsCache = new Map<string, { columns: Set<string>; updatedAt: number }>()
|
private messageTableColumnsCache = new Map<string, { columns: Set<string>; updatedAt: number }>()
|
||||||
|
private messageName2IdTableCache = new Map<string, string | null>()
|
||||||
|
private messageSenderIdCache = new Map<string, string | null>()
|
||||||
private readonly sessionTablesCacheTtl = 300000 // 5分钟
|
private readonly sessionTablesCacheTtl = 300000 // 5分钟
|
||||||
private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000
|
private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000
|
||||||
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
||||||
@@ -1433,7 +1436,7 @@ class ChatService {
|
|||||||
startTime: number = 0,
|
startTime: number = 0,
|
||||||
endTime: number = 0,
|
endTime: number = 0,
|
||||||
ascending: boolean = false
|
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
|
let releaseMessageCursorMutex: (() => void) | null = null
|
||||||
try {
|
try {
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
@@ -1492,7 +1495,6 @@ class ChatService {
|
|||||||
|
|
||||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
||||||
this.messageCursors.set(sessionId, state)
|
this.messageCursors.set(sessionId, state)
|
||||||
releaseMessageCursorMutex?.()
|
|
||||||
|
|
||||||
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
||||||
// 注意:仅在 offset === 0 时重建游标最安全;
|
// 注意:仅在 offset === 0 时重建游标最安全;
|
||||||
@@ -1512,7 +1514,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
if (!skipBatch.rows || skipBatch.rows.length === 0) {
|
if (!skipBatch.rows || skipBatch.rows.length === 0) {
|
||||||
console.warn(`[ChatService] 跳过时数据耗尽: skipped=${skipped}/${offset}`)
|
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
|
const count = skipBatch.rows.length
|
||||||
@@ -1531,7 +1533,7 @@ class ChatService {
|
|||||||
|
|
||||||
if (!skipBatch.hasMore) {
|
if (!skipBatch.hasMore) {
|
||||||
console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`)
|
console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`)
|
||||||
return { success: true, messages: [], hasMore: false }
|
return { success: true, messages: [], hasMore: false, nextOffset: skipped }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (attempts >= maxSkipAttempts) {
|
if (attempts >= maxSkipAttempts) {
|
||||||
@@ -1548,91 +1550,28 @@ class ChatService {
|
|||||||
return { success: false, error: '游标状态未初始化' }
|
return { success: false, error: '游标状态未初始化' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前批次的消息
|
const collected = await this.collectVisibleMessagesFromCursor(
|
||||||
// Use buffered rows from skip logic if available
|
sessionId,
|
||||||
let rows: any[] = state.bufferedMessages || []
|
state.cursor,
|
||||||
state.bufferedMessages = undefined // Clear buffer after use
|
limit,
|
||||||
|
state.bufferedMessages as Record<string, any>[] | undefined
|
||||||
// Track actual hasMore status from C++ layer
|
)
|
||||||
// If we have buffered messages, we need to check if there's more data
|
state.bufferedMessages = collected.bufferedRows
|
||||||
let actualHasMore = rows.length > 0 // If buffer exists, assume there might be more
|
if (!collected.success) {
|
||||||
|
return { success: false, error: collected.error || '获取消息失败' }
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have more than limit (due to buffer + full batch), slice it
|
const rawRowsConsumed = collected.rawRowsConsumed || 0
|
||||||
if (rows.length > limit) {
|
const filtered = collected.messages || []
|
||||||
rows = rows.slice(0, limit)
|
const hasMore = collected.hasMore === true
|
||||||
// Note: We don't adjust state.fetched here because it tracks cursor position.
|
state.fetched += rawRowsConsumed
|
||||||
// Next time offset will catch up or mismatch trigger reset.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use actual hasMore from C++ layer, not simplified row count check
|
|
||||||
const hasMore = actualHasMore
|
|
||||||
|
|
||||||
const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows))
|
|
||||||
|
|
||||||
// 🔒 安全验证:过滤掉不属于当前 sessionId 的消息(防止 C++ 层或缓存错误)
|
|
||||||
const filtered = normalized.filter(msg => {
|
|
||||||
// 检查消息的 senderUsername 或 rawContent 中的 talker
|
|
||||||
// 群聊消息:senderUsername 是群成员,需要检查 _db_path 或上下文
|
|
||||||
// 单聊消息:senderUsername 应该是 sessionId 或自己
|
|
||||||
const isGroupChat = sessionId.includes('@chatroom')
|
|
||||||
|
|
||||||
if (isGroupChat) {
|
|
||||||
// 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId)
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
// 单聊消息:senderUsername 应该是 sessionId(对方)或为空/null(自己)
|
|
||||||
if (!msg.senderUsername || msg.senderUsername === sessionId) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 如果 isSend 为 1,说明是自己发的,允许通过
|
|
||||||
if (msg.isSend === 1) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 其他情况:可能是错误的消息
|
|
||||||
console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (filtered.length < normalized.length) {
|
|
||||||
console.warn(`[ChatService] 过滤了 ${normalized.length - filtered.length} 条异常消息`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 并发检查并修复缺失 CDN URL 的表情包
|
|
||||||
const fixPromises: Promise<void>[] = []
|
|
||||||
for (const msg of filtered) {
|
|
||||||
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
|
|
||||||
fixPromises.push(this.fallbackEmoticon(msg))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fixPromises.length > 0) {
|
|
||||||
await Promise.allSettled(fixPromises)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.fetched += rows.length
|
|
||||||
releaseMessageCursorMutex?.()
|
releaseMessageCursorMutex?.()
|
||||||
|
|
||||||
this.messageCacheService.set(sessionId, filtered)
|
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) {
|
} catch (e) {
|
||||||
console.error('ChatService: 获取消息失败:', e)
|
console.error('ChatService: 获取消息失败:', e)
|
||||||
return { success: false, error: String(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 {
|
try {
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
if (!connectResult.success) {
|
if (!connectResult.success) {
|
||||||
@@ -1746,24 +1685,19 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursorResult.cursor, limit)
|
||||||
if (!batch.success || !batch.rows) {
|
if (!collected.success) {
|
||||||
return { success: false, error: batch.error || '获取消息失败' }
|
return { success: false, error: collected.error || '获取消息失败' }
|
||||||
}
|
}
|
||||||
const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(batch.rows as Record<string, any>[]))
|
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}`
|
||||||
// 并发检查并修复缺失 CDN URL 的表情包
|
)
|
||||||
const fixPromises: Promise<void>[] = []
|
return {
|
||||||
for (const msg of normalized) {
|
success: true,
|
||||||
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
|
messages: collected.messages,
|
||||||
fixPromises.push(this.fallbackEmoticon(msg))
|
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 {
|
} finally {
|
||||||
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
||||||
}
|
}
|
||||||
@@ -1819,6 +1753,174 @@ class ChatService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private encodeMessageKeySegment(value: unknown): string {
|
||||||
|
const normalized = String(value ?? '').trim()
|
||||||
|
return encodeURIComponent(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessageSourceInfo(row: Record<string, any>): { dbName?: string; tableName?: string; dbPath?: string } {
|
||||||
|
const dbPath = String(
|
||||||
|
this.getRowField(row, ['_db_path', 'db_path', 'dbPath', 'database_path', 'databasePath', 'source_db_path'])
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
const explicitDbName = String(
|
||||||
|
this.getRowField(row, ['db_name', 'dbName', 'database_name', 'databaseName', 'db', 'database', 'source_db'])
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
const tableName = String(
|
||||||
|
this.getRowField(row, ['table_name', 'tableName', 'table', 'source_table', 'sourceTable'])
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '')
|
||||||
|
return {
|
||||||
|
dbName: dbName || undefined,
|
||||||
|
tableName: tableName || undefined,
|
||||||
|
dbPath: dbPath || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMessageKey(input: {
|
||||||
|
localId: number
|
||||||
|
serverId: number
|
||||||
|
createTime: number
|
||||||
|
sortSeq: number
|
||||||
|
senderUsername?: string | null
|
||||||
|
localType: number
|
||||||
|
dbName?: string
|
||||||
|
tableName?: string
|
||||||
|
dbPath?: string
|
||||||
|
}): string {
|
||||||
|
const localId = Number.isFinite(input.localId) ? Math.max(0, Math.floor(input.localId)) : 0
|
||||||
|
const serverId = Number.isFinite(input.serverId) ? Math.max(0, Math.floor(input.serverId)) : 0
|
||||||
|
const createTime = Number.isFinite(input.createTime) ? Math.max(0, Math.floor(input.createTime)) : 0
|
||||||
|
const sortSeq = Number.isFinite(input.sortSeq) ? Math.max(0, Math.floor(input.sortSeq)) : 0
|
||||||
|
const localType = Number.isFinite(input.localType) ? Math.floor(input.localType) : 0
|
||||||
|
const senderUsername = this.encodeMessageKeySegment(input.senderUsername || '')
|
||||||
|
const dbName = String(input.dbName || '').trim() || (input.dbPath ? basename(input.dbPath, extname(input.dbPath)) : '')
|
||||||
|
const tableName = String(input.tableName || '').trim()
|
||||||
|
|
||||||
|
if (localId > 0 && dbName && tableName) {
|
||||||
|
return `${this.encodeMessageKeySegment(dbName)}:${this.encodeMessageKeySegment(tableName)}:${localId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverId > 0) {
|
||||||
|
return `server:${serverId}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `fallback:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMessageVisibleForSession(sessionId: string, msg: Message): boolean {
|
||||||
|
const isGroupChat = sessionId.includes('@chatroom')
|
||||||
|
if (isGroupChat) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!msg.senderUsername || msg.senderUsername === sessionId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (msg.isSend === 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private async repairEmojiMessages(messages: Message[]): Promise<void> {
|
||||||
|
const fixPromises: Promise<void>[] = []
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
|
||||||
|
fixPromises.push(this.fallbackEmoticon(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fixPromises.length > 0) {
|
||||||
|
await Promise.allSettled(fixPromises)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectVisibleMessagesFromCursor(
|
||||||
|
sessionId: string,
|
||||||
|
cursor: number,
|
||||||
|
limit: number,
|
||||||
|
initialRows: Record<string, any>[] = []
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
messages?: Message[]
|
||||||
|
hasMore?: boolean
|
||||||
|
error?: string
|
||||||
|
rawRowsConsumed?: number
|
||||||
|
filteredOut?: number
|
||||||
|
bufferedRows?: Record<string, any>[]
|
||||||
|
}> {
|
||||||
|
const visibleMessages: Message[] = []
|
||||||
|
let queuedRows = Array.isArray(initialRows) ? initialRows.slice() : []
|
||||||
|
let rawRowsConsumed = 0
|
||||||
|
let filteredOut = 0
|
||||||
|
let cursorMayHaveMore = queuedRows.length > 0
|
||||||
|
|
||||||
|
while (visibleMessages.length < limit) {
|
||||||
|
if (queuedRows.length === 0) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||||
|
if (!batch.success) {
|
||||||
|
console.error('[ChatService] 获取消息批次失败:', batch.error)
|
||||||
|
if (visibleMessages.length === 0) {
|
||||||
|
return { success: false, error: batch.error || '获取消息失败' }
|
||||||
|
}
|
||||||
|
cursorMayHaveMore = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchRows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||||
|
cursorMayHaveMore = batch.hasMore === true
|
||||||
|
if (batchRows.length === 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
queuedRows = batchRows
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsToProcess = queuedRows
|
||||||
|
queuedRows = []
|
||||||
|
const mappedMessages = this.mapRowsToMessages(rowsToProcess)
|
||||||
|
for (let index = 0; index < mappedMessages.length; index += 1) {
|
||||||
|
const msg = mappedMessages[index]
|
||||||
|
rawRowsConsumed += 1
|
||||||
|
if (this.isMessageVisibleForSession(sessionId, msg)) {
|
||||||
|
visibleMessages.push(msg)
|
||||||
|
if (visibleMessages.length >= limit) {
|
||||||
|
if (index + 1 < rowsToProcess.length) {
|
||||||
|
queuedRows = rowsToProcess.slice(index + 1)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filteredOut += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleMessages.length >= limit) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cursorMayHaveMore) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredOut > 0) {
|
||||||
|
console.warn(`[ChatService] 过滤了 ${filteredOut} 条异常消息`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = this.normalizeMessageOrder(visibleMessages)
|
||||||
|
await this.repairEmojiMessages(normalized)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messages: normalized,
|
||||||
|
hasMore: queuedRows.length > 0 || cursorMayHaveMore,
|
||||||
|
rawRowsConsumed,
|
||||||
|
filteredOut,
|
||||||
|
bufferedRows: queuedRows.length > 0 ? queuedRows : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getRowField(row: Record<string, any>, keys: string[]): any {
|
private getRowField(row: Record<string, any>, keys: string[]): any {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (row[key] !== undefined && row[key] !== null) return row[key]
|
if (row[key] !== undefined && row[key] !== null) return row[key]
|
||||||
@@ -1890,6 +1992,62 @@ class ChatService {
|
|||||||
return [lowerRaw]
|
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 {
|
private extractGroupMemberUsername(member: any): string {
|
||||||
if (!member) return ''
|
if (!member) return ''
|
||||||
if (typeof member === 'string') return member.trim()
|
if (typeof member === 'string') return member.trim()
|
||||||
@@ -2948,12 +3106,10 @@ class ChatService {
|
|||||||
|
|
||||||
private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
|
private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
|
||||||
const myWxid = this.configService.get('myWxid')
|
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[] = []
|
const messages: Message[] = []
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
const sourceInfo = this.getMessageSourceInfo(row)
|
||||||
const rawMessageContent = this.getRowField(row, [
|
const rawMessageContent = this.getRowField(row, [
|
||||||
'message_content',
|
'message_content',
|
||||||
'messageContent',
|
'messageContent',
|
||||||
@@ -2974,30 +3130,14 @@ class ChatService {
|
|||||||
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
|
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
|
||||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
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'])
|
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'])
|
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
||||||
|| this.extractSenderUsernameFromContent(content)
|
|| this.extractSenderUsernameFromContent(content)
|
||||||
|| null
|
|| 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)
|
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)) {
|
if (senderUsername && !myWxid) {
|
||||||
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) {
|
|
||||||
// [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送
|
// [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送
|
||||||
if (messages.length < 5) {
|
if (messages.length < 5) {
|
||||||
console.warn(`[ChatService] Warning: myWxid not set. Cannot determine if message is sent by me. sender=${senderUsername}`)
|
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
|
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({
|
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),
|
messageKey: this.buildMessageKey({
|
||||||
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
|
localId,
|
||||||
|
serverId,
|
||||||
|
createTime,
|
||||||
|
sortSeq,
|
||||||
|
senderUsername,
|
||||||
|
localType,
|
||||||
|
...sourceInfo
|
||||||
|
}),
|
||||||
|
localId,
|
||||||
|
serverId,
|
||||||
localType,
|
localType,
|
||||||
createTime,
|
createTime,
|
||||||
sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime),
|
sortSeq,
|
||||||
isSend,
|
isSend,
|
||||||
senderUsername,
|
senderUsername,
|
||||||
parsedContent: this.parseMessageContent(content, localType),
|
parsedContent: this.parseMessageContent(content, localType),
|
||||||
@@ -3217,7 +3370,8 @@ class ChatService {
|
|||||||
transferPayerUsername,
|
transferPayerUsername,
|
||||||
transferReceiverUsername,
|
transferReceiverUsername,
|
||||||
chatRecordTitle,
|
chatRecordTitle,
|
||||||
chatRecordList
|
chatRecordList,
|
||||||
|
_db_path: sourceInfo.dbPath
|
||||||
})
|
})
|
||||||
const last = messages[messages.length - 1]
|
const last = messages[messages.length - 1]
|
||||||
if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) {
|
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
|
return result.rows[0]?.name || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveMessageName2IdTableName(dbPath: string): Promise<string | null> {
|
||||||
|
const normalizedDbPath = String(dbPath || '').trim()
|
||||||
|
if (!normalizedDbPath) return null
|
||||||
|
if (this.messageName2IdTableCache.has(normalizedDbPath)) {
|
||||||
|
return this.messageName2IdTableCache.get(normalizedDbPath) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wcdbService.execQuery(
|
||||||
|
'message',
|
||||||
|
normalizedDbPath,
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%' ORDER BY name DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
const tableName = result.success && result.rows && result.rows.length > 0
|
||||||
|
? String(result.rows[0]?.name || '').trim() || null
|
||||||
|
: null
|
||||||
|
this.messageName2IdTableCache.set(normalizedDbPath, tableName)
|
||||||
|
return tableName
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveMessageSenderUsernameById(dbPath: string, senderId: unknown): Promise<string | null> {
|
||||||
|
const normalizedDbPath = String(dbPath || '').trim()
|
||||||
|
const numericSenderId = Number.parseInt(String(senderId ?? '').trim(), 10)
|
||||||
|
if (!normalizedDbPath || !Number.isFinite(numericSenderId) || numericSenderId <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${normalizedDbPath}::${numericSenderId}`
|
||||||
|
if (this.messageSenderIdCache.has(cacheKey)) {
|
||||||
|
return this.messageSenderIdCache.get(cacheKey) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const name2IdTable = await this.resolveMessageName2IdTableName(normalizedDbPath)
|
||||||
|
if (!name2IdTable) {
|
||||||
|
this.messageSenderIdCache.set(cacheKey, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedTableName = String(name2IdTable).replace(/"/g, '""')
|
||||||
|
const result = await wcdbService.execQuery(
|
||||||
|
'message',
|
||||||
|
normalizedDbPath,
|
||||||
|
`SELECT user_name FROM "${escapedTableName}" WHERE rowid = ${numericSenderId} LIMIT 1`
|
||||||
|
)
|
||||||
|
const username = result.success && result.rows && result.rows.length > 0
|
||||||
|
? String(result.rows[0]?.user_name || result.rows[0]?.userName || '').trim() || null
|
||||||
|
: null
|
||||||
|
this.messageSenderIdCache.set(cacheKey, username)
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSenderUsernameForMessageRow(
|
||||||
|
row: Record<string, any>,
|
||||||
|
rawContent: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const directSender = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
||||||
|
|| this.extractSenderUsernameFromContent(rawContent)
|
||||||
|
if (directSender) {
|
||||||
|
return directSender
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = this.getRowField(row, ['db_path', 'dbPath', '_db_path'])
|
||||||
|
const realSenderId = this.getRowField(row, ['real_sender_id', 'realSenderId'])
|
||||||
|
if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resolveMessageSenderUsernameById(String(dbPath), realSenderId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否像 wxid
|
* 判断是否像 wxid
|
||||||
*/
|
*/
|
||||||
@@ -5524,6 +5747,12 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 读取解密后的文件并转成 base64
|
// 3. 读取解密后的文件并转成 base64
|
||||||
|
// 如果已经是 data URL,直接返回 base64 部分
|
||||||
|
if (result.localPath.startsWith('data:')) {
|
||||||
|
const base64Data = result.localPath.split(',')[1]
|
||||||
|
return { success: true, data: base64Data }
|
||||||
|
}
|
||||||
|
|
||||||
// localPath 是 file:// URL,需要转换成文件路径
|
// localPath 是 file:// URL,需要转换成文件路径
|
||||||
const filePath = result.localPath.startsWith('file://')
|
const filePath = result.localPath.startsWith('file://')
|
||||||
? result.localPath.replace(/^file:\/\//, '')
|
? result.localPath.replace(/^file:\/\//, '')
|
||||||
@@ -6564,8 +6793,12 @@ class ChatService {
|
|||||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||||
|
|
||||||
if (result.success && result.rows && result.rows.length > 0) {
|
if (result.success && result.rows && result.rows.length > 0) {
|
||||||
const row = result.rows[0]
|
const row = {
|
||||||
const message = this.parseMessage(row)
|
...(result.rows[0] as Record<string, any>),
|
||||||
|
db_path: dbPath,
|
||||||
|
table_name: tableName
|
||||||
|
}
|
||||||
|
const message = await this.parseMessage(row, { source: 'detail', sessionId })
|
||||||
|
|
||||||
if (message.localId !== 0) {
|
if (message.localId !== 0) {
|
||||||
return { success: true, message }
|
return { success: true, message }
|
||||||
@@ -6580,7 +6813,60 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseMessage(row: any): Message {
|
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
|
||||||
|
try {
|
||||||
|
const result = await wcdbService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
|
||||||
|
if (!result.success || !result.messages) {
|
||||||
|
return { success: false, error: result.error || '搜索失败' }
|
||||||
|
}
|
||||||
|
const messages: Message[] = []
|
||||||
|
const isGroupSearch = Boolean(String(sessionId || '').trim().endsWith('@chatroom'))
|
||||||
|
|
||||||
|
for (const row of result.messages) {
|
||||||
|
let message = await this.parseMessage(row, { source: 'search', sessionId })
|
||||||
|
const needsDetailHydration = isGroupSearch &&
|
||||||
|
Boolean(sessionId) &&
|
||||||
|
message.localId > 0 &&
|
||||||
|
(!message.senderUsername || message.isSend === null)
|
||||||
|
|
||||||
|
if (needsDetailHydration && sessionId) {
|
||||||
|
const detail = await this.getMessageById(sessionId, message.localId)
|
||||||
|
if (detail.success && detail.message) {
|
||||||
|
message = {
|
||||||
|
...message,
|
||||||
|
...detail.message,
|
||||||
|
parsedContent: message.parsedContent || detail.message.parsedContent,
|
||||||
|
rawContent: message.rawContent || detail.message.rawContent,
|
||||||
|
content: message.content || detail.message.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGroupSearch && (needsDetailHydration || message.isSend === 1)) {
|
||||||
|
console.info('[ChatService][GroupSearchHydratedHit]', {
|
||||||
|
sessionId,
|
||||||
|
localId: message.localId,
|
||||||
|
senderUsername: message.senderUsername,
|
||||||
|
isSend: message.isSend,
|
||||||
|
senderDisplayName: message.senderDisplayName,
|
||||||
|
senderAvatarUrl: message.senderAvatarUrl,
|
||||||
|
usedDetailHydration: needsDetailHydration,
|
||||||
|
parsedContent: message.parsedContent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, messages }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ChatService: searchMessages 失败:', e)
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> {
|
||||||
|
const sourceInfo = this.getMessageSourceInfo(row)
|
||||||
const rawContent = this.decodeMessageContent(
|
const rawContent = this.decodeMessageContent(
|
||||||
this.getRowField(row, [
|
this.getRowField(row, [
|
||||||
'message_content',
|
'message_content',
|
||||||
@@ -6601,19 +6887,35 @@ class ChatService {
|
|||||||
)
|
)
|
||||||
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
|
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
|
||||||
// 实际项目中建议抽取 parseRawMessage(row) 供多处使用
|
// 实际项目中建议抽取 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 = {
|
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),
|
messageKey: this.buildMessageKey({
|
||||||
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
|
localId,
|
||||||
localType: this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0),
|
serverId,
|
||||||
createTime: this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0),
|
createTime,
|
||||||
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)),
|
sortSeq,
|
||||||
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
|
senderUsername,
|
||||||
senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
localType,
|
||||||
|| this.extractSenderUsernameFromContent(rawContent)
|
...sourceInfo
|
||||||
|| null,
|
}),
|
||||||
|
localId,
|
||||||
|
serverId,
|
||||||
|
localType,
|
||||||
|
createTime,
|
||||||
|
sortSeq,
|
||||||
|
isSend: sendState.isSend,
|
||||||
|
senderUsername,
|
||||||
rawContent: rawContent,
|
rawContent: rawContent,
|
||||||
content: rawContent, // 添加原始内容供视频MD5解析使用
|
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) {
|
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
|
if (msg.localType === 3) { // Image
|
||||||
const imgInfo = this.parseImageInfo(rawContent)
|
const imgInfo = this.parseImageInfo(rawContent)
|
||||||
|
|||||||
@@ -47,9 +47,10 @@ interface ConfigSchema {
|
|||||||
|
|
||||||
// 通知
|
// 通知
|
||||||
notificationEnabled: boolean
|
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'
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
notificationFilterList: string[]
|
notificationFilterList: string[]
|
||||||
|
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||||
wordCloudExcludeWords: string[]
|
wordCloudExcludeWords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ export class ConfigService {
|
|||||||
notificationPosition: 'top-right',
|
notificationPosition: 'top-right',
|
||||||
notificationFilterMode: 'all',
|
notificationFilterMode: 'all',
|
||||||
notificationFilterList: [],
|
notificationFilterList: [],
|
||||||
|
windowCloseBehavior: 'ask',
|
||||||
wordCloudExcludeWords: []
|
wordCloudExcludeWords: []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,13 +1,90 @@
|
|||||||
import { join, basename } from 'path'
|
import { join, basename } from 'path'
|
||||||
import { existsSync, readdirSync, statSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
import { createDecipheriv } from 'crypto'
|
||||||
|
|
||||||
export interface WxidInfo {
|
export interface WxidInfo {
|
||||||
wxid: string
|
wxid: string
|
||||||
modifiedTime: number
|
modifiedTime: number
|
||||||
|
nickname?: string
|
||||||
|
avatarUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DbPathService {
|
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) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(rootPath, entry)
|
const entryPath = join(rootPath, entry)
|
||||||
let stat: ReturnType<typeof statSync>
|
let stat: ReturnType<typeof statSync>
|
||||||
try {
|
try { stat = statSync(entryPath) } catch { continue }
|
||||||
stat = statSync(entryPath)
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stat.isDirectory()) continue
|
if (!stat.isDirectory()) continue
|
||||||
const lower = entry.toLowerCase()
|
const lower = entry.toLowerCase()
|
||||||
if (lower === 'all_users') continue
|
if (lower === 'all_users') continue
|
||||||
if (!entry.includes('_')) continue
|
if (!entry.includes('_')) continue
|
||||||
|
|
||||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (wxids.length === 0) {
|
if (wxids.length === 0) {
|
||||||
const rootName = basename(rootPath)
|
const rootName = basename(rootPath)
|
||||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||||
@@ -159,12 +231,25 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
return wxids.sort((a, b) => {
|
const sorted = wxids.sort((a, b) => {
|
||||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||||
return a.wxid.localeCompare(b.wxid)
|
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 列表
|
* 扫描 wxid 列表
|
||||||
*/
|
*/
|
||||||
@@ -187,10 +272,21 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
return wxids.sort((a, b) => {
|
const sorted = wxids.sort((a, b) => {
|
||||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
})
|
});
|
||||||
|
|
||||||
|
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||||
|
if (globalInfo) {
|
||||||
|
for (const w of sorted) {
|
||||||
|
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||||
|
w.nickname = globalInfo.nickname;
|
||||||
|
w.avatarUrl = globalInfo.avatarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -562,23 +562,50 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过 contact.chat_room.ext_buffer 解析群昵称(纯 SQL)
|
* 获取群成员群昵称。优先使用 DLL,必要时回退到 `contact.chat_room.ext_buffer` 解析。
|
||||||
*/
|
*/
|
||||||
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||||
|
const nicknameMap = new Map<string, string>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||||
|
if (dllResult.success && dllResult.nicknames) {
|
||||||
|
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('getGroupNicknamesForRoom dll error:', e)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用参数化查询防止SQL注入
|
|
||||||
const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1'
|
const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1'
|
||||||
const result = await wcdbService.execQuery('contact', null, sql, [chatroomId])
|
const result = await wcdbService.execQuery('contact', null, sql, [chatroomId])
|
||||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||||
return new Map<string, string>()
|
return nicknameMap
|
||||||
}
|
}
|
||||||
|
|
||||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
||||||
if (!extBuffer) return new Map<string, string>()
|
if (!extBuffer) return nicknameMap
|
||||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
|
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
|
||||||
|
return nicknameMap
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGroupNicknamesForRoom error:', e)
|
console.error('getGroupNicknamesForRoom error:', e)
|
||||||
return new Map<string, string>()
|
return nicknameMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeGroupNicknameEntries(
|
||||||
|
target: Map<string, string>,
|
||||||
|
entries: Iterable<[string, string]>
|
||||||
|
): void {
|
||||||
|
for (const [memberIdRaw, nicknameRaw] of entries) {
|
||||||
|
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
|
||||||
|
if (!nickname) continue
|
||||||
|
for (const alias of this.buildGroupNicknameIdCandidates([memberIdRaw])) {
|
||||||
|
if (!alias) continue
|
||||||
|
if (!target.has(alias)) target.set(alias, nickname)
|
||||||
|
const lower = alias.toLowerCase()
|
||||||
|
if (!target.has(lower)) target.set(lower, nickname)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4453,6 +4480,7 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
|
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
|
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
@@ -5650,6 +5678,7 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
|
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
|
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export interface GroupMediaStats {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberMessagesPage {
|
||||||
|
messages: Message[]
|
||||||
|
hasMore: boolean
|
||||||
|
nextCursor: number
|
||||||
|
}
|
||||||
|
|
||||||
interface GroupMemberContactInfo {
|
interface GroupMemberContactInfo {
|
||||||
remark: string
|
remark: string
|
||||||
nickName: string
|
nickName: string
|
||||||
@@ -255,20 +261,47 @@ class GroupAnalyticsService {
|
|||||||
* 从 DLL 获取群成员的群昵称
|
* 从 DLL 获取群成员的群昵称
|
||||||
*/
|
*/
|
||||||
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||||
|
const nicknameMap = new Map<string, string>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
|
if (dllResult.success && dllResult.nicknames) {
|
||||||
const result = await wcdbService.execQuery('contact', null, sql)
|
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) {
|
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||||
return new Map<string, string>()
|
return nicknameMap
|
||||||
}
|
}
|
||||||
|
|
||||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
||||||
if (!extBuffer) return new Map<string, string>()
|
if (!extBuffer) return nicknameMap
|
||||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
|
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
|
||||||
|
return nicknameMap
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGroupNicknamesForRoom error:', e)
|
console.error('getGroupNicknamesForRoom error:', e)
|
||||||
return new Map<string, string>()
|
return nicknameMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeGroupNicknameEntries(
|
||||||
|
target: Map<string, string>,
|
||||||
|
entries: Iterable<[string, string]>
|
||||||
|
): void {
|
||||||
|
for (const [memberIdRaw, nicknameRaw] of entries) {
|
||||||
|
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
|
||||||
|
if (!nickname) continue
|
||||||
|
for (const alias of this.buildIdCandidates([memberIdRaw])) {
|
||||||
|
if (!alias) continue
|
||||||
|
if (!target.has(alias)) target.set(alias, nickname)
|
||||||
|
const lower = alias.toLowerCase()
|
||||||
|
if (!target.has(lower)) target.set(lower, nickname)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,6 +804,100 @@ class GroupAnalyticsService {
|
|||||||
return { success: true, data: matchedMessages }
|
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 }> {
|
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { wcdbService } from './wcdbService'
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { videoService } from './videoService'
|
import { videoService } from './videoService'
|
||||||
import { imageDecryptService } from './imageDecryptService'
|
import { imageDecryptService } from './imageDecryptService'
|
||||||
|
import { groupAnalyticsService } from './groupAnalyticsService'
|
||||||
|
|
||||||
// ChatLab 格式定义
|
// ChatLab 格式定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -238,6 +239,8 @@ class HttpService {
|
|||||||
await this.handleSessions(url, res)
|
await this.handleSessions(url, res)
|
||||||
} else if (pathname === '/api/v1/contacts') {
|
} else if (pathname === '/api/v1/contacts') {
|
||||||
await this.handleContacts(url, res)
|
await this.handleContacts(url, res)
|
||||||
|
} else if (pathname === '/api/v1/group-members') {
|
||||||
|
await this.handleGroupMembers(url, res)
|
||||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||||
this.handleMediaRequest(pathname, res)
|
this.handleMediaRequest(pathname, res)
|
||||||
} else {
|
} else {
|
||||||
@@ -589,6 +592,54 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理群成员查询
|
||||||
|
* GET /api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=0
|
||||||
|
*/
|
||||||
|
private async handleGroupMembers(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
|
const chatroomId = (url.searchParams.get('chatroomId') || url.searchParams.get('talker') || '').trim()
|
||||||
|
const includeMessageCounts = this.parseBooleanParam(url, ['includeMessageCounts', 'withCounts'], false)
|
||||||
|
const forceRefresh = this.parseBooleanParam(url, ['forceRefresh'], false)
|
||||||
|
|
||||||
|
if (!chatroomId) {
|
||||||
|
this.sendError(res, 400, 'Missing chatroomId')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await groupAnalyticsService.getGroupMembersPanelData(chatroomId, {
|
||||||
|
forceRefresh,
|
||||||
|
includeMessageCounts
|
||||||
|
})
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to get group members')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendJson(res, {
|
||||||
|
success: true,
|
||||||
|
chatroomId,
|
||||||
|
count: result.data.length,
|
||||||
|
fromCache: result.fromCache,
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
members: result.data.map((member) => ({
|
||||||
|
wxid: member.username,
|
||||||
|
displayName: member.displayName,
|
||||||
|
nickname: member.nickname || '',
|
||||||
|
remark: member.remark || '',
|
||||||
|
alias: member.alias || '',
|
||||||
|
groupNickname: member.groupNickname || '',
|
||||||
|
avatarUrl: member.avatarUrl,
|
||||||
|
isOwner: Boolean(member.isOwner),
|
||||||
|
isFriend: Boolean(member.isFriend),
|
||||||
|
messageCount: Number.isFinite(member.messageCount) ? member.messageCount : 0
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.sendError(res, 500, String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getApiMediaExportPath(): string {
|
private getApiMediaExportPath(): string {
|
||||||
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||||
}
|
}
|
||||||
@@ -886,7 +937,12 @@ class HttpService {
|
|||||||
|
|
||||||
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
|
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
|
||||||
if (!sender) return ''
|
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(
|
private resolveChatLabSenderInfo(
|
||||||
@@ -957,7 +1013,21 @@ class HttpService {
|
|||||||
try {
|
try {
|
||||||
const result = await wcdbService.getGroupNicknames(talkerId)
|
const result = await wcdbService.getGroupNicknames(talkerId)
|
||||||
if (result.success && result.nicknames) {
|
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) {
|
} catch (e) {
|
||||||
console.error('[HttpService] Failed to get group nicknames:', e)
|
console.error('[HttpService] Failed to get group nicknames:', e)
|
||||||
|
|||||||
@@ -436,6 +436,10 @@ export class ImageDecryptService {
|
|||||||
if (imageMd5) {
|
if (imageMd5) {
|
||||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
||||||
if (res) return res
|
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,也尝试快速定位
|
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||||
@@ -889,7 +893,8 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const months: string[] = []
|
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 d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
months.push(mStr)
|
months.push(mStr)
|
||||||
|
|||||||
@@ -116,11 +116,30 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async checkSipStatus(): Promise<{ enabled: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
|
||||||
|
const enabled = stdout.toLowerCase().includes('enabled')
|
||||||
|
return { enabled }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { enabled: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async autoGetDbKey(
|
async autoGetDbKey(
|
||||||
timeoutMs = 60_000,
|
timeoutMs = 60_000,
|
||||||
onStatus?: (message: string, level: number) => void
|
onStatus?: (message: string, level: number) => void
|
||||||
): Promise<DbKeyResult> {
|
): Promise<DbKeyResult> {
|
||||||
try {
|
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?.('正在获取数据库密钥...', 0)
|
||||||
onStatus?.('正在请求管理员授权并执行 helper...', 0)
|
onStatus?.('正在请求管理员授权并执行 helper...', 0)
|
||||||
let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string }
|
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)
|
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
|
||||||
if (wxidCandidates.length === 0) {
|
if (wxidCandidates.length === 0) {
|
||||||
return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' }
|
return { success: false, error: '未找到可用的账号候选,请先选择正确的账号目录' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountPathCandidates = this.collectAccountPathCandidates(accountPath)
|
||||||
|
|
||||||
// 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错
|
// 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错
|
||||||
let verifyCiphertext: Buffer | null = null
|
if (accountPathCandidates.length > 0) {
|
||||||
if (accountPath && existsSync(accountPath)) {
|
|
||||||
const template = await this._findTemplateData(accountPath, 32)
|
|
||||||
verifyCiphertext = template.ciphertext
|
|
||||||
}
|
|
||||||
if (verifyCiphertext) {
|
|
||||||
onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`)
|
onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`)
|
||||||
for (const candidateWxid of wxidCandidates) {
|
for (const candidateAccountPath of accountPathCandidates) {
|
||||||
for (const code of codes) {
|
if (!existsSync(candidateAccountPath)) continue
|
||||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
const template = await this._findTemplateData(candidateAccountPath, 32)
|
||||||
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
if (!template.ciphertext) continue
|
||||||
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
|
||||||
return { success: true, xorKey, aesKey }
|
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)
|
// 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code)
|
||||||
@@ -542,16 +574,21 @@ export class KeyServiceMac {
|
|||||||
|
|
||||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||||
|
|
||||||
// 2. 找微信 PID
|
// 2. 持续轮询微信 PID 与内存扫描,兼容微信崩溃后重启 PID 变化
|
||||||
const pid = await this.findWeChatPid()
|
|
||||||
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
|
|
||||||
|
|
||||||
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
|
|
||||||
|
|
||||||
// 3. 持续轮询内存扫描
|
|
||||||
const deadline = Date.now() + 60_000
|
const deadline = Date.now() + 60_000
|
||||||
let scanCount = 0
|
let scanCount = 0
|
||||||
|
let lastPid: number | null = null
|
||||||
while (Date.now() < deadline) {
|
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++
|
scanCount++
|
||||||
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
|
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
|
||||||
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
||||||
@@ -764,7 +801,7 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = chunk.subarray(0, bytesRead)
|
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)
|
const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext)
|
||||||
if (key) return key
|
if (key) return key
|
||||||
// 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中)
|
// 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中)
|
||||||
@@ -793,8 +830,8 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]'
|
const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]'
|
||||||
let stdout = '', stderr = ''
|
let stdout = '', stderr = ''
|
||||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
||||||
child.stderr.on('data', (chunk: Buffer) => {
|
child.stderr?.on('data', (chunk: Buffer) => {
|
||||||
stderr += chunk.toString()
|
stderr += chunk.toString()
|
||||||
console.log(tag, chunk.toString().trim())
|
console.log(tag, chunk.toString().trim())
|
||||||
})
|
})
|
||||||
@@ -819,11 +856,8 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async findWeChatPid(): Promise<number | null> {
|
private async findWeChatPid(): Promise<number | null> {
|
||||||
const { execSync } = await import('child_process')
|
|
||||||
try {
|
try {
|
||||||
const output = execSync('pgrep -x WeChat', { encoding: 'utf8' })
|
return await this.getWeChatPid()
|
||||||
const pid = parseInt(output.trim())
|
|
||||||
return isNaN(pid) ? null : pid
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -840,12 +874,70 @@ export class KeyServiceMac {
|
|||||||
this.machPortDeallocate = null
|
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 {
|
private cleanWxid(wxid: string): string {
|
||||||
const first = wxid.indexOf('_')
|
return this.normalizeAccountId(wxid)
|
||||||
if (first === -1) return wxid
|
|
||||||
const second = wxid.indexOf('_', first + 1)
|
|
||||||
if (second === -1) return wxid
|
|
||||||
return wxid.substring(0, second)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||||
@@ -858,32 +950,59 @@ export class KeyServiceMac {
|
|||||||
|
|
||||||
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
|
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
const pushUnique = (value: string) => {
|
|
||||||
const v = String(value || '').trim()
|
|
||||||
if (!v || candidates.includes(v)) return
|
|
||||||
candidates.push(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) 显式传参优先
|
// 1) 显式传参优先
|
||||||
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
|
this.pushAccountIdCandidates(candidates, wxidParam)
|
||||||
|
|
||||||
if (accountPath) {
|
if (accountPath) {
|
||||||
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
|
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
const dirName = basename(normalized)
|
const dirName = basename(normalized)
|
||||||
// 2) 当前目录名为 wxid_*
|
// 2) 当前目录名本身就是账号目录
|
||||||
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
this.pushAccountIdCandidates(candidates, dirName)
|
||||||
|
|
||||||
// 3) 从 xwechat_files 根目录枚举全部 wxid_* 目录
|
// 3) 从 xwechat_files 根目录枚举全部账号目录
|
||||||
const marker = '/xwechat_files'
|
const root = this.resolveXwechatRootFromPath(accountPath)
|
||||||
const markerIdx = normalized.indexOf(marker)
|
if (root) {
|
||||||
if (markerIdx >= 0) {
|
|
||||||
const root = normalized.slice(0, markerIdx + marker.length)
|
|
||||||
if (existsSync(root)) {
|
if (existsSync(root)) {
|
||||||
try {
|
try {
|
||||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory()) continue
|
if (!entry.isDirectory()) continue
|
||||||
if (!entry.name.startsWith('wxid_')) continue
|
const entryPath = join(root, entry.name)
|
||||||
pushUnique(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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -892,7 +1011,6 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pushUnique('unknown')
|
|
||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export class WcdbCore {
|
|||||||
private wcdbGetEmoticonCdnUrl: any = null
|
private wcdbGetEmoticonCdnUrl: any = null
|
||||||
private wcdbGetDbStatus: any = null
|
private wcdbGetDbStatus: any = null
|
||||||
private wcdbGetVoiceData: any = null
|
private wcdbGetVoiceData: any = null
|
||||||
|
private wcdbSearchMessages: any = null
|
||||||
private wcdbGetSnsTimeline: any = null
|
private wcdbGetSnsTimeline: any = null
|
||||||
private wcdbGetSnsAnnualStats: any = null
|
private wcdbGetSnsAnnualStats: any = null
|
||||||
private wcdbInstallSnsBlockDeleteTrigger: any = null
|
private wcdbInstallSnsBlockDeleteTrigger: any = null
|
||||||
@@ -817,6 +818,13 @@ export class WcdbCore {
|
|||||||
this.wcdbGetVoiceData = null
|
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)
|
// 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 {
|
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)')
|
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 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 outPtr = [null as any]
|
||||||
const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr)
|
const result = this.wcdbGetAvatarUrls(handle, JSON.stringify(toFetch), outPtr)
|
||||||
|
|
||||||
// DLL 调用后再次让出控制权
|
// DLL 调用后再次让出控制权
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
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 }> {
|
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.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
||||||
|
|||||||
@@ -406,6 +406,10 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageById', { sessionId, localId })
|
return this.callWorker('getMessageById', { sessionId, localId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取语音数据
|
* 获取语音数据
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ if (parentPort) {
|
|||||||
case 'getMessageById':
|
case 'getMessageById':
|
||||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||||
break
|
break
|
||||||
|
case 'searchMessages':
|
||||||
|
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
case 'getVoiceData':
|
case 'getVoiceData':
|
||||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
|||||||
|
|
||||||
// 更新位置
|
// 更新位置
|
||||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||||
const winWidth = 344
|
const winWidth = position === 'top-center' ? 280 : 344
|
||||||
const winHeight = 114
|
const winHeight = 114
|
||||||
const padding = 20
|
const padding = 20
|
||||||
|
|
||||||
@@ -140,6 +140,10 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
|||||||
let y = 0
|
let y = 0
|
||||||
|
|
||||||
switch (position) {
|
switch (position) {
|
||||||
|
case 'top-center':
|
||||||
|
x = (screenWidth - winWidth) / 2
|
||||||
|
y = padding
|
||||||
|
break
|
||||||
case 'top-right':
|
case 'top-right':
|
||||||
x = screenWidth - winWidth - padding
|
x = screenWidth - winWidth - padding
|
||||||
y = padding
|
y = padding
|
||||||
@@ -166,7 +170,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
|||||||
win.showInactive() // 显示但不聚焦
|
win.showInactive() // 显示但不聚焦
|
||||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||||
|
|
||||||
win.webContents.send('notification:show', data)
|
win.webContents.send('notification:show', { ...data, position })
|
||||||
|
|
||||||
// 自动关闭计时器通常由渲染进程管理
|
// 自动关闭计时器通常由渲染进程管理
|
||||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||||
|
|||||||
BIN
resources/libwcdb_api.dylib
Executable file
BIN
resources/libwcdb_api.dylib
Executable file
Binary file not shown.
Binary file not shown.
39
src/App.tsx
39
src/App.tsx
@@ -37,6 +37,7 @@ import LockScreen from './components/LockScreen'
|
|||||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||||
|
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||||
|
|
||||||
function RouteStateRedirect({ to }: { to: string }) {
|
function RouteStateRedirect({ to }: { to: string }) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -85,6 +86,8 @@ function App() {
|
|||||||
const isExportRoute = routeLocation.pathname === '/export'
|
const isExportRoute = routeLocation.pathname === '/export'
|
||||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = 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
|
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||||
@@ -107,6 +110,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [location])
|
}, [location])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => {
|
||||||
|
setCanMinimizeToTray(Boolean(payload.canMinimizeToTray))
|
||||||
|
setShowCloseDialog(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => removeCloseConfirmListener()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
const body = document.body
|
const body = document.body
|
||||||
@@ -315,6 +327,26 @@ function App() {
|
|||||||
setUpdateInfo(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (isAgreementWindow || isOnboardingWindow) return
|
if (isAgreementWindow || isOnboardingWindow) return
|
||||||
@@ -593,6 +625,13 @@ function App() {
|
|||||||
progress={downloadProgress}
|
progress={downloadProgress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WindowCloseDialog
|
||||||
|
open={showCloseDialog}
|
||||||
|
canMinimizeToTray={canMinimizeToTray}
|
||||||
|
onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
|
||||||
|
onCancel={() => handleWindowCloseAction('cancel')}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="main-layout">
|
<div className="main-layout">
|
||||||
<Sidebar collapsed={sidebarCollapsed} />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
<main className="content">
|
<main className="content">
|
||||||
|
|||||||
@@ -50,6 +50,21 @@
|
|||||||
border-radius: inherit;
|
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 */
|
/* Loading Skeleton */
|
||||||
.avatar-skeleton {
|
.avatar-skeleton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -76,4 +91,14 @@
|
|||||||
background-position: -200% 0;
|
background-position: -200% 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@keyframes avatar-spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
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 { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||||
import './Avatar.scss'
|
import './Avatar.scss'
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ interface AvatarProps {
|
|||||||
shape?: 'circle' | 'square' | 'rounded'
|
shape?: 'circle' | 'square' | 'rounded'
|
||||||
className?: string
|
className?: string
|
||||||
lazy?: boolean
|
lazy?: boolean
|
||||||
|
loading?: boolean
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,12 +24,14 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
shape = 'rounded',
|
shape = 'rounded',
|
||||||
className = '',
|
className = '',
|
||||||
lazy = true,
|
lazy = true,
|
||||||
|
loading = false,
|
||||||
onClick
|
onClick
|
||||||
}: AvatarProps) {
|
}: AvatarProps) {
|
||||||
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
||||||
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
||||||
|
const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src])
|
||||||
const [imageLoaded, setImageLoaded] = useState(isCached)
|
const [imageLoaded, setImageLoaded] = useState(isCached)
|
||||||
const [imageError, setImageError] = useState(false)
|
const [imageError, setImageError] = useState(isFailed)
|
||||||
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
||||||
const [isInQueue, setIsInQueue] = useState(false)
|
const [isInQueue, setIsInQueue] = useState(false)
|
||||||
const imgRef = useRef<HTMLImageElement>(null)
|
const imgRef = useRef<HTMLImageElement>(null)
|
||||||
@@ -42,7 +45,7 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
|
|
||||||
// Intersection Observer for lazy loading
|
// Intersection Observer for lazy loading
|
||||||
useEffect(() => {
|
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(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
@@ -50,10 +53,11 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
if (entry.isIntersecting && !isInQueue) {
|
if (entry.isIntersecting && !isInQueue) {
|
||||||
setIsInQueue(true)
|
setIsInQueue(true)
|
||||||
avatarLoadQueue.enqueue(src).then(() => {
|
avatarLoadQueue.enqueue(src).then(() => {
|
||||||
|
setImageError(false)
|
||||||
setShouldLoad(true)
|
setShouldLoad(true)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// 加载失败不要立刻显示错误,让浏览器渲染去报错
|
setImageError(true)
|
||||||
setShouldLoad(true)
|
setShouldLoad(false)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
setIsInQueue(false)
|
setIsInQueue(false)
|
||||||
})
|
})
|
||||||
@@ -67,14 +71,18 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
observer.observe(containerRef.current)
|
observer.observe(containerRef.current)
|
||||||
|
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [src, lazy, shouldLoad, isInQueue, isCached])
|
}, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed])
|
||||||
|
|
||||||
// Reset state when src changes
|
// Reset state when src changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cached = src ? loadedAvatarCache.has(src) : false
|
const cached = src ? loadedAvatarCache.has(src) : false
|
||||||
|
const failed = src ? avatarLoadQueue.hasFailed(src) : false
|
||||||
setImageLoaded(cached)
|
setImageLoaded(cached)
|
||||||
setImageError(false)
|
setImageError(failed)
|
||||||
if (lazy && !cached) {
|
if (failed) {
|
||||||
|
setShouldLoad(false)
|
||||||
|
setIsInQueue(false)
|
||||||
|
} else if (lazy && !cached) {
|
||||||
setShouldLoad(false)
|
setShouldLoad(false)
|
||||||
setIsInQueue(false)
|
setIsInQueue(false)
|
||||||
} else {
|
} else {
|
||||||
@@ -95,6 +103,7 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasValidUrl = !!src && !imageError && shouldLoad
|
const hasValidUrl = !!src && !imageError && shouldLoad
|
||||||
|
const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -112,13 +121,30 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
alt={name || 'avatar'}
|
alt={name || 'avatar'}
|
||||||
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
if (src) loadedAvatarCache.add(src)
|
if (src) {
|
||||||
|
avatarLoadQueue.clearFailed(src)
|
||||||
|
loadedAvatarCache.add(src)
|
||||||
|
}
|
||||||
setImageLoaded(true)
|
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"}
|
loading={lazy ? "lazy" : "eager"}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : shouldShowLoadingPlaceholder ? (
|
||||||
|
<div className="avatar-loading">
|
||||||
|
<Loader2 size="50%" className="avatar-loading-icon" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="avatar-placeholder">
|
<div className="avatar-placeholder">
|
||||||
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import type { ChatSession } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
export function GlobalSessionMonitor() {
|
export function GlobalSessionMonitor() {
|
||||||
@@ -20,9 +20,9 @@ export function GlobalSessionMonitor() {
|
|||||||
}, [sessions])
|
}, [sessions])
|
||||||
|
|
||||||
// 去重辅助函数:获取消息 key
|
// 去重辅助函数:获取消息 key
|
||||||
const getMessageKey = (msg: any) => {
|
const getMessageKey = (msg: Message) => {
|
||||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
if (msg.messageKey) return msg.messageKey
|
||||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
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 {
|
try {
|
||||||
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
||||||
if (result.success && result.messages && result.messages.length > 0) {
|
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) {
|
} catch (e) {
|
||||||
console.warn('后台活跃会话刷新失败:', e)
|
console.warn('后台活跃会话刷新失败:', e)
|
||||||
|
|||||||
@@ -134,6 +134,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.top-center {
|
||||||
|
top: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -20px) scale(0.95);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translate(-50%, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 灵动岛样式
|
||||||
|
border-radius: 40px !important;
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
&.static {
|
||||||
|
border-radius: 40px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface NotificationToastProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onClick: (sessionId: string) => void
|
onClick: (sessionId: string) => void
|
||||||
duration?: number
|
duration?: number
|
||||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||||
isStatic?: boolean
|
isStatic?: boolean
|
||||||
initialVisible?: boolean
|
initialVisible?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useChatStore } from '../stores/chatStore'
|
|||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||||
|
import { UserRound } from 'lucide-react'
|
||||||
|
|
||||||
import './Sidebar.scss'
|
import './Sidebar.scss'
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ interface AccountProfilesCache {
|
|||||||
interface WxidOption {
|
interface WxidOption {
|
||||||
wxid: string
|
wxid: string
|
||||||
modifiedTime: number
|
modifiedTime: number
|
||||||
|
nickname?: string
|
||||||
displayName?: string
|
displayName?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
}
|
}
|
||||||
@@ -280,26 +282,28 @@ function Sidebar({ collapsed }: SidebarProps) {
|
|||||||
const accountsCache = readAccountProfilesCache()
|
const accountsCache = readAccountProfilesCache()
|
||||||
console.log('[切换账号] 账号缓存:', accountsCache)
|
console.log('[切换账号] 账号缓存:', accountsCache)
|
||||||
|
|
||||||
const enrichedWxids = wxids.map(option => {
|
const enrichedWxids = wxids.map((option: WxidOption) => {
|
||||||
const normalizedWxid = normalizeAccountId(option.wxid)
|
const normalizedWxid = normalizeAccountId(option.wxid)
|
||||||
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
|
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) {
|
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
|
||||||
return {
|
displayName = userProfile.displayName || displayName
|
||||||
...option,
|
avatarUrl = userProfile.avatarUrl || avatarUrl
|
||||||
displayName: userProfile.displayName,
|
|
||||||
avatarUrl: userProfile.avatarUrl
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (cached) {
|
|
||||||
console.log('[切换账号] 使用缓存:', option.wxid, cached)
|
else if (cached) {
|
||||||
return {
|
displayName = cached.displayName || displayName
|
||||||
...option,
|
avatarUrl = cached.avatarUrl || avatarUrl
|
||||||
displayName: cached.displayName,
|
}
|
||||||
avatarUrl: cached.avatarUrl
|
|
||||||
}
|
return {
|
||||||
|
...option,
|
||||||
|
displayName,
|
||||||
|
avatarUrl
|
||||||
}
|
}
|
||||||
return { ...option, displayName: option.wxid }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setWxidOptions(enrichedWxids)
|
setWxidOptions(enrichedWxids)
|
||||||
@@ -553,11 +557,17 @@ function Sidebar({ collapsed }: SidebarProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="wxid-avatar">
|
<div className="wxid-avatar">
|
||||||
{option.avatarUrl ? <img src={option.avatarUrl} alt="" /> : <span>{getAvatarLetter(option.displayName || option.wxid)}</span>}
|
{option.avatarUrl ? (
|
||||||
|
<img src={option.avatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-tertiary)', borderRadius: '6px', color: 'var(--text-tertiary)' }}>
|
||||||
|
<UserRound size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="wxid-info">
|
<div className="wxid-info">
|
||||||
<div className="wxid-name">{option.displayName || option.wxid}</div>
|
<div className="wxid-name">{option.displayName}</div>
|
||||||
<div className="wxid-id">{option.wxid}</div>
|
{option.displayName !== option.wxid && <div className="wxid-id">{option.wxid}</div>}
|
||||||
</div>
|
</div>
|
||||||
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
306
src/components/WindowCloseDialog.scss
Normal file
306
src/components/WindowCloseDialog.scss
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
.window-close-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(36, 42, 54, 0.18), transparent 48%),
|
||||||
|
rgba(7, 10, 18, 0.56);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 3000;
|
||||||
|
animation: windowCloseDialogFadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||||
|
border-radius: 24px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 94%, white 6%) 0%, var(--bg-primary) 100%);
|
||||||
|
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-header {
|
||||||
|
padding: 28px 30px 18px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||||
|
|
||||||
|
.window-close-dialog-kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-body {
|
||||||
|
padding: 20px 24px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-option {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 18px 18px 16px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 86%, white 14%) 0%, var(--bg-secondary) 100%);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
background 0.18s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
|
||||||
|
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-danger:hover {
|
||||||
|
border-color: rgba(205, 73, 73, 0.42);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-option-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex: 0 0 42px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in srgb, var(--primary) 14%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-option.is-danger .window-close-dialog-option-icon {
|
||||||
|
background: rgba(205, 73, 73, 0.12);
|
||||||
|
color: #cd4949;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-option-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-actions {
|
||||||
|
padding: 8px 24px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-remember {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 4px 24px 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 76%, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
|
border: 1.5px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
position: relative;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
top: 1px;
|
||||||
|
width: 5px;
|
||||||
|
height: 10px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg) scale(0.7);
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(45deg) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-remember-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-cancel {
|
||||||
|
min-width: 112px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
border-color 0.18s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 24%, var(--border-color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 84%, transparent);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.window-close-dialog-overlay {
|
||||||
|
padding: 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog {
|
||||||
|
border-radius: 24px 24px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-header {
|
||||||
|
padding: 24px 22px 16px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-body {
|
||||||
|
padding: 18px 18px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-actions {
|
||||||
|
padding: 8px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-close-dialog-cancel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes windowCloseDialogFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes windowCloseDialogSlideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(24px) scale(0.98);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/components/WindowCloseDialog.tsx
Normal file
115
src/components/WindowCloseDialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Minimize2, Power, X } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import './WindowCloseDialog.scss'
|
||||||
|
|
||||||
|
interface WindowCloseDialogProps {
|
||||||
|
open: boolean
|
||||||
|
canMinimizeToTray: boolean
|
||||||
|
onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WindowCloseDialog({
|
||||||
|
open,
|
||||||
|
canMinimizeToTray,
|
||||||
|
onSelect,
|
||||||
|
onCancel
|
||||||
|
}: WindowCloseDialogProps) {
|
||||||
|
const [rememberChoice, setRememberChoice] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setRememberChoice(false)
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onCancel])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="window-close-dialog-overlay" onClick={onCancel}>
|
||||||
|
<div
|
||||||
|
className="window-close-dialog"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="window-close-dialog-title"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="window-close-dialog-close"
|
||||||
|
onClick={onCancel}
|
||||||
|
aria-label="关闭提示"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="window-close-dialog-header">
|
||||||
|
<span className="window-close-dialog-kicker">退出行为</span>
|
||||||
|
<h2 id="window-close-dialog-title">关闭 WeFlow</h2>
|
||||||
|
<p>
|
||||||
|
{canMinimizeToTray
|
||||||
|
? '你可以保留后台进程与本地 API,或者直接完全退出应用。'
|
||||||
|
: '当前系统托盘不可用,本次只能完全退出应用。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="window-close-dialog-body">
|
||||||
|
{canMinimizeToTray && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="window-close-dialog-option"
|
||||||
|
onClick={() => onSelect('tray', rememberChoice)}
|
||||||
|
>
|
||||||
|
<span className="window-close-dialog-option-icon">
|
||||||
|
<Minimize2 size={18} />
|
||||||
|
</span>
|
||||||
|
<span className="window-close-dialog-option-text">
|
||||||
|
<strong>最小化到系统托盘</strong>
|
||||||
|
<span>继续保留后台进程和本地 API,稍后可从托盘恢复。</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="window-close-dialog-option is-danger"
|
||||||
|
onClick={() => onSelect('quit', rememberChoice)}
|
||||||
|
>
|
||||||
|
<span className="window-close-dialog-option-icon">
|
||||||
|
<Power size={18} />
|
||||||
|
</span>
|
||||||
|
<span className="window-close-dialog-option-text">
|
||||||
|
<strong>完全关闭</strong>
|
||||||
|
<span>结束 WeFlow 进程,并停止当前保留的本地 API。</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="window-close-dialog-remember">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rememberChoice}
|
||||||
|
onChange={(event) => setRememberChoice(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="window-close-dialog-checkbox" aria-hidden="true" />
|
||||||
|
<span className="window-close-dialog-remember-text">下次不再提示,直接按本次选择处理</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="window-close-dialog-actions">
|
||||||
|
<button type="button" className="window-close-dialog-cancel" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1129,8 +1129,12 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.unread-badge {
|
.unread-badge {
|
||||||
@@ -2761,7 +2765,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@@ -3045,13 +3049,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-flag {
|
.member-flag {
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&.owner {
|
&.owner {
|
||||||
color: #f59e0b;
|
color: #f59e0b;
|
||||||
@@ -4538,7 +4544,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@@ -4630,3 +4636,248 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 会话内搜索栏
|
||||||
|
// 会话内搜索浮窗
|
||||||
|
.in-session-search-popup {
|
||||||
|
position: absolute;
|
||||||
|
top: 60px;
|
||||||
|
right: 16px;
|
||||||
|
width: 360px;
|
||||||
|
max-height: 500px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.in-session-search-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 0;
|
||||||
|
&::placeholder { color: var(--text-tertiary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
color: var(--primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-header {
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.in-session-results {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.result-sender {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索分类标题
|
||||||
|
.search-section-header {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局消息搜索结果面板
|
||||||
|
.global-msg-search-results {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.search-loading,
|
||||||
|
.no-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-list {
|
||||||
|
.session-item {
|
||||||
|
display: flex;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.session-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.session-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-preview {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-search-toggle-btn.active {
|
||||||
|
color: var(--accent-color, #07c160);
|
||||||
|
}
|
||||||
|
.in-session-search-btn.active {
|
||||||
|
color: var(--accent-color, #07c160);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1596,6 +1596,7 @@ function ExportPage() {
|
|||||||
const sessionMutualFriendsRunIdRef = useRef(0)
|
const sessionMutualFriendsRunIdRef = useRef(0)
|
||||||
const sessionMutualFriendsWorkerRunningRef = useRef(false)
|
const sessionMutualFriendsWorkerRunningRef = useRef(false)
|
||||||
const sessionMutualFriendsBackgroundFeedTimerRef = useRef<number | null>(null)
|
const sessionMutualFriendsBackgroundFeedTimerRef = useRef<number | null>(null)
|
||||||
|
const sessionMutualFriendsPersistTimerRef = useRef<number | null>(null)
|
||||||
const sessionMutualFriendsVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
|
const sessionMutualFriendsVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
|
||||||
startIndex: 0,
|
startIndex: 0,
|
||||||
endIndex: -1
|
endIndex: -1
|
||||||
@@ -2748,8 +2749,32 @@ function ExportPage() {
|
|||||||
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
||||||
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
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 => {
|
const isSessionMutualFriendsReady = useCallback((sessionId: string): boolean => {
|
||||||
if (!sessionId) return true
|
if (!sessionId) return true
|
||||||
if (sessionMutualFriendsReadySetRef.current.has(sessionId)) return true
|
if (sessionMutualFriendsReadySetRef.current.has(sessionId)) return true
|
||||||
@@ -2879,10 +2904,35 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [getSessionMutualFriendProfile])
|
}, [getSessionMutualFriendProfile])
|
||||||
|
|
||||||
|
const rebuildSessionMutualFriendsStateFromDirectMetrics = useCallback((sessionIds?: string[]) => {
|
||||||
|
const targets = Array.isArray(sessionIds) && sessionIds.length > 0
|
||||||
|
? sessionIds
|
||||||
|
: Object.keys(sessionMutualFriendsDirectMetricsRef.current)
|
||||||
|
const nextMetrics: Record<string, SessionMutualFriendsMetric> = {}
|
||||||
|
const readyIds: string[] = []
|
||||||
|
for (const sessionIdRaw of targets) {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
const rebuilt = rebuildSessionMutualFriendsMetric(sessionId)
|
||||||
|
if (!rebuilt) continue
|
||||||
|
nextMetrics[sessionId] = rebuilt
|
||||||
|
readyIds.push(sessionId)
|
||||||
|
}
|
||||||
|
sessionMutualFriendsMetricsRef.current = nextMetrics
|
||||||
|
setSessionMutualFriendsMetrics(nextMetrics)
|
||||||
|
if (readyIds.length > 0) {
|
||||||
|
for (const sessionId of readyIds) {
|
||||||
|
sessionMutualFriendsReadySetRef.current.add(sessionId)
|
||||||
|
}
|
||||||
|
patchSessionLoadTraceStage(readyIds, 'mutualFriends', 'done')
|
||||||
|
}
|
||||||
|
}, [patchSessionLoadTraceStage, rebuildSessionMutualFriendsMetric])
|
||||||
|
|
||||||
const applySessionMutualFriendsMetric = useCallback((sessionId: string, directMetric: SessionMutualFriendsMetric) => {
|
const applySessionMutualFriendsMetric = useCallback((sessionId: string, directMetric: SessionMutualFriendsMetric) => {
|
||||||
const normalizedSessionId = String(sessionId || '').trim()
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
if (!normalizedSessionId) return
|
if (!normalizedSessionId) return
|
||||||
sessionMutualFriendsDirectMetricsRef.current[normalizedSessionId] = directMetric
|
sessionMutualFriendsDirectMetricsRef.current[normalizedSessionId] = directMetric
|
||||||
|
scheduleFlushSessionMutualFriendsCache()
|
||||||
|
|
||||||
const impactedSessionIds = new Set<string>([normalizedSessionId])
|
const impactedSessionIds = new Set<string>([normalizedSessionId])
|
||||||
const allSessionIds = sessionsRef.current
|
const allSessionIds = sessionsRef.current
|
||||||
@@ -2912,7 +2962,7 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
return changed ? next : prev
|
return changed ? next : prev
|
||||||
})
|
})
|
||||||
}, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric])
|
}, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric, scheduleFlushSessionMutualFriendsCache])
|
||||||
|
|
||||||
const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => {
|
const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => {
|
||||||
if (!sessionId) return true
|
if (!sessionId) return true
|
||||||
@@ -3339,11 +3389,13 @@ function ExportPage() {
|
|||||||
const [
|
const [
|
||||||
cachedContactsPayload,
|
cachedContactsPayload,
|
||||||
cachedMessageCountsPayload,
|
cachedMessageCountsPayload,
|
||||||
cachedContentMetricsPayload
|
cachedContentMetricsPayload,
|
||||||
|
cachedMutualFriendsPayload
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
loadContactsCaches(scopeKey),
|
loadContactsCaches(scopeKey),
|
||||||
configService.getExportSessionMessageCountCache(scopeKey),
|
configService.getExportSessionMessageCountCache(scopeKey),
|
||||||
configService.getExportSessionContentMetricCache(scopeKey)
|
configService.getExportSessionContentMetricCache(scopeKey),
|
||||||
|
configService.getExportSessionMutualFriendsCache(scopeKey)
|
||||||
])
|
])
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
|
|
||||||
@@ -3411,6 +3463,15 @@ function ExportPage() {
|
|||||||
if (cachedContentMetricReadySessionIds.length > 0) {
|
if (cachedContentMetricReadySessionIds.length > 0) {
|
||||||
patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done')
|
patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done')
|
||||||
}
|
}
|
||||||
|
const cachedMutualFriendDirectMetrics = Object.entries(cachedMutualFriendsPayload?.metrics || {}).reduce<Record<string, SessionMutualFriendsMetric>>((acc, [sessionIdRaw, metricRaw]) => {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!exportableSessionIdSet.has(sessionId) || !isSingleContactSession(sessionId)) return acc
|
||||||
|
const metric = metricRaw as SessionMutualFriendsMetric | undefined
|
||||||
|
if (!metric || !Array.isArray(metric.items) || !Number.isFinite(metric.count)) return acc
|
||||||
|
acc[sessionId] = metric
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
const cachedMutualFriendSessionIds = Object.keys(cachedMutualFriendDirectMetrics)
|
||||||
|
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
if (Object.keys(cachedMessageCounts).length > 0) {
|
if (Object.keys(cachedMessageCounts).length > 0) {
|
||||||
@@ -3422,6 +3483,13 @@ function ExportPage() {
|
|||||||
if (Object.keys(cachedContentMetrics).length > 0) {
|
if (Object.keys(cachedContentMetrics).length > 0) {
|
||||||
mergeSessionContentMetrics(cachedContentMetrics)
|
mergeSessionContentMetrics(cachedContentMetrics)
|
||||||
}
|
}
|
||||||
|
if (cachedMutualFriendSessionIds.length > 0) {
|
||||||
|
sessionMutualFriendsDirectMetricsRef.current = cachedMutualFriendDirectMetrics
|
||||||
|
rebuildSessionMutualFriendsStateFromDirectMetrics(cachedMutualFriendSessionIds)
|
||||||
|
} else {
|
||||||
|
sessionMutualFriendsMetricsRef.current = {}
|
||||||
|
setSessionMutualFriendsMetrics({})
|
||||||
|
}
|
||||||
setSessions(baseSessions)
|
setSessions(baseSessions)
|
||||||
sessionsHydratedAtRef.current = Date.now()
|
sessionsHydratedAtRef.current = Date.now()
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -3622,7 +3690,7 @@ function ExportPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
if (!isStale()) setIsLoading(false)
|
if (!isStale()) setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts])
|
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, rebuildSessionMutualFriendsStateFromDirectMetrics, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isExportRoute) return
|
if (!isExportRoute) return
|
||||||
@@ -3630,10 +3698,7 @@ function ExportPage() {
|
|||||||
const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current &&
|
const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current &&
|
||||||
sessionsRef.current.length > 0 &&
|
sessionsRef.current.length > 0 &&
|
||||||
now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS
|
now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS
|
||||||
const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current &&
|
const baseConfigPromise = loadBaseConfig()
|
||||||
now - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS
|
|
||||||
|
|
||||||
void loadBaseConfig()
|
|
||||||
void ensureSharedTabCountsLoaded()
|
void ensureSharedTabCountsLoaded()
|
||||||
if (!hasFreshSessionSnapshot) {
|
if (!hasFreshSessionSnapshot) {
|
||||||
void loadSessions()
|
void loadSessions()
|
||||||
@@ -3641,9 +3706,14 @@ function ExportPage() {
|
|||||||
|
|
||||||
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
|
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
if (!hasFreshSnsSnapshot) {
|
void (async () => {
|
||||||
void loadSnsStats({ full: true })
|
await baseConfigPromise
|
||||||
}
|
const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current &&
|
||||||
|
Date.now() - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS
|
||||||
|
if (!hasFreshSnsSnapshot) {
|
||||||
|
void loadSnsStats({ full: true })
|
||||||
|
}
|
||||||
|
})()
|
||||||
}, 120)
|
}, 120)
|
||||||
|
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
@@ -4988,9 +5058,14 @@ function ExportPage() {
|
|||||||
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
||||||
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
if (sessionMutualFriendsPersistTimerRef.current) {
|
||||||
|
window.clearTimeout(sessionMutualFriendsPersistTimerRef.current)
|
||||||
|
sessionMutualFriendsPersistTimerRef.current = null
|
||||||
|
}
|
||||||
void flushSessionMediaMetricCache()
|
void flushSessionMediaMetricCache()
|
||||||
|
void flushSessionMutualFriendsCache()
|
||||||
}
|
}
|
||||||
}, [flushSessionMediaMetricCache])
|
}, [flushSessionMediaMetricCache, flushSessionMutualFriendsCache])
|
||||||
|
|
||||||
const contactByUsername = useMemo(() => {
|
const contactByUsername = useMemo(() => {
|
||||||
const map = new Map<string, ContactInfo>()
|
const map = new Map<string, ContactInfo>()
|
||||||
@@ -5254,6 +5329,23 @@ function ExportPage() {
|
|||||||
console.error('导出页读取会话统计缓存失败:', error)
|
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 lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0
|
||||||
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
|
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
|
||||||
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
|
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
|
||||||
@@ -5302,16 +5394,36 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername])
|
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername])
|
||||||
|
|
||||||
const loadSessionRelationStats = useCallback(async () => {
|
const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => {
|
||||||
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
|
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
|
||||||
if (!normalizedSessionId || isLoadingSessionRelationStats) return
|
if (!normalizedSessionId || isLoadingSessionRelationStats) return
|
||||||
|
|
||||||
const requestSeq = detailRequestSeqRef.current
|
const requestSeq = detailRequestSeqRef.current
|
||||||
|
const forceRefresh = options?.forceRefresh === true
|
||||||
setIsLoadingSessionRelationStats(true)
|
setIsLoadingSessionRelationStats(true)
|
||||||
try {
|
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(
|
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
[normalizedSessionId],
|
[normalizedSessionId],
|
||||||
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
|
{ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true }
|
||||||
)
|
)
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
|
||||||
@@ -5333,6 +5445,60 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return
|
if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return
|
||||||
if (snsUserPostCountsStatus === 'idle') {
|
if (snsUserPostCountsStatus === 'idle') {
|
||||||
@@ -6371,7 +6537,7 @@ function ExportPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary-btn" onClick={() => void loadContactsList()} disabled={isContactsListLoading}>
|
<button className="secondary-btn" onClick={() => void handleRefreshTableData()} disabled={isContactsListLoading}>
|
||||||
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} />
|
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} />
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
@@ -6468,7 +6634,7 @@ function ExportPage() {
|
|||||||
<li>可能原因3:数据库连接状态异常或 IPC 调用卡住。</li>
|
<li>可能原因3:数据库连接状态异常或 IPC 调用卡住。</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="issue-actions">
|
<div className="issue-actions">
|
||||||
<button className="issue-btn primary" onClick={() => void loadContactsList()}>
|
<button className="issue-btn primary" onClick={() => void handleRefreshTableData()}>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
<span>重试加载</span>
|
<span>重试加载</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-analytics-page {
|
.group-analytics-page {
|
||||||
@@ -10,6 +12,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&.standalone {
|
&.standalone {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -197,6 +200,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
max-width: 450px;
|
max-width: 450px;
|
||||||
|
min-height: 0;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -207,6 +211,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
.search-row {
|
.search-row {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -296,6 +301,7 @@
|
|||||||
|
|
||||||
.group-list {
|
.group-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
@@ -468,11 +474,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-drag-region {
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
@@ -495,22 +508,30 @@
|
|||||||
|
|
||||||
.function-menu {
|
.function-menu {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 20px;
|
||||||
justify-content: center;
|
padding: 24px;
|
||||||
padding: 32px;
|
overflow-y: auto;
|
||||||
|
|
||||||
.selected-group-info {
|
.selected-group-info {
|
||||||
text-align: center;
|
display: flex;
|
||||||
margin-bottom: 40px;
|
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 {
|
.group-avatar.large {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 auto 16px;
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
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 {
|
h2 {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-grid {
|
.function-grid {
|
||||||
display: flex;
|
width: 100%;
|
||||||
flex-wrap: wrap;
|
display: grid;
|
||||||
gap: 20px;
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
justify-content: center;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-card {
|
.function-card {
|
||||||
width: 140px;
|
min-height: 148px;
|
||||||
padding: 24px 16px;
|
padding: 20px 18px;
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: color-mix(in srgb, var(--card-bg) 92%, var(--bg-secondary));
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
box-shadow: var(--shadow-sm);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid var(--border-color);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
background: rgba(255, 255, 255, 0.25);
|
background: color-mix(in srgb, var(--card-bg) 100%, var(--bg-hover));
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@@ -575,15 +615,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-content {
|
.function-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -694,6 +741,7 @@
|
|||||||
|
|
||||||
.content-body {
|
.content-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -785,7 +833,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-export-panel {
|
.member-export-panel,
|
||||||
|
.member-messages-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -1121,6 +1170,153 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.rankings-list {
|
||||||
@@ -1405,6 +1601,16 @@
|
|||||||
background: rgba(30, 30, 30, 0.95);
|
background: rgba(30, 30, 30, 0.95);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
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;
|
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 {
|
.detail-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1537,3 +1771,141 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-export-modal {
|
||||||
|
background: rgba(255, 255, 255, 0.97);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 28px;
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
max-height: min(760px, calc(100vh - 32px));
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-modal-header {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-right: 40px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-panel {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-result-modal {
|
||||||
|
background: rgba(255, 255, 255, 0.97);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 28px;
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
position: relative;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
border: 1px solid color-mix(in srgb, #ef4444 38%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-result-modal-body {
|
||||||
|
padding-right: 40px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-result-modal-actions {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-result-modal-btn {
|
||||||
|
min-width: 96px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
@keyframes noti-exit {
|
||||||
0% {
|
0% {
|
||||||
opacity: 1;
|
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 {
|
body {
|
||||||
// Ensure the body background is transparent to let the rounded corners show
|
// Ensure the body background is transparent to let the rounded corners show
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -41,6 +65,10 @@ body {
|
|||||||
// New notification slides in
|
// New notification slides in
|
||||||
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
|
|
||||||
|
&.anim-center {
|
||||||
|
animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#notification-prev {
|
#notification-prev {
|
||||||
@@ -51,4 +79,8 @@ body {
|
|||||||
|
|
||||||
// Ensure it stays behind
|
// Ensure it stays behind
|
||||||
z-index: 0 !important;
|
z-index: 0 !important;
|
||||||
|
|
||||||
|
&.anim-center {
|
||||||
|
animation: noti-exit-center 0.5s cubic-bezier(0.33, 1, 0.68, 1) forwards;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,9 @@ import './NotificationWindow.scss'
|
|||||||
export default function NotificationWindow() {
|
export default function NotificationWindow() {
|
||||||
const [notification, setNotification] = useState<NotificationData | null>(null)
|
const [notification, setNotification] = useState<NotificationData | null>(null)
|
||||||
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
||||||
|
const [position, setPosition] = useState<string>('top-right')
|
||||||
|
|
||||||
// We need a ref to access the current notification inside the callback
|
// We need a ref to access the current notification inside the callback
|
||||||
// without satisfying the dependency array which would recreate the listener
|
// 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.
|
// 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 })
|
// So we use setNotification callback: setNotification(current => { ... return newNode })
|
||||||
@@ -34,6 +35,11 @@ export default function NotificationWindow() {
|
|||||||
avatarUrl: data.avatarUrl
|
avatarUrl: data.avatarUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取位置配置
|
||||||
|
if (data.position) {
|
||||||
|
setPosition(data.position)
|
||||||
|
}
|
||||||
|
|
||||||
// Set previous to current (ref)
|
// Set previous to current (ref)
|
||||||
if (notificationRef.current) {
|
if (notificationRef.current) {
|
||||||
setPrevNotification(notificationRef.current)
|
setPrevNotification(notificationRef.current)
|
||||||
@@ -117,6 +123,7 @@ export default function NotificationWindow() {
|
|||||||
<div
|
<div
|
||||||
id="notification-prev"
|
id="notification-prev"
|
||||||
key={prevNotification.id}
|
key={prevNotification.id}
|
||||||
|
className={position === 'top-center' ? 'anim-center' : ''}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 2, // Match padding
|
top: 2, // Match padding
|
||||||
@@ -131,7 +138,7 @@ export default function NotificationWindow() {
|
|||||||
data={prevNotification}
|
data={prevNotification}
|
||||||
onClose={() => { }} // No-op for background item
|
onClose={() => { }} // No-op for background item
|
||||||
onClick={() => { }}
|
onClick={() => { }}
|
||||||
position="top-right"
|
position={position as any}
|
||||||
isStatic={true}
|
isStatic={true}
|
||||||
initialVisible={true}
|
initialVisible={true}
|
||||||
/>
|
/>
|
||||||
@@ -143,6 +150,7 @@ export default function NotificationWindow() {
|
|||||||
<div
|
<div
|
||||||
id="notification-current"
|
id="notification-current"
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
|
className={position === 'top-center' ? 'anim-center' : ''}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative', // Takes up space
|
position: 'relative', // Takes up space
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
@@ -154,7 +162,7 @@ export default function NotificationWindow() {
|
|||||||
data={notification}
|
data={notification}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
position="top-right"
|
position={position as any}
|
||||||
isStatic={true}
|
isStatic={true}
|
||||||
initialVisible={true}
|
initialVisible={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.settings-modal-overlay {
|
.settings-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 41px;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -1705,7 +1705,7 @@
|
|||||||
|
|
||||||
.wxid-dialog-item {
|
.wxid-dialog-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -1743,6 +1743,66 @@
|
|||||||
justify-content: flex-end;
|
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 {
|
.notification-filter-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||||
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
|
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'
|
} from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
@@ -34,6 +34,8 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
|||||||
interface WxidOption {
|
interface WxidOption {
|
||||||
wxid: string
|
wxid: string
|
||||||
modifiedTime: number
|
modifiedTime: number
|
||||||
|
nickname?: string
|
||||||
|
avatarUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
@@ -102,12 +104,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||||
|
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(true)
|
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 [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
|
||||||
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
||||||
|
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
|
||||||
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
||||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||||
|
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
|
||||||
|
|
||||||
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
|
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
|
||||||
const [excludeWordsInput, setExcludeWordsInput] = useState('')
|
const [excludeWordsInput, setExcludeWordsInput] = useState('')
|
||||||
@@ -251,15 +255,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
if (!target.closest('.custom-select')) {
|
if (!target.closest('.custom-select')) {
|
||||||
setFilterModeDropdownOpen(false)
|
setFilterModeDropdownOpen(false)
|
||||||
setPositionDropdownOpen(false)
|
setPositionDropdownOpen(false)
|
||||||
|
setCloseBehaviorDropdownOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filterModeDropdownOpen || positionDropdownOpen) {
|
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
}, [filterModeDropdownOpen, positionDropdownOpen])
|
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen])
|
||||||
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -281,6 +286,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedNotificationPosition = await configService.getNotificationPosition()
|
const savedNotificationPosition = await configService.getNotificationPosition()
|
||||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||||
|
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||||
|
|
||||||
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||||
@@ -316,6 +322,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setNotificationPosition(savedNotificationPosition)
|
setNotificationPosition(savedNotificationPosition)
|
||||||
setNotificationFilterMode(savedNotificationFilterMode)
|
setNotificationFilterMode(savedNotificationFilterMode)
|
||||||
setNotificationFilterList(savedNotificationFilterList)
|
setNotificationFilterList(savedNotificationFilterList)
|
||||||
|
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||||
|
|
||||||
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
||||||
setWordCloudExcludeWords(savedExcludeWords)
|
setWordCloudExcludeWords(savedExcludeWords)
|
||||||
@@ -1022,6 +1029,61 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>关闭主窗口时</label>
|
||||||
|
<span className="form-hint">设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。</span>
|
||||||
|
<div className="custom-select">
|
||||||
|
<div
|
||||||
|
className={`custom-select-trigger ${closeBehaviorDropdownOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => setCloseBehaviorDropdownOpen(!closeBehaviorDropdownOpen)}
|
||||||
|
>
|
||||||
|
<span className="custom-select-value">
|
||||||
|
{windowCloseBehavior === 'tray'
|
||||||
|
? '最小化到系统托盘'
|
||||||
|
: windowCloseBehavior === 'quit'
|
||||||
|
? '完全关闭'
|
||||||
|
: '每次询问'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={14} className={`custom-select-arrow ${closeBehaviorDropdownOpen ? 'rotate' : ''}`} />
|
||||||
|
</div>
|
||||||
|
<div className={`custom-select-dropdown ${closeBehaviorDropdownOpen ? 'open' : ''}`}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
value: 'ask' as const,
|
||||||
|
label: '每次询问',
|
||||||
|
successMessage: '已恢复关闭确认弹窗'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tray' as const,
|
||||||
|
label: '最小化到系统托盘',
|
||||||
|
successMessage: '关闭按钮已改为最小化到托盘'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'quit' as const,
|
||||||
|
label: '完全关闭',
|
||||||
|
successMessage: '关闭按钮已改为完全关闭'
|
||||||
|
}
|
||||||
|
].map(option => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={`custom-select-option ${windowCloseBehavior === option.value ? 'selected' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setWindowCloseBehavior(option.value)
|
||||||
|
setCloseBehaviorDropdownOpen(false)
|
||||||
|
await configService.setWindowCloseBehavior(option.value)
|
||||||
|
showMessage(option.successMessage, true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
{windowCloseBehavior === option.value && <Check size={14} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1102,12 +1164,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<span className="custom-select-value">
|
<span className="custom-select-value">
|
||||||
{notificationPosition === 'top-right' ? '右上角' :
|
{notificationPosition === 'top-right' ? '右上角' :
|
||||||
notificationPosition === 'bottom-right' ? '右下角' :
|
notificationPosition === 'bottom-right' ? '右下角' :
|
||||||
notificationPosition === 'top-left' ? '左上角' : '左下角'}
|
notificationPosition === 'top-left' ? '左上角' :
|
||||||
|
notificationPosition === 'top-center' ? '中间上方' : '左下角'}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={14} className={`custom-select-arrow ${positionDropdownOpen ? 'rotate' : ''}`} />
|
<ChevronDown size={14} className={`custom-select-arrow ${positionDropdownOpen ? 'rotate' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className={`custom-select-dropdown ${positionDropdownOpen ? 'open' : ''}`}>
|
<div className={`custom-select-dropdown ${positionDropdownOpen ? 'open' : ''}`}>
|
||||||
{[
|
{[
|
||||||
|
{ value: 'top-center', label: '中间上方' },
|
||||||
{ value: 'top-right', label: '右上角' },
|
{ value: 'top-right', label: '右上角' },
|
||||||
{ value: 'bottom-right', label: '右下角' },
|
{ value: 'bottom-right', label: '右下角' },
|
||||||
{ value: 'top-left', label: '左上角' },
|
{ value: 'top-left', label: '左上角' },
|
||||||
@@ -1117,7 +1181,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`}
|
className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`}
|
||||||
onClick={async () => {
|
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)
|
setNotificationPosition(val)
|
||||||
setPositionDropdownOpen(false)
|
setPositionDropdownOpen(false)
|
||||||
await configService.setNotificationPosition(val)
|
await configService.setNotificationPosition(val)
|
||||||
@@ -2130,14 +2194,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="wxid-dialog-list">
|
<div className="wxid-dialog-list">
|
||||||
{wxidOptions.map((opt) => (
|
{wxidOptions.map((opt) => (
|
||||||
<div
|
<div
|
||||||
key={opt.wxid}
|
key={opt.wxid}
|
||||||
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
|
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
|
||||||
onClick={() => handleSelectWxid(opt.wxid)}
|
onClick={() => handleSelectWxid(opt.wxid)}
|
||||||
>
|
>
|
||||||
<span className="wxid-id">{opt.wxid}</span>
|
<div className="wxid-profile-row">
|
||||||
<span className="wxid-date">最后修改 {new Date(opt.modifiedTime).toLocaleString()}</span>
|
{opt.avatarUrl ? (
|
||||||
</div>
|
<img src={opt.avatarUrl} alt="avatar" className="wxid-avatar" />
|
||||||
|
) : (
|
||||||
|
<div className="wxid-avatar-fallback"><UserRound size={18}/></div>
|
||||||
|
)}
|
||||||
|
<div className="wxid-info-col">
|
||||||
|
<span className="wxid-id">{opt.nickname || opt.wxid}</span>
|
||||||
|
{opt.nickname && <span className="wxid-date">{opt.wxid}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="wxid-date" style={{marginLeft: 'auto'}}>最后修改 {new Date(opt.modifiedTime).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="wxid-dialog-footer">
|
<div className="wxid-dialog-footer">
|
||||||
|
|||||||
@@ -488,6 +488,48 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.field-with-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const [imageAesKey, setImageAesKey] = useState('')
|
const [imageAesKey, setImageAesKey] = useState('')
|
||||||
const [cachePath, setCachePath] = useState('')
|
const [cachePath, setCachePath] = useState('')
|
||||||
const [wxid, setWxid] = useState('')
|
const [wxid, setWxid] = useState('')
|
||||||
const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([])
|
const [wxidOptions, setWxidOptions] = useState<Array<{
|
||||||
|
avatarUrl?: string;
|
||||||
|
nickname?: string;
|
||||||
|
wxid: string;
|
||||||
|
modifiedTime: number
|
||||||
|
}>>([])
|
||||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||||
const wxidSelectRef = useRef<HTMLDivElement>(null)
|
const wxidSelectRef = useRef<HTMLDivElement>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -688,22 +693,32 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
onChange={(e) => setWxid(e.target.value)}
|
onChange={(e) => setWxid(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{showWxidSelect && wxidOptions.length > 0 && (
|
{showWxidSelect && wxidOptions.length > 0 && (
|
||||||
<div className="wxid-dropdown">
|
<div className="wxid-dropdown">
|
||||||
{wxidOptions.map((opt) => (
|
{wxidOptions.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.wxid}
|
key={opt.wxid}
|
||||||
type="button"
|
type="button"
|
||||||
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
|
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setWxid(opt.wxid)
|
setWxid(opt.wxid)
|
||||||
setShowWxidSelect(false)
|
setShowWxidSelect(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="wxid-name">{opt.wxid}</span>
|
<div className="wxid-profile">
|
||||||
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span>
|
{opt.avatarUrl ? (
|
||||||
</button>
|
<img src={opt.avatarUrl} alt="avatar" className="wxid-avatar" />
|
||||||
))}
|
) : (
|
||||||
</div>
|
<div className="wxid-avatar-fallback"><UserRound size={14}/></div>
|
||||||
|
)}
|
||||||
|
<div className="wxid-info">
|
||||||
|
<span className="wxid-nickname">{opt.nickname || opt.wxid}</span>
|
||||||
|
{opt.nickname && <span className="wxid-sub">{opt.wxid}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
||||||
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||||
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
||||||
|
EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP: 'exportSessionMutualFriendsCacheMap',
|
||||||
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
||||||
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
||||||
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
||||||
@@ -62,6 +63,7 @@ export const CONFIG_KEYS = {
|
|||||||
NOTIFICATION_POSITION: 'notificationPosition',
|
NOTIFICATION_POSITION: 'notificationPosition',
|
||||||
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
||||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||||
|
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||||
|
|
||||||
// 词云
|
// 词云
|
||||||
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
|
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
|
||||||
@@ -85,6 +87,8 @@ export interface ExportDefaultMediaConfig {
|
|||||||
emojis: boolean
|
emojis: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||||
|
|
||||||
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
@@ -593,6 +597,34 @@ export interface ExportSnsUserPostCountsCacheItem {
|
|||||||
counts: Record<string, number>
|
counts: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExportSessionMutualFriendDirection = 'incoming' | 'outgoing' | 'bidirectional'
|
||||||
|
export type ExportSessionMutualFriendBehavior = 'likes' | 'comments' | 'both'
|
||||||
|
|
||||||
|
export interface ExportSessionMutualFriendCacheItem {
|
||||||
|
name: string
|
||||||
|
incomingLikeCount: number
|
||||||
|
incomingCommentCount: number
|
||||||
|
outgoingLikeCount: number
|
||||||
|
outgoingCommentCount: number
|
||||||
|
totalCount: number
|
||||||
|
latestTime: number
|
||||||
|
direction: ExportSessionMutualFriendDirection
|
||||||
|
behavior: ExportSessionMutualFriendBehavior
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionMutualFriendsCacheEntry {
|
||||||
|
count: number
|
||||||
|
items: ExportSessionMutualFriendCacheItem[]
|
||||||
|
loadedPosts: number
|
||||||
|
totalPosts: number | null
|
||||||
|
computedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionMutualFriendsCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
metrics: Record<string, ExportSessionMutualFriendsCacheEntry>
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnsPageOverviewCache {
|
export interface SnsPageOverviewCache {
|
||||||
totalPosts: number
|
totalPosts: number
|
||||||
totalFriends: number
|
totalFriends: number
|
||||||
@@ -852,6 +884,148 @@ export async function setExportSnsUserPostCountsCache(
|
|||||||
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
|
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeMutualFriendDirection = (value: unknown): ExportSessionMutualFriendDirection | null => {
|
||||||
|
if (value === 'incoming' || value === 'outgoing' || value === 'bidirectional') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeMutualFriendBehavior = (value: unknown): ExportSessionMutualFriendBehavior | null => {
|
||||||
|
if (value === 'likes' || value === 'comments' || value === 'both') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeExportSessionMutualFriendsCacheEntry = (raw: unknown): ExportSessionMutualFriendsCacheEntry | null => {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const count = Number(source.count)
|
||||||
|
const loadedPosts = Number(source.loadedPosts)
|
||||||
|
const computedAt = Number(source.computedAt)
|
||||||
|
const itemsRaw = Array.isArray(source.items) ? source.items : []
|
||||||
|
const totalPostsRaw = source.totalPosts
|
||||||
|
const totalPosts = totalPostsRaw === null || totalPostsRaw === undefined
|
||||||
|
? null
|
||||||
|
: Number(totalPostsRaw)
|
||||||
|
|
||||||
|
if (!Number.isFinite(count) || count < 0 || !Number.isFinite(loadedPosts) || loadedPosts < 0 || !Number.isFinite(computedAt) || computedAt < 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: ExportSessionMutualFriendCacheItem[] = []
|
||||||
|
for (const itemRaw of itemsRaw) {
|
||||||
|
if (!itemRaw || typeof itemRaw !== 'object') continue
|
||||||
|
const item = itemRaw as Record<string, unknown>
|
||||||
|
const name = String(item.name || '').trim()
|
||||||
|
const direction = normalizeMutualFriendDirection(item.direction)
|
||||||
|
const behavior = normalizeMutualFriendBehavior(item.behavior)
|
||||||
|
const incomingLikeCount = Number(item.incomingLikeCount)
|
||||||
|
const incomingCommentCount = Number(item.incomingCommentCount)
|
||||||
|
const outgoingLikeCount = Number(item.outgoingLikeCount)
|
||||||
|
const outgoingCommentCount = Number(item.outgoingCommentCount)
|
||||||
|
const totalCount = Number(item.totalCount)
|
||||||
|
const latestTime = Number(item.latestTime)
|
||||||
|
if (!name || !direction || !behavior) continue
|
||||||
|
if (
|
||||||
|
!Number.isFinite(incomingLikeCount) || incomingLikeCount < 0 ||
|
||||||
|
!Number.isFinite(incomingCommentCount) || incomingCommentCount < 0 ||
|
||||||
|
!Number.isFinite(outgoingLikeCount) || outgoingLikeCount < 0 ||
|
||||||
|
!Number.isFinite(outgoingCommentCount) || outgoingCommentCount < 0 ||
|
||||||
|
!Number.isFinite(totalCount) || totalCount < 0 ||
|
||||||
|
!Number.isFinite(latestTime) || latestTime < 0
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
name,
|
||||||
|
incomingLikeCount: Math.floor(incomingLikeCount),
|
||||||
|
incomingCommentCount: Math.floor(incomingCommentCount),
|
||||||
|
outgoingLikeCount: Math.floor(outgoingLikeCount),
|
||||||
|
outgoingCommentCount: Math.floor(outgoingCommentCount),
|
||||||
|
totalCount: Math.floor(totalCount),
|
||||||
|
latestTime: Math.floor(latestTime),
|
||||||
|
direction,
|
||||||
|
behavior
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: Math.floor(count),
|
||||||
|
items,
|
||||||
|
loadedPosts: Math.floor(loadedPosts),
|
||||||
|
totalPosts: totalPosts === null
|
||||||
|
? null
|
||||||
|
: (Number.isFinite(totalPosts) && totalPosts >= 0 ? Math.floor(totalPosts) : null),
|
||||||
|
computedAt: Math.floor(computedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportSessionMutualFriendsCache(scopeKey: string): Promise<ExportSessionMutualFriendsCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||||
|
const rawMetrics = (rawItem as Record<string, unknown>).metrics
|
||||||
|
if (!rawMetrics || typeof rawMetrics !== 'object') return null
|
||||||
|
|
||||||
|
const metrics: Record<string, ExportSessionMutualFriendsCacheEntry> = {}
|
||||||
|
for (const [sessionIdRaw, metricRaw] of Object.entries(rawMetrics as Record<string, unknown>)) {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
const metric = normalizeExportSessionMutualFriendsCacheEntry(metricRaw)
|
||||||
|
if (!metric) continue
|
||||||
|
metrics[sessionId] = metric
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||||
|
metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSessionMutualFriendsCache(
|
||||||
|
scopeKey: string,
|
||||||
|
metrics: Record<string, ExportSessionMutualFriendsCacheEntry>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const normalized: Record<string, ExportSessionMutualFriendsCacheEntry> = {}
|
||||||
|
for (const [sessionIdRaw, metricRaw] of Object.entries(metrics || {})) {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
const metric = normalizeExportSessionMutualFriendsCacheEntry(metricRaw)
|
||||||
|
if (!metric) continue
|
||||||
|
normalized[sessionId] = metric
|
||||||
|
}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
metrics: normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearExportSessionMutualFriendsCache(scopeKey: string): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||||
|
if (!current || typeof current !== 'object') return
|
||||||
|
const map = { ...(current as Record<string, unknown>) }
|
||||||
|
if (!(scopeKey in map)) return
|
||||||
|
delete map[scopeKey]
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
||||||
if (!scopeKey) return null
|
if (!scopeKey) return null
|
||||||
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
||||||
@@ -1188,6 +1362,16 @@ export async function setNotificationFilterList(list: string[]): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
|
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWindowCloseBehavior(): Promise<WindowCloseBehavior> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
|
||||||
|
if (value === 'tray' || value === 'quit') return value
|
||||||
|
return 'ask'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setWindowCloseBehavior(behavior: WindowCloseBehavior): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR, behavior)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取词云排除词列表
|
// 获取词云排除词列表
|
||||||
export async function getWordCloudExcludeWords(): Promise<string[]> {
|
export async function getWordCloudExcludeWords(): Promise<string[]> {
|
||||||
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)
|
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)
|
||||||
|
|||||||
@@ -81,10 +81,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
setMessages: (messages) => set({ messages }),
|
setMessages: (messages) => set({ messages }),
|
||||||
|
|
||||||
appendMessages: (newMessages, prepend = false) => set((state) => {
|
appendMessages: (newMessages, prepend = false) => set((state) => {
|
||||||
// 强制去重逻辑
|
|
||||||
const getMsgKey = (m: Message) => {
|
const getMsgKey = (m: Message) => {
|
||||||
if (m.localId && m.localId > 0) return `l:${m.localId}`
|
if (m.messageKey) return m.messageKey
|
||||||
return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}`
|
return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}`
|
||||||
}
|
}
|
||||||
const currentMessages = state.messages || []
|
const currentMessages = state.messages || []
|
||||||
const existingKeys = new Set(currentMessages.map(getMsgKey))
|
const existingKeys = new Set(currentMessages.map(getMsgKey))
|
||||||
|
|||||||
18
src/types/electron.d.ts
vendored
18
src/types/electron.d.ts
vendored
@@ -14,6 +14,8 @@ export interface ElectronAPI {
|
|||||||
isMaximized: () => Promise<boolean>
|
isMaximized: () => Promise<boolean>
|
||||||
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void
|
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void
|
||||||
close: () => void
|
close: () => void
|
||||||
|
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => () => void
|
||||||
|
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise<boolean>
|
||||||
openAgreementWindow: () => Promise<boolean>
|
openAgreementWindow: () => Promise<boolean>
|
||||||
completeOnboarding: () => Promise<boolean>
|
completeOnboarding: () => Promise<boolean>
|
||||||
openOnboardingWindow: () => Promise<boolean>
|
openOnboardingWindow: () => Promise<boolean>
|
||||||
@@ -183,12 +185,14 @@ export interface ElectronAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
|
nextOffset?: number;
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
|
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
messages?: Message[]
|
messages?: Message[]
|
||||||
hasMore?: boolean
|
hasMore?: boolean
|
||||||
|
nextOffset?: number
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
|
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
|
||||||
@@ -219,6 +223,7 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
getMyAvatarUrl: () => Promise<{ success: boolean; avatarUrl?: string; error?: string }>
|
getMyAvatarUrl: () => Promise<{ success: boolean; avatarUrl?: string; error?: string }>
|
||||||
downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||||
|
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => Promise<{ success: boolean; messages?: Message[]; error?: string }>
|
||||||
close: () => Promise<boolean>
|
close: () => Promise<boolean>
|
||||||
getSessionDetail: (sessionId: string) => Promise<{
|
getSessionDetail: (sessionId: string) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -489,6 +494,19 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
error?: string
|
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<{
|
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
count?: number
|
count?: number
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface ChatSession {
|
|||||||
selfWxid?: string // Helper field to avoid extra API calls
|
selfWxid?: string // Helper field to avoid extra API calls
|
||||||
isFolded?: boolean // 是否已折叠进"折叠的群聊"
|
isFolded?: boolean // 是否已折叠进"折叠的群聊"
|
||||||
isMuted?: boolean // 是否开启免打扰
|
isMuted?: boolean // 是否开启免打扰
|
||||||
|
alias?: string // 微信号
|
||||||
|
matchedField?: 'wxid' | 'alias' | 'name' // 搜索匹配的字段
|
||||||
}
|
}
|
||||||
|
|
||||||
// 联系人
|
// 联系人
|
||||||
@@ -41,6 +43,7 @@ export interface ContactInfo {
|
|||||||
|
|
||||||
// 消息
|
// 消息
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
messageKey: string
|
||||||
localId: number
|
localId: number
|
||||||
serverId: number
|
serverId: number
|
||||||
localType: number
|
localType: number
|
||||||
@@ -105,6 +108,10 @@ export interface Message {
|
|||||||
// 聊天记录
|
// 聊天记录
|
||||||
chatRecordTitle?: string // 聊天记录标题
|
chatRecordTitle?: string // 聊天记录标题
|
||||||
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
||||||
|
_db_path?: string
|
||||||
|
// 运行时补充的发送者信息
|
||||||
|
senderDisplayName?: string
|
||||||
|
senderAvatarUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 聊天记录项
|
// 聊天记录项
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
export class AvatarLoadQueue {
|
export class AvatarLoadQueue {
|
||||||
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
|
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
|
||||||
private loading = new Map<string, Promise<void>>()
|
private loading = new Map<string, Promise<void>>()
|
||||||
|
private failed = new Map<string, number>()
|
||||||
private activeCount = 0
|
private activeCount = 0
|
||||||
private readonly maxConcurrent = 3
|
private readonly maxConcurrent = 3
|
||||||
private readonly delayBetweenBatches = 10
|
private readonly delayBetweenBatches = 10
|
||||||
|
private readonly failedTtlMs = 10 * 60 * 1000
|
||||||
|
|
||||||
private static instance: AvatarLoadQueue
|
private static instance: AvatarLoadQueue
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ export class AvatarLoadQueue {
|
|||||||
|
|
||||||
async enqueue(url: string): Promise<void> {
|
async enqueue(url: string): Promise<void> {
|
||||||
if (!url) return Promise.resolve()
|
if (!url) return Promise.resolve()
|
||||||
|
if (this.hasFailed(url)) {
|
||||||
|
return Promise.reject(new Error(`Failed: ${url}`))
|
||||||
|
}
|
||||||
|
|
||||||
// 核心修复:防止重复并发请求同一个 URL
|
// 核心修复:防止重复并发请求同一个 URL
|
||||||
const existingPromise = this.loading.get(url)
|
const existingPromise = this.loading.get(url)
|
||||||
@@ -31,13 +36,40 @@ export class AvatarLoadQueue {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.loading.set(url, loadPromise)
|
this.loading.set(url, loadPromise)
|
||||||
loadPromise.finally(() => {
|
void loadPromise.then(
|
||||||
this.loading.delete(url)
|
() => {
|
||||||
})
|
this.loading.delete(url)
|
||||||
|
this.clearFailed(url)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.loading.delete(url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return loadPromise
|
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() {
|
private async processQueue() {
|
||||||
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
|
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
|
||||||
return
|
return
|
||||||
@@ -49,13 +81,16 @@ export class AvatarLoadQueue {
|
|||||||
this.activeCount++
|
this.activeCount++
|
||||||
|
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
|
img.referrerPolicy = 'no-referrer'
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
this.activeCount--
|
this.activeCount--
|
||||||
|
this.clearFailed(task.url)
|
||||||
task.resolve()
|
task.resolve()
|
||||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||||
}
|
}
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
this.activeCount--
|
this.activeCount--
|
||||||
|
this.markFailed(task.url)
|
||||||
task.reject(new Error(`Failed: ${task.url}`))
|
task.reject(new Error(`Failed: ${task.url}`))
|
||||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||||
}
|
}
|
||||||
@@ -67,6 +102,7 @@ export class AvatarLoadQueue {
|
|||||||
clear() {
|
clear() {
|
||||||
this.queue = []
|
this.queue = []
|
||||||
this.loading.clear()
|
this.loading.clear()
|
||||||
|
this.failed.clear()
|
||||||
this.activeCount = 0
|
this.activeCount = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user