Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77adc744e0 | ||
|
|
0adb064681 | ||
|
|
8e40487e08 | ||
|
|
d0ae9e6019 | ||
|
|
49684f3365 | ||
|
|
33aaf4d558 | ||
|
|
5ccabb3898 | ||
|
|
7db845ac7e | ||
|
|
9348c5ce4a | ||
|
|
f626d89a1a | ||
|
|
95e517eca5 | ||
|
|
e93e4554d7 | ||
|
|
9ae0c6cc47 | ||
|
|
59e609b08a | ||
|
|
9de7dcde3b | ||
|
|
cb3c2855c0 | ||
|
|
dca88e5db3 | ||
|
|
1a9f27488b | ||
|
|
3b64e18b5e | ||
|
|
554dccdb21 | ||
|
|
c674dcbc4a | ||
|
|
0f65b96da1 | ||
|
|
8488e81bb5 | ||
|
|
430a36333b | ||
|
|
474250c6c7 |
@@ -6,9 +6,12 @@ VITE_DB_KEY=
|
|||||||
VITE_AUTO_LOGIN=false
|
VITE_AUTO_LOGIN=false
|
||||||
|
|
||||||
# AI API Configuration (Optional, can be entered in UI)
|
# AI API Configuration (Optional, can be entered in UI)
|
||||||
|
# 注意:发布版本不再自动读取以下环境变量。
|
||||||
|
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
|
||||||
|
# 然后在「设置 → AI 模型」里手动完成"添加供应商"流程。
|
||||||
VITE_DEEPSEEK_API_KEY=
|
VITE_DEEPSEEK_API_KEY=
|
||||||
VITE_AI_BASE_URL=https://api.deepseek.com
|
VITE_AI_BASE_URL=https://api.deepseek.com
|
||||||
VITE_AI_MODEL=deepseek-v4-flash
|
VITE_AI_MODEL=deepseek-chat
|
||||||
|
|
||||||
# Message types to filter out (comma separated). Empty means show all message types.
|
# Message types to filter out (comma separated). Empty means show all message types.
|
||||||
VITE_FILTER_MSG_TYPES=
|
VITE_FILTER_MSG_TYPES=
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ docs/design/
|
|||||||
docs/ui-redesign-plan.md
|
docs/ui-redesign-plan.md
|
||||||
docs/ui-redesign-spec.md
|
docs/ui-redesign-spec.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
findings.md
|
||||||
|
progress.md
|
||||||
|
task_plan.md
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# WechatExplorer
|
# WechatExplorer
|
||||||
|
|
||||||
macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
macOS / Windows 微信聊天记录查看与 AI 分析工具。
|
||||||
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
|
是一个基于 Electron + React + TypeScript 开发的本地微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供 AI 智能检索、群聊总结和多格式聊天记录导出功能。
|
||||||
|
|
||||||
## 项目说明
|
## 项目说明
|
||||||
|
|
||||||
@@ -9,12 +9,13 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
|||||||
|
|
||||||
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||||
|
|
||||||
> 当前版本:`v2.1.2`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
|
> macOS 支持相对稳定;Windows 已初步支持 但因聊天记录大/机械硬盘等问题 会有所卡顿,仍在持续兼容不同微信版本与本地目录结构。
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
||||||
- **全局搜索**: 快速搜索聊天内容。
|
- **AI 智能检索**: 支持全局搜索、指定会话搜索和自然语言提问,帮助快速定位聊天主题与相关证据。
|
||||||
|
- **消息防撤回**: 可在设置中开启,归档并高亮查看对方已撤回消息。
|
||||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||||
@@ -28,7 +29,8 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
|||||||
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||||
- **词云/关键词**: 可视化展示群聊关键词。
|
- **词云/关键词**: 可视化展示群聊关键词。
|
||||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||||
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
|
- **数据导出**: 支持将聊天记录导出为 HTML、CSV、JSON 和 Markdown,支持按时间范围导出并打开文件所在文件夹。
|
||||||
|
- **诊断日志**: 可在设置的高级选项中控制诊断日志,方便排查运行异常。
|
||||||
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
||||||
|
|
||||||
## 📸 预览
|
## 📸 预览
|
||||||
@@ -45,48 +47,31 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
|||||||
|
|
||||||
<img src="./public/software-1.png" alt="AI 群聊日报页面" />
|
<img src="./public/software-1.png" alt="AI 群聊日报页面" />
|
||||||
|
|
||||||
|
### 检索界面
|
||||||
|
|
||||||
|
<img src="./public/ai-search.png" alt="检索页面" />
|
||||||
|
|
||||||
### 本地 API 与 Reader Skill
|
### 本地 API 与 Reader Skill
|
||||||
|
|
||||||
<img src="./public/software-2.png" alt="本地 API 与 Reader Skill 页面" />
|
<img src="./public/software-2.png" alt="本地 API 与 Reader Skill 页面" />
|
||||||
|
|
||||||
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||||
|
|
||||||
## 📦 安装说明
|
## 📖 使用方法
|
||||||
|
|
||||||
### macOS 版本已测 4.1.8
|
安装、获取数据库密钥、连接微信数据及常见问题,请查看:
|
||||||
|
|
||||||
> 微信 macOS `4.1.8.100` 下载地址:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100)
|
### [👉 WechatExplorer 完整使用教程](./docs/user-guide/getting-started.md)
|
||||||
|
|
||||||
1. 下载 Releases 中的 `xxx.dmg` 文件。
|
教程包含 macOS 与 Windows 的分步截图,以及数据目录、SIP、图片解密密钥和自动获取失败的排查方法。
|
||||||
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
|
|
||||||
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
|
|
||||||
`系统设置 -> 隐私与安全性 -> 仍要打开`。
|
|
||||||
4. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥
|
|
||||||
|
|
||||||
### Windows 版本已测 4.1.9
|
> 微信 4.0+ 在 macOS / Windows 上已支持部分能力,目前仍在持续适配。如需其他成熟方案,也可参考 [WeFlow](https://github.com/hicccc77/WeFlow) 和 [Chatlog](https://github.com/sjzar/chatlog)。
|
||||||
|
|
||||||
> 微信 Windows `4.1.9.57` 下载地址:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
|
## 🛠️ 开发配置(可选)
|
||||||
|
|
||||||
1. 下载 Releases 中的 `xxx-setup.exe` 安装包。
|
本地开发需要 Node.js(推荐 v16+)和 pnpm 7。
|
||||||
2. 双击安装,可按安装向导选择安装目录。
|
|
||||||
3. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥。
|
|
||||||
|
|
||||||
> Windows 支持仍处于初步阶段。软件会有些卡顿, 如果自动获取密钥失败,请先确认微信客户端已登录并保持运行;不同微信安装路径、数据目录和权限环境可能仍需要继续适配。
|
### 环境变量
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 使用前置要求
|
|
||||||
|
|
||||||
- **微信版本**:
|
|
||||||
- 微信 4.0+: macOS / Windows 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
|
|
||||||
- **macOS 自动获取密钥**: 需要先关闭 SIP,操作方式见 [macOS 关闭 SIP 教程](./docs/mac-disable-sip.md);关闭 SIP 会降低系统安全性,使用完成后建议重新开启。
|
|
||||||
- 如无法获取本地数据库密码,则无法使用当前项目
|
|
||||||
- Node.js (推荐 v16+)
|
|
||||||
- pnpm@7
|
|
||||||
- 解密后的微信数据库文件 (`.db`) 和对应的密钥
|
|
||||||
- AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等)
|
|
||||||
|
|
||||||
### 环境变量配置 (.env)
|
|
||||||
|
|
||||||
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
|
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
|
||||||
|
|
||||||
@@ -100,20 +85,6 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
|||||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||||
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||||
|
|
||||||
#### 图片解密密钥说明
|
|
||||||
|
|
||||||
微信 4.0+ 的图片以 `.dat` 文件存储,需要密钥解密:
|
|
||||||
|
|
||||||
- **XOR Key**: 单字节 hex 值(如 `0x40`),用于简单的字节异或解密
|
|
||||||
- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密
|
|
||||||
|
|
||||||
这两个密钥可以通过以下方式获取:
|
|
||||||
|
|
||||||
1. 在应用登录界面点击“自动获取密钥”
|
|
||||||
2. 从 WeFlow/Chatlog 设置中导出
|
|
||||||
3. 在软件“设置 -> 图片解密密钥”中自动获取或手动填写
|
|
||||||
4. 如自动获取失败,可手动粘贴已获取的密钥
|
|
||||||
|
|
||||||
## 🤖 AI 集成(本地 HTTP API)
|
## 🤖 AI 集成(本地 HTTP API)
|
||||||
|
|
||||||
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
|
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
|
||||||
@@ -192,6 +163,16 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
|||||||
|
|
||||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=Wxw-Gu%2FWechatExplorer&type=date&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&theme=dark&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
## 🔗 参考致谢
|
## 🔗 参考致谢
|
||||||
|
|
||||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# WechatExplorer 使用教程
|
||||||
|
|
||||||
|
本文介绍如何安装 WechatExplorer、自动获取微信数据库密钥,并完成首次连接。
|
||||||
|
|
||||||
|
## 1. 使用前准备
|
||||||
|
|
||||||
|
### 支持的版本
|
||||||
|
|
||||||
|
| 系统 | 已测试的微信版本 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| macOS | `4.1.8.100` | 支持相对稳定;自动获取密钥前需要关闭 SIP |
|
||||||
|
| Windows | `4.1.9.57` | 已初步支持;不同安装路径和数据目录可能仍需手动调整 |
|
||||||
|
|
||||||
|
- macOS 微信下载:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100)
|
||||||
|
- Windows 微信下载:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
|
||||||
|
- WechatExplorer 下载:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> WechatExplorer 必须取得本机微信数据库密钥才能读取聊天记录。请仅处理你有权访问的数据。
|
||||||
|
|
||||||
|
### macOS:关闭 SIP
|
||||||
|
|
||||||
|
macOS 自动获取密钥前需要关闭 SIP,具体操作见 [macOS 关闭 SIP 教程](../mac-disable-sip.md)。
|
||||||
|
|
||||||
|
关闭 SIP 会降低系统安全性。建议了解风险后再操作,并在不再需要自动获取密钥时重新开启。
|
||||||
|
|
||||||
|
## 2. 安装 WechatExplorer
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
1. 从 Releases 下载 `.dmg` 文件。
|
||||||
|
2. 打开 DMG,将 WechatExplorer 拖入“应用程序”文件夹。
|
||||||
|
3. 如果系统提示“无法打开,因为开发者无法验证”,请前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||||
|
4. 如果系统提示应用已损坏,在终端执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xattr -cr "/Applications/WechatExplorer.app"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
1. 从 Releases 下载 `-setup.exe` 安装包。
|
||||||
|
2. 双击安装,并按安装向导完成操作。
|
||||||
|
|
||||||
|
## 3. 自动获取密钥
|
||||||
|
|
||||||
|
### 第一步:确认微信数据目录
|
||||||
|
|
||||||
|
启动 WechatExplorer 后,先检查页面中的“存储路径”是否正确。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Windows 当前不会扫描二级目录。如果没有正确识别微信数据,请进入“设置”,手动选择微信数据所在目录。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 第二步:让微信停留在登录页面
|
||||||
|
|
||||||
|
如果微信已经登录,请先退出登录;然后重新打开微信,让它停留在未登录页面,暂时不要点击登录。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 第三步:开始获取密钥
|
||||||
|
|
||||||
|
返回 WechatExplorer,点击“自动获取密钥”。
|
||||||
|
|
||||||
|
- **Windows**:看到“Hook 注入成功”后,返回微信完成登录。
|
||||||
|
- **macOS**:系统会弹出授权提示,请输入当前 macOS 用户密码并完成授权,然后返回微信完成登录。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 点击“自动获取密钥”前,微信必须停留在登录页面。WechatExplorer 提示可以登录后,再回到微信完成登录。
|
||||||
|
|
||||||
|
### 第四步:完成连接
|
||||||
|
|
||||||
|
如果系统环境和微信版本符合要求,WechatExplorer 会自动填写数据库密钥并连接数据库。连接成功后即可查看、搜索和导出聊天记录,也可以配置 AI 服务生成群聊总结。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 4. 图片解密密钥
|
||||||
|
|
||||||
|
微信 4.0 及以上版本的图片通常以 `.dat` 文件存储,显示图片还需要:
|
||||||
|
|
||||||
|
- **XOR Key**:单字节十六进制值,例如 `0x40`。
|
||||||
|
- **AES Key**:用于 AES-128-ECB 解密的 16 字符字符串。
|
||||||
|
|
||||||
|
可以通过以下方式配置:
|
||||||
|
|
||||||
|
1. 使用首次连接页面的“自动获取密钥”。
|
||||||
|
2. 在“设置 → 图片解密密钥”中自动获取或手动填写。
|
||||||
|
3. 从 WeFlow 或 Chatlog 的设置中导出后手动填写。
|
||||||
|
|
||||||
|
数据库连接成功但图片无法显示时,请优先检查这两项密钥。
|
||||||
|
|
||||||
|
## 5. 常见问题
|
||||||
|
|
||||||
|
### 自动获取密钥失败
|
||||||
|
|
||||||
|
请依次确认:
|
||||||
|
|
||||||
|
1. 微信版本是否与上方已测试版本一致。
|
||||||
|
2. 点击“自动获取密钥”时,微信是否停留在未登录页面。
|
||||||
|
3. 微信数据目录是否正确;Windows 用户尤其需要检查是否多选或少选了一层目录。
|
||||||
|
4. macOS 是否已按教程关闭 SIP,并完成系统授权。
|
||||||
|
5. 微信和 WechatExplorer 是否都保持运行。
|
||||||
|
|
||||||
|
仍然失败时,可以切换到“手动输入”,粘贴从其他兼容工具中取得的数据库密钥。
|
||||||
|
|
||||||
|
### Windows 使用时卡顿
|
||||||
|
|
||||||
|
Windows 支持仍处于初步阶段,不同微信版本、安装路径、数据目录和权限环境可能存在差异。建议优先使用上方已测试的微信版本。
|
||||||
|
|
||||||
|
### 数据会上传吗?
|
||||||
|
|
||||||
|
聊天数据库在本机读取和处理。只有使用 AI 总结功能时,相关聊天内容才会按你配置的模型服务发送;是否启用以及使用哪个服务由你决定。
|
||||||
|
|
||||||
|
## 6. 下一步
|
||||||
|
|
||||||
|
- 在应用“设置”中填写兼容 OpenAI API 的模型服务和 API Key,使用 AI 总结功能。
|
||||||
|
- 在应用的 **API** 页面安装 Reader Skill,让 Codex 或 Claude Code 读取和总结本地群聊。
|
||||||
|
- 本地 API 的端点和调试方法见项目 [README](../../README.md#ai-集成本地-http-api)。
|
||||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 214 KiB |
@@ -68,4 +68,4 @@ publish:
|
|||||||
provider: github
|
provider: github
|
||||||
owner: Wxw-Gu
|
owner: Wxw-Gu
|
||||||
repo: WechatExplorer
|
repo: WechatExplorer
|
||||||
releaseType: draft
|
releaseType: release
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"use strict";
|
||||||
|
const electron = require("electron");
|
||||||
|
const preload = require("@electron-toolkit/preload");
|
||||||
|
const api = {
|
||||||
|
writeAppLog: (entry) => electron.ipcRenderer.invoke("app-log:write", entry),
|
||||||
|
getAppLogPath: () => electron.ipcRenderer.invoke("app-log:getPath"),
|
||||||
|
revealAppLog: () => electron.ipcRenderer.invoke("app-log:reveal"),
|
||||||
|
getAppUpdateState: () => electron.ipcRenderer.invoke("app-update:getState"),
|
||||||
|
checkAppUpdate: () => electron.ipcRenderer.invoke("app-update:check"),
|
||||||
|
downloadAppUpdate: () => electron.ipcRenderer.invoke("app-update:download"),
|
||||||
|
installAppUpdate: () => electron.ipcRenderer.invoke("app-update:install"),
|
||||||
|
onAppUpdateState: (callback) => {
|
||||||
|
const listener = (_event, state) => callback(state);
|
||||||
|
electron.ipcRenderer.on("app-update:state", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("app-update:state", listener);
|
||||||
|
},
|
||||||
|
getCacheSummary: () => electron.ipcRenderer.invoke("cache:getSummary"),
|
||||||
|
clearCache: (scope) => electron.ipcRenderer.invoke("cache:clear", scope),
|
||||||
|
initDb: (key) => electron.ipcRenderer.invoke("db:init", key),
|
||||||
|
getBootstrapCache: () => electron.ipcRenderer.invoke("db:getBootstrapCache"),
|
||||||
|
getStartupCache: () => electron.ipcRenderer.invoke("db:getStartupCache"),
|
||||||
|
getContacts: (filter) => electron.ipcRenderer.invoke("db:getContacts", filter),
|
||||||
|
getContactAvatars: (usernames) => electron.ipcRenderer.invoke("db:getContactAvatars", usernames),
|
||||||
|
getCachedMessages: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessages", userMd5, startTime, endTime),
|
||||||
|
getCachedMessagePage: (userMd5, startTime, endTime) => electron.ipcRenderer.invoke("db:getCachedMessagePage", userMd5, startTime, endTime),
|
||||||
|
getMessages: (userMd5, startTime, endTime, options) => electron.ipcRenderer.invoke("db:getMessages", userMd5, startTime, endTime, options),
|
||||||
|
getGroupSnapshot: (userMd5) => electron.ipcRenderer.invoke("db:getGroupSnapshot", userMd5),
|
||||||
|
search: (keyword) => electron.ipcRenderer.invoke("db:search", keyword),
|
||||||
|
aiChat: (messages, options) => electron.ipcRenderer.invoke("ai:chat", messages, options),
|
||||||
|
listAIProviders: () => electron.ipcRenderer.invoke("ai:listProviders"),
|
||||||
|
getAIRuntimeConfig: () => electron.ipcRenderer.invoke("ai:getRuntimeConfig"),
|
||||||
|
saveAIProvider: (provider) => electron.ipcRenderer.invoke("ai:saveProvider", provider),
|
||||||
|
deleteAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:deleteProvider", providerId),
|
||||||
|
setDefaultAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:setDefaultProvider", providerId),
|
||||||
|
testAIProvider: (providerId) => electron.ipcRenderer.invoke("ai:testProvider", providerId),
|
||||||
|
testAIVision: (request) => electron.ipcRenderer.invoke("ai:testVision", request),
|
||||||
|
migrateLegacyAIConfig: (config) => electron.ipcRenderer.invoke("ai:migrateLegacy", config),
|
||||||
|
copyImage: (base64String) => electron.ipcRenderer.invoke("copy-image", base64String),
|
||||||
|
getVoiceData: (sessionId, localId, createTime, svrId) => electron.ipcRenderer.invoke("db:getVoiceData", sessionId, localId, createTime, svrId),
|
||||||
|
parseMessage: (content, messageType) => electron.ipcRenderer.invoke("db:parseMessage", content, messageType),
|
||||||
|
getImage: (imageMd5, imageDatNameOrThumb, sessionId, options) => electron.ipcRenderer.invoke("db:getImage", imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||||
|
getVideo: (hashes) => electron.ipcRenderer.invoke("db:getVideo", hashes),
|
||||||
|
getSticker: (cdnUrl, md5) => electron.ipcRenderer.invoke("db:getSticker", cdnUrl, md5),
|
||||||
|
startExport: (request) => electron.ipcRenderer.invoke("export:start", request),
|
||||||
|
cancelExport: (jobId) => electron.ipcRenderer.invoke("export:cancel", jobId),
|
||||||
|
revealExport: (path) => electron.ipcRenderer.invoke("export:reveal", path),
|
||||||
|
onExportProgress: (callback) => {
|
||||||
|
const listener = (_event, progress) => callback(progress);
|
||||||
|
electron.ipcRenderer.on("export:progress", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("export:progress", listener);
|
||||||
|
},
|
||||||
|
exportGroupReport: (request) => electron.ipcRenderer.invoke("report:export", request),
|
||||||
|
listGeneratedReports: () => electron.ipcRenderer.invoke("report:listGenerated"),
|
||||||
|
saveGeneratedReport: (request) => electron.ipcRenderer.invoke("report:saveGenerated", request),
|
||||||
|
deleteGeneratedReport: (reportId) => electron.ipcRenderer.invoke("report:deleteGenerated", reportId),
|
||||||
|
revealGroupReport: (filePath) => electron.ipcRenderer.invoke("report:reveal", filePath),
|
||||||
|
getSavedDbKey: () => electron.ipcRenderer.invoke("key:getSavedDbKey"),
|
||||||
|
getDatabaseKeyEnvironment: () => electron.ipcRenderer.invoke("key:getEnvironment"),
|
||||||
|
readDatabaseKeyClipboard: () => electron.ipcRenderer.invoke("key:readClipboardDbKey"),
|
||||||
|
autoGetDbKey: (options) => electron.ipcRenderer.invoke("key:autoGetDbKey", options),
|
||||||
|
autoGetImageKey: (options) => electron.ipcRenderer.invoke("key:autoGetImageKey", options),
|
||||||
|
getImageKeyConfig: () => electron.ipcRenderer.invoke("image:getConfig"),
|
||||||
|
getImageDecryptionStatus: () => electron.ipcRenderer.invoke("image:getStatus"),
|
||||||
|
saveImageKeyConfig: (request) => electron.ipcRenderer.invoke("image:saveConfig", request),
|
||||||
|
testImageDecryption: (request) => electron.ipcRenderer.invoke("image:testConfig", request),
|
||||||
|
clearImageKeyConfig: () => electron.ipcRenderer.invoke("image:clearConfig"),
|
||||||
|
pasteAndSaveDbKey: () => electron.ipcRenderer.invoke("key:pasteAndSaveDbKey"),
|
||||||
|
saveDbKey: (key) => electron.ipcRenderer.invoke("key:saveDbKey", key),
|
||||||
|
clearSavedDbKey: () => electron.ipcRenderer.invoke("key:clearSavedDbKey"),
|
||||||
|
onWcdbChange: (callback) => {
|
||||||
|
const listener = (_event, payload) => callback(payload);
|
||||||
|
electron.ipcRenderer.on("wcdb-change", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("wcdb-change", listener);
|
||||||
|
},
|
||||||
|
onDbKeyStatus: (callback) => {
|
||||||
|
const listener = (_event, payload) => callback(payload);
|
||||||
|
electron.ipcRenderer.on("key:dbKeyStatus", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("key:dbKeyStatus", listener);
|
||||||
|
},
|
||||||
|
onImageKeyStatus: (callback) => {
|
||||||
|
const listener = (_event, payload) => callback(payload);
|
||||||
|
electron.ipcRenderer.on("key:imageKeyStatus", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("key:imageKeyStatus", listener);
|
||||||
|
},
|
||||||
|
getSettings: () => electron.ipcRenderer.invoke("settings:get"),
|
||||||
|
setSettings: (patch) => electron.ipcRenderer.invoke("settings:set", patch),
|
||||||
|
getSelf: () => electron.ipcRenderer.invoke("settings:getSelf"),
|
||||||
|
testConnection: (key, accountRoot) => electron.ipcRenderer.invoke("db:testConnection", key, accountRoot),
|
||||||
|
reopenWithRoot: (accountRoot) => electron.ipcRenderer.invoke("db:reopenWithRoot", accountRoot),
|
||||||
|
selectDbRoot: () => electron.ipcRenderer.invoke("settings:selectDbRoot"),
|
||||||
|
openAccountRoot: () => electron.ipcRenderer.invoke("settings:openAccountRoot"),
|
||||||
|
disconnectDb: (options) => electron.ipcRenderer.invoke("db:disconnect", options),
|
||||||
|
apiStatus: () => electron.ipcRenderer.invoke("api:getStatus"),
|
||||||
|
apiStart: (host, port) => electron.ipcRenderer.invoke("api:start", host, port),
|
||||||
|
apiStop: () => electron.ipcRenderer.invoke("api:stop"),
|
||||||
|
apiToggle: (enabled) => electron.ipcRenderer.invoke("api:toggle", enabled),
|
||||||
|
getReaderSkillStatus: () => electron.ipcRenderer.invoke("api:skillStatus"),
|
||||||
|
readReaderSkill: () => electron.ipcRenderer.invoke("api:readSkill"),
|
||||||
|
revealReaderSkill: () => electron.ipcRenderer.invoke("api:revealSkill"),
|
||||||
|
openReaderSkillGithub: () => electron.ipcRenderer.invoke("api:openSkillGithub"),
|
||||||
|
testLocalApiRequest: (request) => electron.ipcRenderer.invoke("api:testLocalRequest", request),
|
||||||
|
copyText: (text) => electron.ipcRenderer.invoke("api:copyText", text),
|
||||||
|
// ============================================================
|
||||||
|
// AI 图片理解基础设施(ImageInsightService)
|
||||||
|
// ============================================================
|
||||||
|
imageListCandidates: (query) => electron.ipcRenderer.invoke("image:listCandidates", query),
|
||||||
|
imageAnalyze: (request) => electron.ipcRenderer.invoke("image:analyze", request),
|
||||||
|
getImageInsight: (imageHash) => electron.ipcRenderer.invoke("image:getInsight", imageHash),
|
||||||
|
listImageInsights: (sessionId, limit) => electron.ipcRenderer.invoke("image:listInsights", sessionId, limit),
|
||||||
|
getAgentHubStatus: () => electron.ipcRenderer.invoke("agent-hub:getStatus"),
|
||||||
|
getAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:getLogs"),
|
||||||
|
clearAgentHubLogs: () => electron.ipcRenderer.invoke("agent-hub:clearLogs"),
|
||||||
|
startAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:startLogin"),
|
||||||
|
cancelAgentHubLogin: () => electron.ipcRenderer.invoke("agent-hub:cancelLogin"),
|
||||||
|
reconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:reconnect"),
|
||||||
|
disconnectAgentHub: () => electron.ipcRenderer.invoke("agent-hub:disconnect"),
|
||||||
|
selectAgentHubTestImage: () => electron.ipcRenderer.invoke("agent-hub:selectTestImage"),
|
||||||
|
onAgentHubStatus: (callback) => {
|
||||||
|
const listener = (_event, status) => callback(status);
|
||||||
|
electron.ipcRenderer.on("agent-hub:status", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("agent-hub:status", listener);
|
||||||
|
},
|
||||||
|
onAgentHubLog: (callback) => {
|
||||||
|
const listener = (_event, entry) => callback(entry);
|
||||||
|
electron.ipcRenderer.on("agent-hub:log", listener);
|
||||||
|
return () => electron.ipcRenderer.removeListener("agent-hub:log", listener);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (process.contextIsolated) {
|
||||||
|
try {
|
||||||
|
electron.contextBridge.exposeInMainWorld("electron", preload.electronAPI);
|
||||||
|
electron.contextBridge.exposeInMainWorld("api", api);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.electron = preload.electronAPI;
|
||||||
|
window.api = api;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wechatexplorer",
|
"name": "wechatexplorer",
|
||||||
"version": "2.1.4",
|
"version": "2.1.5",
|
||||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"wechat",
|
"wechat",
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@koromix/koffi-win32-x64": "3.1.0",
|
"@koromix/koffi-win32-x64": "3.1.0",
|
||||||
"@tanstack/react-virtual": "^3.14.6",
|
"@tanstack/react-virtual": "^3.14.6",
|
||||||
|
"electron-updater": "^6.6.2",
|
||||||
"fs-extra": "^11.3.2",
|
"fs-extra": "^11.3.2",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
"jsonrepair": "^3.15.0",
|
"jsonrepair": "^3.15.0",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
|
"sass": "^1.102.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.6"
|
"vite": "^7.2.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ specifiers:
|
|||||||
'@vitejs/plugin-react': ^5.1.1
|
'@vitejs/plugin-react': ^5.1.1
|
||||||
electron: ^43.0.0
|
electron: ^43.0.0
|
||||||
electron-builder: ^26.0.12
|
electron-builder: ^26.0.12
|
||||||
|
electron-updater: ^6.6.2
|
||||||
electron-vite: ^5.0.0
|
electron-vite: ^5.0.0
|
||||||
eslint: ^9.39.1
|
eslint: ^9.39.1
|
||||||
eslint-plugin-react: ^7.37.5
|
eslint-plugin-react: ^7.37.5
|
||||||
@@ -33,6 +34,7 @@ specifiers:
|
|||||||
prettier: ^3.7.4
|
prettier: ^3.7.4
|
||||||
react: ^19.2.1
|
react: ^19.2.1
|
||||||
react-dom: ^19.2.1
|
react-dom: ^19.2.1
|
||||||
|
sass: ^1.102.0
|
||||||
silk-wasm: ^3.7.1
|
silk-wasm: ^3.7.1
|
||||||
typescript: ^5.9.3
|
typescript: ^5.9.3
|
||||||
vite: ^7.2.6
|
vite: ^7.2.6
|
||||||
@@ -43,6 +45,7 @@ dependencies:
|
|||||||
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
|
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
|
||||||
'@koromix/koffi-win32-x64': 3.1.0
|
'@koromix/koffi-win32-x64': 3.1.0
|
||||||
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
|
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
|
||||||
|
electron-updater: 6.8.9
|
||||||
fs-extra: 11.3.2
|
fs-extra: 11.3.2
|
||||||
fzstd: 0.1.1
|
fzstd: 0.1.1
|
||||||
jsonrepair: 3.15.0
|
jsonrepair: 3.15.0
|
||||||
@@ -71,8 +74,9 @@ devDependencies:
|
|||||||
prettier: 3.7.4
|
prettier: 3.7.4
|
||||||
react: 19.2.1
|
react: 19.2.1
|
||||||
react-dom: 19.2.1_react@19.2.1
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
sass: 1.102.0
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
vite: 7.2.7_@types+node@22.19.2
|
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -930,6 +934,132 @@ packages:
|
|||||||
rimraf: 3.0.2
|
rimraf: 3.0.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@parcel/watcher-android-arm64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-darwin-arm64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-darwin-x64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-freebsd-x64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-arm-glibc/2.6.0:
|
||||||
|
resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-arm-musl/2.6.0:
|
||||||
|
resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-arm64-glibc/2.6.0:
|
||||||
|
resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-arm64-musl/2.6.0:
|
||||||
|
resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-x64-glibc/2.6.0:
|
||||||
|
resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-linux-x64-musl/2.6.0:
|
||||||
|
resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-win32-arm64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher-win32-x64/2.6.0:
|
||||||
|
resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@parcel/watcher/2.6.0:
|
||||||
|
resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
is-glob: 4.0.3
|
||||||
|
node-addon-api: 7.1.1
|
||||||
|
picomatch: 4.0.5
|
||||||
|
optionalDependencies:
|
||||||
|
'@parcel/watcher-android-arm64': 2.6.0
|
||||||
|
'@parcel/watcher-darwin-arm64': 2.6.0
|
||||||
|
'@parcel/watcher-darwin-x64': 2.6.0
|
||||||
|
'@parcel/watcher-freebsd-x64': 2.6.0
|
||||||
|
'@parcel/watcher-linux-arm-glibc': 2.6.0
|
||||||
|
'@parcel/watcher-linux-arm-musl': 2.6.0
|
||||||
|
'@parcel/watcher-linux-arm64-glibc': 2.6.0
|
||||||
|
'@parcel/watcher-linux-arm64-musl': 2.6.0
|
||||||
|
'@parcel/watcher-linux-x64-glibc': 2.6.0
|
||||||
|
'@parcel/watcher-linux-x64-musl': 2.6.0
|
||||||
|
'@parcel/watcher-win32-arm64': 2.6.0
|
||||||
|
'@parcel/watcher-win32-x64': 2.6.0
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
/@pkgjs/parseargs/0.11.0:
|
/@pkgjs/parseargs/0.11.0:
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1429,7 +1559,7 @@ packages:
|
|||||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.18.0
|
react-refresh: 0.18.0
|
||||||
vite: 7.2.7_@types+node@22.19.2
|
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
dev: true
|
||||||
@@ -1577,7 +1707,6 @@ packages:
|
|||||||
|
|
||||||
/argparse/2.0.1:
|
/argparse/2.0.1:
|
||||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/array-buffer-byte-length/1.0.2:
|
/array-buffer-byte-length/1.0.2:
|
||||||
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
|
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
|
||||||
@@ -1766,6 +1895,16 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/builder-util-runtime/9.7.0:
|
||||||
|
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
sax: 1.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
dev: false
|
||||||
|
|
||||||
/builder-util/26.0.11:
|
/builder-util/26.0.11:
|
||||||
resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==}
|
resolution: {integrity: sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1882,6 +2021,13 @@ packages:
|
|||||||
supports-color: 7.2.0
|
supports-color: 7.2.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/chokidar/5.0.0:
|
||||||
|
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
dependencies:
|
||||||
|
readdirp: 5.0.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
/chownr/2.0.0:
|
/chownr/2.0.0:
|
||||||
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
|
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2219,6 +2365,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/electron-updater/6.8.9:
|
||||||
|
resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==}
|
||||||
|
dependencies:
|
||||||
|
builder-util-runtime: 9.7.0
|
||||||
|
fs-extra: 10.1.0
|
||||||
|
js-yaml: 4.1.1
|
||||||
|
lazy-val: 1.0.5
|
||||||
|
lodash.escaperegexp: 4.1.2
|
||||||
|
lodash.isequal: 4.5.0
|
||||||
|
semver: 7.7.3
|
||||||
|
tiny-typed-emitter: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
dev: false
|
||||||
|
|
||||||
/electron-vite/5.0.0_vite@7.2.7:
|
/electron-vite/5.0.0_vite@7.2.7:
|
||||||
resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==}
|
resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -2236,7 +2397,7 @@ packages:
|
|||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
vite: 7.2.7_@types+node@22.19.2
|
vite: 7.2.7_kx7yyowhop237vgn7dp2p54shq
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
dev: true
|
||||||
@@ -2739,7 +2900,6 @@ packages:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
jsonfile: 6.2.0
|
jsonfile: 6.2.0
|
||||||
universalify: 2.0.1
|
universalify: 2.0.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/fs-extra/11.3.2:
|
/fs-extra/11.3.2:
|
||||||
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
|
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
|
||||||
@@ -3091,6 +3251,10 @@ packages:
|
|||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/immutable/5.1.9:
|
||||||
|
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/import-fresh/3.3.1:
|
/import-fresh/3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3400,7 +3564,6 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/jsesc/3.1.0:
|
/jsesc/3.1.0:
|
||||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||||
@@ -3475,7 +3638,6 @@ packages:
|
|||||||
|
|
||||||
/lazy-val/1.0.5:
|
/lazy-val/1.0.5:
|
||||||
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
|
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/levn/0.4.1:
|
/levn/0.4.1:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
@@ -3492,6 +3654,15 @@ packages:
|
|||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/lodash.escaperegexp/4.1.2:
|
||||||
|
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/lodash.isequal/4.5.0:
|
||||||
|
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||||
|
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||||
|
dev: false
|
||||||
|
|
||||||
/lodash.merge/4.6.2:
|
/lodash.merge/4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -3742,6 +3913,11 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
/node-addon-api/7.1.1:
|
||||||
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
/node-api-version/0.2.1:
|
/node-api-version/0.2.1:
|
||||||
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
|
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3962,6 +4138,12 @@ packages:
|
|||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/picomatch/4.0.5:
|
||||||
|
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
/plist/3.1.0:
|
/plist/3.1.0:
|
||||||
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
||||||
engines: {node: '>=10.4.0'}
|
engines: {node: '>=10.4.0'}
|
||||||
@@ -4093,6 +4275,11 @@ packages:
|
|||||||
util-deprecate: 1.0.2
|
util-deprecate: 1.0.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/readdirp/5.0.0:
|
||||||
|
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||||
|
engines: {node: '>= 20.19.0'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/reflect.getprototypeof/1.0.10:
|
/reflect.getprototypeof/1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4250,9 +4437,20 @@ packages:
|
|||||||
truncate-utf8-bytes: 1.0.2
|
truncate-utf8-bytes: 1.0.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/sass/1.102.0:
|
||||||
|
resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
chokidar: 5.0.0
|
||||||
|
immutable: 5.1.9
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@parcel/watcher': 2.6.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
/sax/1.4.3:
|
/sax/1.4.3:
|
||||||
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
|
resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/scheduler/0.27.0:
|
/scheduler/0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
@@ -4600,6 +4798,10 @@ packages:
|
|||||||
semver: 5.7.2
|
semver: 5.7.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/tiny-typed-emitter/2.1.0:
|
||||||
|
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/tinyglobby/0.2.15:
|
/tinyglobby/0.2.15:
|
||||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -4784,7 +4986,7 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/vite/7.2.7_@types+node@22.19.2:
|
/vite/7.2.7_kx7yyowhop237vgn7dp2p54shq:
|
||||||
resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==}
|
resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -4830,6 +5032,7 @@ packages:
|
|||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
rollup: 4.53.3
|
rollup: 4.53.3
|
||||||
|
sass: 1.102.0
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|||||||
|
After Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 158 KiB |
@@ -2,6 +2,7 @@ import { app, shell } from 'electron'
|
|||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
import { isPackagedRuntime } from './runtime-mode'
|
||||||
|
|
||||||
const MAX_LOG_BYTES = 5 * 1024 * 1024
|
const MAX_LOG_BYTES = 5 * 1024 * 1024
|
||||||
const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i
|
const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i
|
||||||
@@ -52,14 +53,14 @@ export class AppLogger {
|
|||||||
this.rotateIfNeeded()
|
this.rotateIfNeeded()
|
||||||
const record = {
|
const record = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
mode: app.isPackaged ? 'packaged' : 'development',
|
mode: isPackagedRuntime() ? 'packaged' : 'development',
|
||||||
level: entry.level,
|
level: entry.level,
|
||||||
scope: String(entry.scope || 'app').slice(0, 80),
|
scope: String(entry.scope || 'app').slice(0, 80),
|
||||||
message: String(entry.message || '').slice(0, 500),
|
message: String(entry.message || '').slice(0, 500),
|
||||||
details: sanitize(entry.details || {})
|
details: sanitize(entry.details || {})
|
||||||
}
|
}
|
||||||
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
|
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
|
||||||
if (!app.isPackaged) {
|
if (!isPackagedRuntime()) {
|
||||||
const method =
|
const method =
|
||||||
entry.level === 'error'
|
entry.level === 'error'
|
||||||
? console.error
|
? console.error
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Message } from '../shared/types'
|
||||||
|
|
||||||
|
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}`
|
||||||
|
const safe = (value: unknown): string =>
|
||||||
|
String(value ?? '').replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c
|
||||||
|
)
|
||||||
|
|
||||||
|
export function renderExportPage(name: string, messages: Message[]): string {
|
||||||
|
const body = messages
|
||||||
|
.map((m) => {
|
||||||
|
const avatar = m.img
|
||||||
|
? `<img src="${safe(m.img)}" alt="">`
|
||||||
|
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
|
||||||
|
const audio = m.voiceDataUrl
|
||||||
|
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${m.voiceDataUrl}"></audio></div>`
|
||||||
|
: ''
|
||||||
|
const quote =
|
||||||
|
m.contentData?.type === 'quote'
|
||||||
|
? `<div class="quote-reference"><strong>${safe(m.contentData.quotedSender || '引用消息')}</strong><span>${safe(m.contentData.quotedContent || '[引用消息]')}</span></div>`
|
||||||
|
: ''
|
||||||
|
const media =
|
||||||
|
m.exportMediaUrl && m.exportMediaType === 'image'
|
||||||
|
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="图片">`
|
||||||
|
: m.exportMediaUrl && m.exportMediaType === 'video'
|
||||||
|
? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>`
|
||||||
|
: m.exportMediaUrl && m.exportMediaType === 'sticker'
|
||||||
|
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="表情包">`
|
||||||
|
: ''
|
||||||
|
const avatarMarkup =
|
||||||
|
m.exportShowAvatar === false
|
||||||
|
? ''
|
||||||
|
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
|
||||||
|
const isPat = m.contentData?.type === 'system' && m.contentData.pat
|
||||||
|
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
|
||||||
|
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)box.classList.remove('open')});update()})()</script></body></html>`
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { app, BrowserWindow, shell } from 'electron'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import { extname, join } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import * as chat from './services/chat-service'
|
||||||
|
import type {
|
||||||
|
ExportJobProgress,
|
||||||
|
ExportMessageKind,
|
||||||
|
ExportRequest,
|
||||||
|
ExportResult
|
||||||
|
} from '../shared/export'
|
||||||
|
import type { Message } from '../shared/types'
|
||||||
|
import { VoiceService } from './voice-service'
|
||||||
|
import { renderExportPage } from './export-html-template'
|
||||||
|
import { ImageDecryptService } from './image-decrypt-service'
|
||||||
|
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||||
|
import { VideoAssetService } from './video-asset-service'
|
||||||
|
import { StickerService } from './sticker-service'
|
||||||
|
|
||||||
|
const jobs = new Set<string>()
|
||||||
|
const safeFilePart = (value: string): string =>
|
||||||
|
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
||||||
|
const exportStamp = (): string => {
|
||||||
|
const date = new Date()
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
const imageKeys = new ImageKeyConfigService()
|
||||||
|
function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
|
||||||
|
const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
|
||||||
|
if (!match) return null
|
||||||
|
return {
|
||||||
|
extension: match[1].split('/')[1] === 'jpeg' ? 'jpg' : match[1].split('/')[1],
|
||||||
|
buffer: Buffer.from(match[2], 'base64')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const normalizeAssetExtension = (value: string): string => {
|
||||||
|
const extension = value.toLowerCase().replace(/^\./, '')
|
||||||
|
return /^(png|jpg|jpeg|webp|gif)$/.test(extension)
|
||||||
|
? extension === 'jpeg'
|
||||||
|
? 'jpg'
|
||||||
|
: extension
|
||||||
|
: 'jpg'
|
||||||
|
}
|
||||||
|
const detectAssetExtension = (buffer: Buffer): string | null => {
|
||||||
|
if (buffer.subarray(0, 3).toString('ascii') === 'GIF') return 'gif'
|
||||||
|
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
||||||
|
return 'png'
|
||||||
|
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg'
|
||||||
|
if (
|
||||||
|
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
)
|
||||||
|
return 'webp'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
async function readAvatarAsset(
|
||||||
|
source: string
|
||||||
|
): Promise<{ extension: string; buffer: Buffer } | null> {
|
||||||
|
const decoded = decodeDataUrl(source)
|
||||||
|
if (decoded) return { ...decoded, extension: normalizeAssetExtension(decoded.extension) }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (/^https?:\/\//i.test(source)) {
|
||||||
|
const response = await fetch(source)
|
||||||
|
if (!response.ok) return null
|
||||||
|
const contentType = response.headers.get('content-type')?.split(';')[0].split('/')[1]
|
||||||
|
const extension = normalizeAssetExtension(contentType || extname(new URL(source).pathname))
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer())
|
||||||
|
return { extension: detectAssetExtension(buffer) || extension, buffer }
|
||||||
|
}
|
||||||
|
const path = source.startsWith('file://') ? fileURLToPath(source) : source
|
||||||
|
const buffer = await fs.readFile(path)
|
||||||
|
return {
|
||||||
|
extension: detectAssetExtension(buffer) || normalizeAssetExtension(extname(path)),
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const kindOf = (message: Message): ExportMessageKind => {
|
||||||
|
const type = message.contentData?.type
|
||||||
|
if (type === 'system' && message.contentData?.pat) return 'text'
|
||||||
|
if (
|
||||||
|
type === 'image' ||
|
||||||
|
type === 'video' ||
|
||||||
|
type === 'voice' ||
|
||||||
|
type === 'sticker' ||
|
||||||
|
type === 'share' ||
|
||||||
|
type === 'location' ||
|
||||||
|
type === 'system'
|
||||||
|
)
|
||||||
|
return type
|
||||||
|
if (message.type === '图片') return 'image'
|
||||||
|
if (message.type === '视频') return 'video'
|
||||||
|
if (message.type === '语音') return 'voice'
|
||||||
|
if (message.type === '表情包') return 'sticker'
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||||
|
|
||||||
|
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
||||||
|
if (format === 'html') return renderExportPage(name, messages)
|
||||||
|
if (format === 'json')
|
||||||
|
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||||
|
if (format === 'markdown')
|
||||||
|
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}\n`).join('\n')}`
|
||||||
|
return [
|
||||||
|
'时间,发送者,类型,内容',
|
||||||
|
...messages.map((m) =>
|
||||||
|
[m.datetime, m.name || (m.isSender ? '我' : '联系人'), m.type, m.content].map(csv).join(',')
|
||||||
|
)
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runExport(request: ExportRequest, win: BrowserWindow): Promise<ExportResult> {
|
||||||
|
jobs.add(request.jobId)
|
||||||
|
const send = (p: ExportJobProgress): void => {
|
||||||
|
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
|
const messages = chat
|
||||||
|
.listMessages(request.userMd5, request.startTime, request.endTime)
|
||||||
|
.filter((m) => request.kinds.includes(kindOf(m)))
|
||||||
|
for (const message of messages) {
|
||||||
|
message.exportShowAvatar = request.includeAvatars !== false
|
||||||
|
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||||
|
if (mappedName) message.name = mappedName
|
||||||
|
}
|
||||||
|
send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 })
|
||||||
|
if (!jobs.has(request.jobId)) {
|
||||||
|
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 })
|
||||||
|
return { success: false, error: '已取消' }
|
||||||
|
}
|
||||||
|
send({
|
||||||
|
jobId: request.jobId,
|
||||||
|
phase: 'writing',
|
||||||
|
processed: 0,
|
||||||
|
total: messages.length,
|
||||||
|
percent: 15
|
||||||
|
})
|
||||||
|
const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
|
||||||
|
await fs.mkdir(root, { recursive: true })
|
||||||
|
const ext = request.format === 'markdown' ? 'md' : request.format
|
||||||
|
const outputFolder = `${safeFilePart(request.outputName)}_${exportStamp()}`
|
||||||
|
const outputDir = join(root, outputFolder)
|
||||||
|
const outputPath =
|
||||||
|
request.format === 'html'
|
||||||
|
? join(outputDir, 'index.html')
|
||||||
|
: join(root, `${outputFolder}.${ext}`)
|
||||||
|
if (request.format === 'html') {
|
||||||
|
await fs.mkdir(join(outputDir, 'voices'), { recursive: true })
|
||||||
|
await fs.mkdir(join(outputDir, 'media'), { recursive: true })
|
||||||
|
await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
|
||||||
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
|
const avatarUsernames = Array.from(
|
||||||
|
new Set(
|
||||||
|
messages
|
||||||
|
.map((message) => message.senderId)
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const avatarMap =
|
||||||
|
request.includeAvatars === false
|
||||||
|
? {}
|
||||||
|
: { ...chat.getContactAvatars(avatarUsernames), ...(request.avatarUrls || {}) }
|
||||||
|
const imageConfig = imageKeys.getConfig()
|
||||||
|
const imageService =
|
||||||
|
client && imageConfig.aesKey
|
||||||
|
? new ImageDecryptService(imageConfig.xorKey || '0x40', imageConfig.aesKey, client)
|
||||||
|
: null
|
||||||
|
const videoService = client ? new VideoAssetService(client) : null
|
||||||
|
const stickerService = client ? new StickerService(client) : null
|
||||||
|
const exportedAvatars = new Map<string, string>()
|
||||||
|
const voiceService =
|
||||||
|
request.includeMedia && chat.getChatDb()
|
||||||
|
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
||||||
|
: null
|
||||||
|
if (voiceService) {
|
||||||
|
for (const [index, message] of messages.entries()) {
|
||||||
|
if (
|
||||||
|
kindOf(message) !== 'voice' ||
|
||||||
|
!message.sessionId ||
|
||||||
|
!message.localId ||
|
||||||
|
!message.createTime
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
const voice = await voiceService.resolveVoice(
|
||||||
|
message.sessionId,
|
||||||
|
message.localId,
|
||||||
|
message.createTime,
|
||||||
|
message.serverId
|
||||||
|
)
|
||||||
|
if (!voice.success || !voice.data) continue
|
||||||
|
const voiceName = `voice_${index + 1}_${message.localId}.wav`
|
||||||
|
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||||
|
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||||
|
message.voiceDataUrl = `voices/${voiceName}`
|
||||||
|
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [index, message] of messages.entries()) {
|
||||||
|
message.exportShowAvatar = request.includeAvatars !== false
|
||||||
|
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
|
||||||
|
const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null
|
||||||
|
const avatarBuffer = resolvedAvatar?.buffer || null
|
||||||
|
const avatarExtension = resolvedAvatar?.extension || 'jpg'
|
||||||
|
if (avatarBuffer) {
|
||||||
|
const avatarKey = message.senderId || `message_${index + 1}`
|
||||||
|
let avatarName = exportedAvatars.get(avatarKey)
|
||||||
|
if (!avatarName) {
|
||||||
|
avatarName = `avatar_${exportedAvatars.size + 1}.${avatarExtension}`
|
||||||
|
await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer)
|
||||||
|
exportedAvatars.set(avatarKey, avatarName)
|
||||||
|
}
|
||||||
|
message.exportAvatarUrl = `avatars/${avatarName}`
|
||||||
|
}
|
||||||
|
if (!request.includeMedia || !message.contentData) {
|
||||||
|
send({
|
||||||
|
jobId: request.jobId,
|
||||||
|
phase: 'writing',
|
||||||
|
processed: index + 1,
|
||||||
|
total: messages.length,
|
||||||
|
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (message.contentData.type === 'image' && imageService) {
|
||||||
|
const file = imageService.findImageFile(
|
||||||
|
message.contentData.md5,
|
||||||
|
message.contentData.datName,
|
||||||
|
{ allowThumbnail: true }
|
||||||
|
)
|
||||||
|
const decrypted = file ? imageService.decryptImageToBase64WithFallback(file, true) : null
|
||||||
|
const decoded = decrypted ? decodeDataUrl(decrypted.data) : null
|
||||||
|
if (decoded) {
|
||||||
|
const name = `image_${index + 1}.${decoded.extension}`
|
||||||
|
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||||
|
message.exportMediaUrl = `media/${name}`
|
||||||
|
message.exportMediaType = 'image'
|
||||||
|
}
|
||||||
|
} else if (message.contentData.type === 'video' && videoService) {
|
||||||
|
const hashes = [
|
||||||
|
message.contentData.md5,
|
||||||
|
message.contentData.newMd5,
|
||||||
|
message.contentData.rawMd5
|
||||||
|
].filter((value): value is string => Boolean(value))
|
||||||
|
const resolved = videoService.resolve(hashes)
|
||||||
|
const token = resolved.url?.split('/').pop()
|
||||||
|
const source = token ? videoService.pathForToken(token) : undefined
|
||||||
|
if (source) {
|
||||||
|
const name = `video_${index + 1}.mp4`
|
||||||
|
await fs.copyFile(source, join(outputDir, 'media', name))
|
||||||
|
message.exportMediaUrl = `media/${name}`
|
||||||
|
message.exportMediaType = 'video'
|
||||||
|
}
|
||||||
|
} else if (message.contentData.type === 'sticker' && stickerService) {
|
||||||
|
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
||||||
|
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
||||||
|
const decoded = result.data
|
||||||
|
? decodeDataUrl(result.data)
|
||||||
|
: stickerSource
|
||||||
|
? await readAvatarAsset(stickerSource)
|
||||||
|
: null
|
||||||
|
if (decoded) {
|
||||||
|
const name = `sticker_${index + 1}.${decoded.extension}`
|
||||||
|
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||||
|
message.exportMediaUrl = `media/${name}`
|
||||||
|
message.exportMediaType = 'sticker'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send({
|
||||||
|
jobId: request.jobId,
|
||||||
|
phase: 'writing',
|
||||||
|
processed: index + 1,
|
||||||
|
total: messages.length,
|
||||||
|
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
send({
|
||||||
|
jobId: request.jobId,
|
||||||
|
phase: 'writing',
|
||||||
|
processed: messages.length,
|
||||||
|
total: messages.length,
|
||||||
|
percent: 90
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await fs.writeFile(outputPath, render(request.format, messages, request.name), 'utf8')
|
||||||
|
send({
|
||||||
|
jobId: request.jobId,
|
||||||
|
phase: 'completed',
|
||||||
|
processed: messages.length,
|
||||||
|
total: messages.length,
|
||||||
|
percent: 100,
|
||||||
|
outputPath
|
||||||
|
})
|
||||||
|
return { success: true, outputPath, messageCount: messages.length }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||||||
|
return { success: false, error: message }
|
||||||
|
} finally {
|
||||||
|
jobs.delete(request.jobId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function cancelExport(jobId: string): void {
|
||||||
|
jobs.delete(jobId)
|
||||||
|
}
|
||||||
|
export async function revealExport(path: string): Promise<void> {
|
||||||
|
shell.showItemInFolder(path)
|
||||||
|
}
|
||||||
@@ -9,12 +9,23 @@ const imageDecryptLog = (...args: unknown[]): void => {
|
|||||||
if (imageDecryptDebugEnabled) console.log(...args)
|
if (imageDecryptDebugEnabled) console.log(...args)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImageDecryptService {
|
type DecodedImage = {
|
||||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
data: string
|
||||||
|
filePath: string
|
||||||
|
isThumbnail: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||||
|
|
||||||
|
export class ImageDecryptService {
|
||||||
private xorKey: number = 0
|
private xorKey: number = 0
|
||||||
private aesKey: string = ''
|
private aesKey: string = ''
|
||||||
private wcdb4Client: Wcdb4Client | null = null
|
private wcdb4Client: Wcdb4Client | null = null
|
||||||
|
private accountDirResolved = false
|
||||||
|
private cachedAccountDir: string | null = null
|
||||||
|
private imagePathCache = new Map<string, string>()
|
||||||
|
private decodedImageCache = new Map<string, DecodedImage>()
|
||||||
|
private decodedImageCacheBytes = 0
|
||||||
|
|
||||||
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
|
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
|
||||||
// 解析 XOR Key (支持 0x40 或 64 格式)
|
// 解析 XOR Key (支持 0x40 或 64 格式)
|
||||||
@@ -34,9 +45,13 @@ export class ImageDecryptService {
|
|||||||
* 获取账号目录
|
* 获取账号目录
|
||||||
*/
|
*/
|
||||||
private getAccountDir(): string | null {
|
private getAccountDir(): string | null {
|
||||||
|
if (this.accountDirResolved) return this.cachedAccountDir
|
||||||
|
this.accountDirResolved = true
|
||||||
|
|
||||||
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
|
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
|
||||||
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
|
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
|
||||||
return wcdbAccountRoot
|
this.cachedAccountDir = wcdbAccountRoot
|
||||||
|
return this.cachedAccountDir
|
||||||
}
|
}
|
||||||
|
|
||||||
const homeDir = os.homedir()
|
const homeDir = os.homedir()
|
||||||
@@ -71,7 +86,8 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 返回最新的账号目录
|
// 返回最新的账号目录
|
||||||
return join(accountRoot, accounts[0].name)
|
this.cachedAccountDir = join(accountRoot, accounts[0].name)
|
||||||
|
return this.cachedAccountDir
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,14 +96,32 @@ export class ImageDecryptService {
|
|||||||
findImageFile(
|
findImageFile(
|
||||||
md5?: string,
|
md5?: string,
|
||||||
imageDatName?: string,
|
imageDatName?: string,
|
||||||
options?: { allowThumbnail?: boolean }
|
options?: { allowThumbnail?: boolean; accountDir?: string; preferThumbnail?: boolean }
|
||||||
): string | null {
|
): string | null {
|
||||||
const accountDir = this.getAccountDir()
|
|
||||||
if (!accountDir) return null
|
|
||||||
const allowThumbnail = options?.allowThumbnail !== false
|
const allowThumbnail = options?.allowThumbnail !== false
|
||||||
|
|
||||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||||
|
const pathCacheKey = [
|
||||||
|
normalizedMd5,
|
||||||
|
normalizedDatName,
|
||||||
|
allowThumbnail ? 'thumb' : 'original',
|
||||||
|
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
|
||||||
|
options?.accountDir || ''
|
||||||
|
].join('|')
|
||||||
|
const cachedPath = this.imagePathCache.get(pathCacheKey)
|
||||||
|
if (cachedPath && existsSync(cachedPath)) return cachedPath
|
||||||
|
|
||||||
|
const rememberPath = (path: string | null): string | null => {
|
||||||
|
if (path) this.imagePathCache.set(pathCacheKey, path)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
|
||||||
|
const accountDir =
|
||||||
|
options?.accountDir && existsSync(options.accountDir)
|
||||||
|
? options.accountDir
|
||||||
|
: this.getAccountDir()
|
||||||
|
if (!accountDir) return null
|
||||||
imageDecryptLog('[ImageDecrypt] findImageFile:', {
|
imageDecryptLog('[ImageDecrypt] findImageFile:', {
|
||||||
md5: normalizedMd5,
|
md5: normalizedMd5,
|
||||||
imageDatName: normalizedDatName,
|
imageDatName: normalizedDatName,
|
||||||
@@ -99,10 +133,14 @@ export class ImageDecryptService {
|
|||||||
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
||||||
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
||||||
if (fullPath && existsSync(fullPath)) {
|
if (fullPath && existsSync(fullPath)) {
|
||||||
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
|
const selected = this.getPreferredDatVariantPath(
|
||||||
|
fullPath,
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
||||||
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
|
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
|
||||||
return selected
|
return rememberPath(selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,28 +149,75 @@ export class ImageDecryptService {
|
|||||||
const attachDir = join(accountDir, 'msg', 'attach')
|
const attachDir = join(accountDir, 'msg', 'attach')
|
||||||
if (!existsSync(attachDir)) {
|
if (!existsSync(attachDir)) {
|
||||||
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
||||||
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
|
return rememberPath(
|
||||||
|
this.findImageFileInLegacyDirs(
|
||||||
|
accountDir,
|
||||||
|
normalizedMd5 || normalizedDatName,
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
|
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
|
||||||
if (searchKeys.length === 0) return null
|
if (searchKeys.length === 0) return null
|
||||||
|
|
||||||
for (const key of searchKeys) {
|
for (const key of searchKeys) {
|
||||||
const directHit = this.fastProbabilisticSearch(attachDir, key, allowThumbnail)
|
const directHit = this.fastProbabilisticSearch(
|
||||||
if (directHit) return directHit
|
attachDir,
|
||||||
|
key,
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
|
if (directHit) return rememberPath(directHit)
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
|
const legacyHit = this.findImageFileInLegacyDirs(
|
||||||
if (legacyHit) return legacyHit
|
accountDir,
|
||||||
|
searchKeys[0],
|
||||||
|
allowThumbnail,
|
||||||
|
options?.preferThumbnail
|
||||||
|
)
|
||||||
|
if (legacyHit) return rememberPath(legacyHit)
|
||||||
|
|
||||||
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCachedDecodedImage(key: string): DecodedImage | null {
|
||||||
|
const cached = this.decodedImageCache.get(key)
|
||||||
|
if (!cached) return null
|
||||||
|
this.decodedImageCache.delete(key)
|
||||||
|
this.decodedImageCache.set(key, cached)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheDecodedImage(key: string, image: DecodedImage): void {
|
||||||
|
const size = image.data.length * 2
|
||||||
|
const previous = this.decodedImageCache.get(key)
|
||||||
|
if (previous) {
|
||||||
|
this.decodedImageCacheBytes -= previous.data.length * 2
|
||||||
|
this.decodedImageCache.delete(key)
|
||||||
|
}
|
||||||
|
this.decodedImageCache.set(key, image)
|
||||||
|
this.decodedImageCacheBytes += size
|
||||||
|
while (
|
||||||
|
this.decodedImageCacheBytes > MAX_DECODED_IMAGE_CACHE_BYTES &&
|
||||||
|
this.decodedImageCache.size > 1
|
||||||
|
) {
|
||||||
|
const oldestKey = this.decodedImageCache.keys().next().value
|
||||||
|
if (!oldestKey) break
|
||||||
|
const oldest = this.decodedImageCache.get(oldestKey)
|
||||||
|
this.decodedImageCache.delete(oldestKey)
|
||||||
|
this.decodedImageCacheBytes -= oldest?.data.length ? oldest.data.length * 2 : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fastProbabilisticSearch(
|
private fastProbabilisticSearch(
|
||||||
attachDir: string,
|
attachDir: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
allowThumbnail = true
|
allowThumbnail = true,
|
||||||
|
preferThumbnail = false
|
||||||
): string | null {
|
): string | null {
|
||||||
const normalized = this.normalizeDatBase(datName)
|
const normalized = this.normalizeDatBase(datName)
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
@@ -149,7 +234,7 @@ export class ImageDecryptService {
|
|||||||
join(attachDir, dir1, dir2, 'Image', variant),
|
join(attachDir, dir1, dir2, 'Image', variant),
|
||||||
join(attachDir, dir1, dir2, 'image', variant)
|
join(attachDir, dir1, dir2, 'image', variant)
|
||||||
]
|
]
|
||||||
const found = this.getLargestExistingPath(candidates, allowThumbnail)
|
const found = this.getLargestExistingPath(candidates, allowThumbnail, preferThumbnail)
|
||||||
if (found) {
|
if (found) {
|
||||||
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
|
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
|
||||||
return found
|
return found
|
||||||
@@ -177,7 +262,8 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
const found = this.getLargestExistingPath(
|
const found = this.getLargestExistingPath(
|
||||||
variants.map((variant) => join(imgDir, variant)),
|
variants.map((variant) => join(imgDir, variant)),
|
||||||
allowThumbnail
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
)
|
)
|
||||||
if (found) {
|
if (found) {
|
||||||
imageDecryptLog('[ImageDecrypt] found at:', found)
|
imageDecryptLog('[ImageDecrypt] found at:', found)
|
||||||
@@ -196,7 +282,8 @@ export class ImageDecryptService {
|
|||||||
private findImageFileInLegacyDirs(
|
private findImageFileInLegacyDirs(
|
||||||
accountDir: string,
|
accountDir: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
allowThumbnail = true
|
allowThumbnail = true,
|
||||||
|
preferThumbnail = false
|
||||||
): string | null {
|
): string | null {
|
||||||
const normalized = this.normalizeDatBase(datName)
|
const normalized = this.normalizeDatBase(datName)
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
@@ -208,7 +295,7 @@ export class ImageDecryptService {
|
|||||||
].filter((root) => existsSync(root))
|
].filter((root) => existsSync(root))
|
||||||
|
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail)
|
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail, preferThumbnail)
|
||||||
if (found) return found
|
if (found) return found
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,30 +306,52 @@ export class ImageDecryptService {
|
|||||||
dir: string,
|
dir: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
depth: number,
|
depth: number,
|
||||||
allowThumbnail = true
|
allowThumbnail = true,
|
||||||
|
preferThumbnail = false
|
||||||
): string | null {
|
): string | null {
|
||||||
if (depth < 0) return null
|
if (depth < 0) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const variants = new Set(
|
const variantNames = this.buildPreferredDatNames(datName).filter(
|
||||||
this.buildPreferredDatNames(datName).filter(
|
|
||||||
(name) => allowThumbnail || !this.isThumbnailName(name)
|
(name) => allowThumbnail || !this.isThumbnailName(name)
|
||||||
)
|
)
|
||||||
|
const variants = new Set(
|
||||||
|
preferThumbnail
|
||||||
|
? [
|
||||||
|
...variantNames.filter((name) => this.isThumbnailName(name)),
|
||||||
|
...variantNames.filter((name) => !this.isThumbnailName(name))
|
||||||
|
]
|
||||||
|
: variantNames
|
||||||
)
|
)
|
||||||
const entries = readdirSync(dir)
|
const entries = readdirSync(dir)
|
||||||
|
const matchingFiles: string[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry)
|
const fullPath = join(dir, entry)
|
||||||
const stat = statSync(fullPath)
|
const stat = statSync(fullPath)
|
||||||
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
||||||
imageDecryptLog('[ImageDecrypt] legacy path hit:', fullPath)
|
matchingFiles.push(fullPath)
|
||||||
return fullPath
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const preferredFile = this.getLargestExistingPath(
|
||||||
|
matchingFiles,
|
||||||
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
|
)
|
||||||
|
if (preferredFile) {
|
||||||
|
imageDecryptLog('[ImageDecrypt] legacy path hit:', preferredFile)
|
||||||
|
return preferredFile
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry)
|
const fullPath = join(dir, entry)
|
||||||
if (!statSync(fullPath).isDirectory()) continue
|
if (!statSync(fullPath).isDirectory()) continue
|
||||||
const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail)
|
const found = this.recursiveFindDat(
|
||||||
|
fullPath,
|
||||||
|
datName,
|
||||||
|
depth - 1,
|
||||||
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
|
)
|
||||||
if (found) return found
|
if (found) return found
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -273,12 +382,9 @@ export class ImageDecryptService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let decrypted: Buffer
|
let decrypted: Buffer
|
||||||
if (version === 1) {
|
if (version === 2) {
|
||||||
imageDecryptLog('[ImageDecrypt] using V1 (default AES key)')
|
// WeChat 4.0 标准 dat 头: 07 08 56 32 08 07
|
||||||
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
|
imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)')
|
||||||
decrypted = this.decryptDatV4(datPath, key)
|
|
||||||
} else if (version === 2) {
|
|
||||||
imageDecryptLog('[ImageDecrypt] using V2 (user AES key)')
|
|
||||||
if (!this.aesKey) {
|
if (!this.aesKey) {
|
||||||
imageDecryptLog('[ImageDecrypt] no AES key configured')
|
imageDecryptLog('[ImageDecrypt] no AES key configured')
|
||||||
return null
|
return null
|
||||||
@@ -286,7 +392,8 @@ export class ImageDecryptService {
|
|||||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||||
decrypted = this.decryptDatV4(datPath, key)
|
decrypted = this.decryptDatV4(datPath, key)
|
||||||
} else {
|
} else {
|
||||||
imageDecryptLog('[ImageDecrypt] unsupported dat version:', version)
|
// 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
|
||||||
|
imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +463,8 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检测 DAT 文件版本
|
* 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。
|
||||||
|
* 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。
|
||||||
*/
|
*/
|
||||||
private getDatVersion(inputPath: string): number {
|
private getDatVersion(inputPath: string): number {
|
||||||
const bytes = readFileSync(inputPath)
|
const bytes = readFileSync(inputPath)
|
||||||
@@ -365,9 +473,6 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const signature = bytes.subarray(0, 6)
|
const signature = bytes.subarray(0, 6)
|
||||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
|
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -495,7 +600,11 @@ export class ImageDecryptService {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
|
private getPreferredDatVariantPath(
|
||||||
|
inputPath: string,
|
||||||
|
allowThumbnail: boolean,
|
||||||
|
preferThumbnail = false
|
||||||
|
): string {
|
||||||
const actualDir = dirname(inputPath)
|
const actualDir = dirname(inputPath)
|
||||||
const base = this.normalizeDatBase(basename(inputPath))
|
const base = this.normalizeDatBase(basename(inputPath))
|
||||||
const variants = this.buildPreferredDatNames(base)
|
const variants = this.buildPreferredDatNames(base)
|
||||||
@@ -504,13 +613,18 @@ export class ImageDecryptService {
|
|||||||
: variants.filter((name) => !this.isThumbnailName(name))
|
: variants.filter((name) => !this.isThumbnailName(name))
|
||||||
const largest = this.getLargestExistingPath(
|
const largest = this.getLargestExistingPath(
|
||||||
ordered.map((variant) => join(actualDir, variant)),
|
ordered.map((variant) => join(actualDir, variant)),
|
||||||
allowThumbnail
|
allowThumbnail,
|
||||||
|
preferThumbnail
|
||||||
)
|
)
|
||||||
if (largest) return largest
|
if (largest) return largest
|
||||||
return inputPath
|
return inputPath
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLargestExistingPath(paths: string[], allowThumbnail: boolean): string | null {
|
private getLargestExistingPath(
|
||||||
|
paths: string[],
|
||||||
|
allowThumbnail: boolean,
|
||||||
|
preferThumbnail = false
|
||||||
|
): string | null {
|
||||||
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
|
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
|
||||||
candidates
|
candidates
|
||||||
.filter((candidate) => existsSync(candidate))
|
.filter((candidate) => existsSync(candidate))
|
||||||
@@ -523,6 +637,10 @@ export class ImageDecryptService {
|
|||||||
})
|
})
|
||||||
.sort((left, right) => right.size - left.size)
|
.sort((left, right) => right.size - left.size)
|
||||||
|
|
||||||
|
const thumbnail = toSized(
|
||||||
|
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
|
||||||
|
)
|
||||||
|
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
|
||||||
const nonThumb = toSized(
|
const nonThumb = toSized(
|
||||||
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import './preload-env'
|
import './preload-env'
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
shell,
|
shell,
|
||||||
@@ -8,14 +8,16 @@ import {
|
|||||||
clipboard,
|
clipboard,
|
||||||
Menu,
|
Menu,
|
||||||
Tray,
|
Tray,
|
||||||
dialog
|
dialog,
|
||||||
|
protocol
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync, promises as fsPromises } from 'fs'
|
||||||
|
import { extname } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { WechatDb } from './wechat-db'
|
import { WechatDb } from './wechat-db'
|
||||||
import { bootstrapWcdbNative } from './wcdb4-client'
|
import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
|
||||||
import { VoiceService } from './voice-service'
|
import { VoiceService } from './voice-service'
|
||||||
import { StickerService } from './sticker-service'
|
import { StickerService } from './sticker-service'
|
||||||
import { parseMessageContent } from './message-parser'
|
import { parseMessageContent } from './message-parser'
|
||||||
@@ -59,18 +61,28 @@ import {
|
|||||||
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
||||||
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
||||||
import {
|
import {
|
||||||
|
flushBootstrapCacheWritesSync,
|
||||||
getBootstrapCache,
|
getBootstrapCache,
|
||||||
|
getCachedMessagePage,
|
||||||
getCachedMessages,
|
getCachedMessages,
|
||||||
mergeBootstrapAvatars,
|
mergeBootstrapAvatars,
|
||||||
mergeCachedContactAvatars,
|
mergeCachedContactAvatars,
|
||||||
saveBootstrapContacts,
|
saveBootstrapContacts,
|
||||||
saveBootstrapSelf,
|
saveBootstrapSelf,
|
||||||
|
saveCachedGroupSnapshot,
|
||||||
saveCachedMessages
|
saveCachedMessages
|
||||||
} from './services/bootstrap-cache'
|
} from './services/bootstrap-cache'
|
||||||
import { installSafeConsole } from './safe-log'
|
import { installSafeConsole } from './safe-log'
|
||||||
import { agentHubService } from './services/agent-hub-service'
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
import { appLogger } from './app-logger'
|
import { appLogger } from './app-logger'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
import { appUpdateService } from './services/app-update-service'
|
||||||
|
import { clearCache, getCacheSummary } from './services/cache-service'
|
||||||
|
import type { CacheClearScope } from './services/cache-service'
|
||||||
|
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
|
||||||
|
import { VideoAssetService } from './video-asset-service'
|
||||||
|
import { cancelExport, revealExport, runExport } from './export-service'
|
||||||
|
import type { ExportRequest } from '../shared/export'
|
||||||
|
|
||||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||||
@@ -80,19 +92,99 @@ installSafeConsole()
|
|||||||
let voiceService: VoiceService | null = null
|
let voiceService: VoiceService | null = null
|
||||||
let imageDecryptService: ImageDecryptService | null = null
|
let imageDecryptService: ImageDecryptService | null = null
|
||||||
let stickerService: StickerService | null = null
|
let stickerService: StickerService | null = null
|
||||||
|
let videoAssetService: VideoAssetService | null = null
|
||||||
const databaseKeyStore = new DatabaseKeyStore()
|
const databaseKeyStore = new DatabaseKeyStore()
|
||||||
const imageKeyConfigService = new ImageKeyConfigService()
|
const imageKeyConfigService = new ImageKeyConfigService()
|
||||||
const aiProviderService = new AIProviderService()
|
const aiProviderService = new AIProviderService()
|
||||||
const keyServiceMac = new KeyServiceMac()
|
const keyServiceMac = new KeyServiceMac()
|
||||||
const keyServiceWin = new KeyServiceWin()
|
const keyServiceWin = new KeyServiceWin()
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
|
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||||
|
let recallProtectionGeneration = 0
|
||||||
|
let recallJournalTimer: NodeJS.Timeout | null = null
|
||||||
|
let wcdbBootstrapPromise: Promise<unknown> | null = null
|
||||||
|
|
||||||
|
function configureRecallProtection(
|
||||||
|
wcdb4Client: Wcdb4Client,
|
||||||
|
accountRoot: string,
|
||||||
|
enabled: boolean,
|
||||||
|
installJournal = false
|
||||||
|
): void {
|
||||||
|
recallProtectionGeneration += 1
|
||||||
|
const generation = recallProtectionGeneration
|
||||||
|
recallArchiveMonitor?.stop()
|
||||||
|
recallArchiveMonitor = null
|
||||||
|
if (recallJournalTimer) {
|
||||||
|
clearTimeout(recallJournalTimer)
|
||||||
|
recallJournalTimer = null
|
||||||
|
}
|
||||||
|
configureRecallArchive(enabled ? accountRoot : '')
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
const monitor = new RecallArchiveMonitor(
|
||||||
|
() =>
|
||||||
|
wcdb4Client.getSessions().map((session) => ({
|
||||||
|
md5: wcdb4Client.md5(session.username),
|
||||||
|
m_nsUsrName: session.username,
|
||||||
|
type: session.username.endsWith('@chatroom') ? 'group' : 'user',
|
||||||
|
activityKey: [
|
||||||
|
session.raw['last_timestamp'],
|
||||||
|
session.raw['sort_timestamp'],
|
||||||
|
session.raw['last_msg_locald_id'],
|
||||||
|
session.raw['last_msg_type'],
|
||||||
|
session.raw['summary']
|
||||||
|
].join(':')
|
||||||
|
})),
|
||||||
|
(sessionMd5) =>
|
||||||
|
chat.listMessages(sessionMd5, Math.floor(Date.now() / 1000) - 10 * 60, undefined, {
|
||||||
|
limit: 500
|
||||||
|
})
|
||||||
|
)
|
||||||
|
recallArchiveMonitor = monitor
|
||||||
|
// Session indexing is not required to open the UI. Defer it so large databases
|
||||||
|
// do not make db:init wait for every conversation to be enumerated.
|
||||||
|
setImmediate(() => {
|
||||||
|
if (generation === recallProtectionGeneration && recallArchiveMonitor === monitor) {
|
||||||
|
monitor.seedAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!installJournal) return
|
||||||
|
// Creating recall triggers scans every message table and runs synchronously in
|
||||||
|
// the main process. Only do this after the user explicitly enables protection;
|
||||||
|
// existing installations remain active without repeating the scan at startup.
|
||||||
|
recallJournalTimer = setTimeout(() => {
|
||||||
|
recallJournalTimer = null
|
||||||
|
if (generation !== recallProtectionGeneration || recallArchiveMonitor !== monitor) return
|
||||||
|
const result = wcdb4Client.installRecallJournal(
|
||||||
|
wcdb4Client.getSessions().map((session) => session.username)
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
`[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}`
|
||||||
|
)
|
||||||
|
}, 30_000)
|
||||||
|
}
|
||||||
|
|
||||||
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
||||||
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
|
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
|
||||||
|
|
||||||
|
protocol.registerSchemesAsPrivileged([
|
||||||
|
{
|
||||||
|
scheme: 'wxe-media',
|
||||||
|
privileges: { secure: true, standard: true, stream: true, supportFetchAPI: true }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
// WCDB's Windows runtime checks the host application name during wcdb_init.
|
// WCDB's Windows runtime checks the host application name during wcdb_init.
|
||||||
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
||||||
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
|
app.setName(
|
||||||
|
process.platform === 'win32'
|
||||||
|
? 'WeFlow'
|
||||||
|
: process.env['WXE_USER_DATA']
|
||||||
|
? 'WechatExplorer Dev'
|
||||||
|
: 'WechatExplorer'
|
||||||
|
)
|
||||||
|
const isolatedUserData = process.env['WXE_USER_DATA']
|
||||||
|
if (isolatedUserData) app.setPath('userData', isolatedUserData)
|
||||||
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
|
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
|
||||||
null
|
null
|
||||||
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||||
@@ -107,8 +199,65 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createLocalMediaResponse(request: Request, filePath: string): Promise<Response> {
|
||||||
|
const { size } = await fsPromises.stat(filePath)
|
||||||
|
const mimeType = extname(filePath).toLowerCase() === '.mp4' ? 'video/mp4' : 'image/jpeg'
|
||||||
|
const commonHeaders = {
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Cache-Control': 'private, max-age=300'
|
||||||
|
}
|
||||||
|
const range = request.headers.get('range')
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
const body =
|
||||||
|
request.method === 'HEAD' ? null : Uint8Array.from(await fsPromises.readFile(filePath))
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: { ...commonHeaders, 'Content-Length': String(size) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^bytes=(\d+)-(\d*)$/i.exec(range.trim())
|
||||||
|
if (!match) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 416,
|
||||||
|
headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const start = Number(match[1])
|
||||||
|
const requestedEnd = match[2] ? Number(match[2]) : size - 1
|
||||||
|
const end = Math.min(requestedEnd, size - 1)
|
||||||
|
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= size) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 416,
|
||||||
|
headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = end - start + 1
|
||||||
|
let body: Buffer | null = null
|
||||||
|
if (request.method !== 'HEAD') {
|
||||||
|
const handle = await fsPromises.open(filePath, 'r')
|
||||||
|
try {
|
||||||
|
body = Buffer.allocUnsafe(length)
|
||||||
|
await handle.read(body, 0, length, start)
|
||||||
|
} finally {
|
||||||
|
await handle.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response(body ? Uint8Array.from(body) : null, {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
...commonHeaders,
|
||||||
|
'Content-Length': String(length),
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${size}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// 鍒涘缓娴忚鍣ㄧ獥鍙?
|
// 创建浏览器窗口
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
height: 800,
|
height: 800,
|
||||||
@@ -130,8 +279,8 @@ function createWindow(): void {
|
|||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR
|
// 基于 electron-vite CLI 的渲染器热更新
|
||||||
// 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢
|
// 加载开发环境的远程 URL,或生产环境的本地 HTML 文件
|
||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
} else {
|
} else {
|
||||||
@@ -139,9 +288,20 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶
|
// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法
|
||||||
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
// 某些 API 只能在此事件发生后使用
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
protocol.handle('wxe-media', async (request) => {
|
||||||
|
const token = new URL(request.url).pathname.replace(/^\/+/, '')
|
||||||
|
const filePath = videoAssetService?.pathForToken(token)
|
||||||
|
if (!filePath) return new Response('Not found', { status: 404 })
|
||||||
|
try {
|
||||||
|
return await createLocalMediaResponse(request, filePath)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Video] local media request failed:', error)
|
||||||
|
return new Response('Media unavailable', { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||||
appLogger.write({
|
appLogger.write({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
@@ -169,24 +329,20 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
// Create the renderer before native WCDB bootstrap so startup progress is visible immediately.
|
||||||
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
createWindow()
|
||||||
// reuses the already-initialized library and skips wcdb_init.
|
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
|
||||||
try {
|
console.log('[WCDB4] async bootstrap complete')
|
||||||
bootstrapWcdbNative()
|
})
|
||||||
console.log('[WCDB4] bootstrap complete at whenReady top')
|
|
||||||
} catch (bootstrapError) {
|
|
||||||
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID
|
// 设置应用程序用户模型 ID
|
||||||
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
||||||
|
|
||||||
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
|
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
|
||||||
|
|
||||||
// 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools
|
// 开发环境中默认使用 F12 打开或关闭 DevTools
|
||||||
// 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R
|
// 生产环境中忽略 CommandOrControl + R
|
||||||
// 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
@@ -196,12 +352,24 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('app-log:write', (_, entry: AppLogEntry) => appLogger.write(entry))
|
ipcMain.handle('app-log:write', (_, entry: AppLogEntry) => appLogger.write(entry))
|
||||||
ipcMain.handle('app-log:getPath', () => appLogger.logPath)
|
ipcMain.handle('app-log:getPath', () => appLogger.logPath)
|
||||||
ipcMain.handle('app-log:reveal', () => appLogger.reveal())
|
ipcMain.handle('app-log:reveal', () => appLogger.reveal())
|
||||||
|
ipcMain.handle('app-update:getState', () => appUpdateService.getState())
|
||||||
|
ipcMain.handle('app-update:check', () => appUpdateService.check())
|
||||||
|
ipcMain.handle('app-update:download', () => appUpdateService.download())
|
||||||
|
ipcMain.handle('app-update:install', () => appUpdateService.install())
|
||||||
|
ipcMain.handle('cache:getSummary', () => getCacheSummary())
|
||||||
|
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
|
||||||
|
const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'all']
|
||||||
|
if (!allowedScopes.includes(scope)) return getCacheSummary()
|
||||||
|
imageDecryptService = null
|
||||||
|
return clearCache(scope)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:init', async (_, key: string) => {
|
ipcMain.handle('db:init', async (_, key: string) => {
|
||||||
if (dbInitInFlight) return dbInitInFlight
|
if (dbInitInFlight) return dbInitInFlight
|
||||||
|
|
||||||
dbInitInFlight = (async () => {
|
dbInitInFlight = (async () => {
|
||||||
try {
|
try {
|
||||||
|
if (wcdbBootstrapPromise) await wcdbBootstrapPromise
|
||||||
const trimmedKey = String(key || '').trim()
|
const trimmedKey = String(key || '').trim()
|
||||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
@@ -216,17 +384,33 @@ app.whenReady().then(async () => {
|
|||||||
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
|
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
|
||||||
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||||
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
|
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
|
||||||
saveSettings({ ...settings, dbRoot: resolvedRoot })
|
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
||||||
|
saveSettings({
|
||||||
|
...settings,
|
||||||
|
dbRoot: resolvedRoot,
|
||||||
|
imageKeyRoot: resolvedRoot
|
||||||
|
})
|
||||||
}
|
}
|
||||||
chat.setChatDb(nextWechatDb)
|
chat.setChatDb(nextWechatDb)
|
||||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||||
|
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||||
voiceService = new VoiceService(wcdb4Client)
|
voiceService = new VoiceService(wcdb4Client)
|
||||||
stickerService = new StickerService(wcdb4Client)
|
stickerService = new StickerService(wcdb4Client)
|
||||||
|
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||||
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
||||||
|
wcdb4Client.invalidateSessionCache()
|
||||||
|
recallArchiveMonitor?.handleDatabaseChange(json)
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
setImmediate(() => {
|
||||||
|
const recentSession = wcdb4Client.getSessions()[0]
|
||||||
|
if (!recentSession?.username) return
|
||||||
|
void wcdb4Client
|
||||||
|
.getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 })
|
||||||
|
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
|
||||||
|
})
|
||||||
imageDecryptService = null
|
imageDecryptService = null
|
||||||
return { success: true, monitoring }
|
return { success: true, monitoring }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -297,7 +481,10 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
const self = chat.getSelfAccountInfo()
|
const self = chat.getSelfAccountInfo()
|
||||||
const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot
|
// 优先级:chat 真实识别到的根 → self.accountRoot → settings.imageKeyRoot → settings.dbRoot
|
||||||
|
// 必须先看 chat.getCurrentAccountRoot(),否则 settings 缓存漂移会导致扫错目录。
|
||||||
|
const accountRoot =
|
||||||
|
chat.getCurrentAccountRoot() || self?.accountRoot || settings.imageKeyRoot || settings.dbRoot
|
||||||
const wxid = self?.wxid
|
const wxid = self?.wxid
|
||||||
const onStatus = (message: string): void => {
|
const onStatus = (message: string): void => {
|
||||||
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
|
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
|
||||||
@@ -364,11 +551,26 @@ app.whenReady().then(async () => {
|
|||||||
return getBootstrapCache(chat.getCurrentAccountRoot())
|
return getBootstrapCache(chat.getCurrentAccountRoot())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('db:getStartupCache', () => {
|
||||||
|
const settings = loadSettings()
|
||||||
|
return settings.dbRoot ? getBootstrapCache(settings.dbRoot) : null
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'db:getCachedMessages',
|
'db:getCachedMessages',
|
||||||
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||||
if (!chat.isReady()) return []
|
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
|
||||||
return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime)
|
return accountRoot ? getCachedMessages(accountRoot, userMd5, startTime, endTime) : []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'db:getCachedMessagePage',
|
||||||
|
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||||
|
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
|
||||||
|
return accountRoot
|
||||||
|
? getCachedMessagePage(accountRoot, userMd5, startTime, endTime)
|
||||||
|
: { hit: false, messages: [] }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -391,8 +593,14 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'db:getMessages',
|
'db:getMessages',
|
||||||
(_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
|
async (
|
||||||
const messages = chat.listMessages(userMd5, startTime, endTime, options)
|
_,
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: { limit?: number }
|
||||||
|
) => {
|
||||||
|
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
|
||||||
if (chat.isReady()) {
|
if (chat.isReady()) {
|
||||||
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
||||||
}
|
}
|
||||||
@@ -400,7 +608,13 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
|
ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
|
||||||
|
const snapshot = await chat.getGroupSnapshotAsync(userMd5)
|
||||||
|
if (snapshot && chat.isReady()) {
|
||||||
|
saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||||
|
|
||||||
@@ -443,6 +657,23 @@ app.whenReady().then(async () => {
|
|||||||
return exportGroupReport(request)
|
return exportGroupReport(request)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('export:start', async (event, request: ExportRequest) => {
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
if (!window) return { success: false, error: '窗口不可用' }
|
||||||
|
return runExport(request, window)
|
||||||
|
})
|
||||||
|
ipcMain.handle('export:cancel', (_, jobId: string) => {
|
||||||
|
cancelExport(jobId)
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
ipcMain.handle('export:reveal', async (_, path: string) => {
|
||||||
|
try {
|
||||||
|
await revealExport(path)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) }
|
||||||
|
}
|
||||||
|
})
|
||||||
ipcMain.handle('report:listGenerated', async () => {
|
ipcMain.handle('report:listGenerated', async () => {
|
||||||
return listGeneratedReports()
|
return listGeneratedReports()
|
||||||
})
|
})
|
||||||
@@ -485,7 +716,7 @@ app.whenReady().then(async () => {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
_sessionId?: string,
|
_sessionId?: string,
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||||
) => {
|
) => {
|
||||||
void _sessionId
|
void _sessionId
|
||||||
if (!imageDecryptService) {
|
if (!imageDecryptService) {
|
||||||
@@ -502,12 +733,28 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||||
const force = options?.force === true
|
const force = options?.force === true
|
||||||
|
const preferThumbnail = options?.preferThumbnail === true
|
||||||
|
const imageCacheKey = [
|
||||||
|
imageMd5 || '',
|
||||||
|
imageDatName || '',
|
||||||
|
force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto'
|
||||||
|
].join('|')
|
||||||
|
const cachedImage = imageDecryptService.getCachedDecodedImage(imageCacheKey)
|
||||||
|
if (cachedImage) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: cachedImage.data,
|
||||||
|
isThumb: cachedImage.isThumbnail,
|
||||||
|
filePath: cachedImage.filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
let filePath = force
|
let filePath = force
|
||||||
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
|
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
|
||||||
: null
|
: null
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
|
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
|
||||||
allowThumbnail: true
|
allowThumbnail: true,
|
||||||
|
preferThumbnail
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
@@ -519,12 +766,18 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: '图片解密失败' }
|
return { success: false, error: '图片解密失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
success: true,
|
success: true,
|
||||||
data: decrypted.data,
|
data: decrypted.data,
|
||||||
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
|
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
|
||||||
filePath: decrypted.filePath
|
filePath: decrypted.filePath
|
||||||
}
|
}
|
||||||
|
imageDecryptService.cacheDecodedImage(imageCacheKey, {
|
||||||
|
data: result.data,
|
||||||
|
filePath: result.filePath,
|
||||||
|
isThumbnail: result.isThumb
|
||||||
|
})
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -623,6 +876,15 @@ app.whenReady().then(async () => {
|
|||||||
return stickerService.resolveSticker(cdnUrl, md5)
|
return stickerService.resolveSticker(cdnUrl, md5)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('db:getVideo', async (_, hashes: string[]) => {
|
||||||
|
if (!videoAssetService) {
|
||||||
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
|
if (!client) return { success: false, error: '数据库尚未连接' }
|
||||||
|
videoAssetService = new VideoAssetService(client)
|
||||||
|
}
|
||||||
|
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [])
|
||||||
|
})
|
||||||
|
|
||||||
// -------- Settings & API service --------
|
// -------- Settings & API service --------
|
||||||
|
|
||||||
ipcMain.handle('settings:get', () => ({
|
ipcMain.handle('settings:get', () => ({
|
||||||
@@ -637,13 +899,22 @@ app.whenReady().then(async () => {
|
|||||||
const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40'
|
const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40'
|
||||||
const aesKey = patch.imageAesKey ?? current.aesKey ?? ''
|
const aesKey = patch.imageAesKey ?? current.aesKey ?? ''
|
||||||
const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch
|
const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch
|
||||||
|
const nextSettings = saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
||||||
if (includesImageKey) {
|
if (includesImageKey) {
|
||||||
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
|
||||||
if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey })
|
if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey })
|
||||||
else imageKeyConfigService.clear()
|
else imageKeyConfigService.clear()
|
||||||
imageDecryptService = null
|
imageDecryptService = null
|
||||||
} else {
|
}
|
||||||
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
if ('recallProtectionEnabled' in patch && chat.isReady()) {
|
||||||
|
const currentDb = chat.getChatDb()
|
||||||
|
if (currentDb) {
|
||||||
|
configureRecallProtection(
|
||||||
|
currentDb.getWcdb4Client(),
|
||||||
|
chat.getCurrentAccountRoot(),
|
||||||
|
nextSettings.recallProtectionEnabled,
|
||||||
|
nextSettings.recallProtectionEnabled && !before.recallProtectionEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
settings: imageKeyConfigService.getLegacySettingsView(),
|
settings: imageKeyConfigService.getLegacySettingsView(),
|
||||||
@@ -665,6 +936,11 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
|
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
|
||||||
const ok = chat.reopenWithRoot(accountRoot)
|
const ok = chat.reopenWithRoot(accountRoot)
|
||||||
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
||||||
|
// 同步 imageKeyRoot,避免自动获取扫描到旧目录
|
||||||
|
const settings = loadSettings()
|
||||||
|
if (accountRoot && accountRoot !== settings.imageKeyRoot) {
|
||||||
|
saveSettings({ ...settings, imageKeyRoot: accountRoot })
|
||||||
|
}
|
||||||
const info = chat.getSelfAccountInfo()
|
const info = chat.getSelfAccountInfo()
|
||||||
return { success: true, info }
|
return { success: true, info }
|
||||||
})
|
})
|
||||||
@@ -750,9 +1026,7 @@ app.whenReady().then(async () => {
|
|||||||
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
||||||
})
|
})
|
||||||
|
|
||||||
createWindow()
|
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
|
||||||
|
|
||||||
// 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗)
|
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
if (settings.apiEnabled) {
|
if (settings.apiEnabled) {
|
||||||
await apiServer.start(settings.apiHost, settings.apiPort)
|
await apiServer.start(settings.apiHost, settings.apiPort)
|
||||||
@@ -766,15 +1040,15 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.on('activate', function () {
|
app.on('activate', function () {
|
||||||
// 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝
|
// 在 macOS 上点击 Dock 图标且没有其他窗口打开时,
|
||||||
// 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€?
|
// 通常会在应用程序中重新创建一个窗口。
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛?
|
// 除 macOS 外,所有窗口关闭时退出应用。在 macOS 上,
|
||||||
// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛
|
// 应用程序及其菜单栏通常会保持活动状态,直到用户
|
||||||
// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€?
|
// 明确使用 Cmd + Q 退出。
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (TRAY_MODE) return
|
if (TRAY_MODE) return
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
@@ -784,6 +1058,7 @@ app.on('window-all-closed', () => {
|
|||||||
|
|
||||||
app.on('before-quit', async () => {
|
app.on('before-quit', async () => {
|
||||||
agentHubService.stop()
|
agentHubService.stop()
|
||||||
|
flushBootstrapCacheWritesSync()
|
||||||
chat.setChatDb(null)
|
chat.setChatDb(null)
|
||||||
await apiServer.stop().catch(() => undefined)
|
await apiServer.stop().catch(() => undefined)
|
||||||
if (tray) {
|
if (tray) {
|
||||||
|
|||||||
@@ -889,7 +889,8 @@ export class KeyService {
|
|||||||
const dirName = normalized.split(/[\\/]/).pop() ?? ''
|
const dirName = normalized.split(/[\\/]/).pop() ?? ''
|
||||||
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
||||||
|
|
||||||
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
|
// 仅支持 WeChat 4.0:路径识别只匹配 xwechat_files
|
||||||
|
const marker = normalized.match(/[\\/]xwechat_files/i)
|
||||||
if (marker) {
|
if (marker) {
|
||||||
const root = normalized.slice(0, marker.index! + marker[0].length)
|
const root = normalized.slice(0, marker.index! + marker[0].length)
|
||||||
try {
|
try {
|
||||||
@@ -934,6 +935,9 @@ export class KeyService {
|
|||||||
onProgress?.('正在查找模板文件...')
|
onProgress?.('正在查找模板文件...')
|
||||||
let result = await this._findTemplateData(userDir, 32)
|
let result = await this._findTemplateData(userDir, 32)
|
||||||
let { ciphertext, xorKey } = result
|
let { ciphertext, xorKey } = result
|
||||||
|
const firstDiag = (this as { _imageTemplateDiag?: {
|
||||||
|
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
|
||||||
|
} })._imageTemplateDiag
|
||||||
|
|
||||||
// 如果找不到密钥,尝试扫描更多文件
|
// 如果找不到密钥,尝试扫描更多文件
|
||||||
if (ciphertext && xorKey === null) {
|
if (ciphertext && xorKey === null) {
|
||||||
@@ -942,7 +946,38 @@ export class KeyService {
|
|||||||
xorKey = result.xorKey
|
xorKey = result.xorKey
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
if (!ciphertext) {
|
||||||
|
// 用诊断信息给具体提示
|
||||||
|
const diag = (this as { _imageTemplateDiag?: {
|
||||||
|
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
|
||||||
|
} })._imageTemplateDiag || firstDiag
|
||||||
|
if (!diag || diag.totalTFiles === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'在账号目录下未找到任何 _t.dat 图片文件。\n' +
|
||||||
|
`扫描路径:${diag?.userDir || userDir || '(空)'}\n` +
|
||||||
|
'原因:微信没在本地生成缩略图。\n' +
|
||||||
|
'请让用户在微信里打开任意聊天的图片大图(等"原图"按钮可点击),然后再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (diag.v2Count === 0 && diag.nonV2Count > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`找到 ${diag.totalTFiles} 个 _t.dat,但都不是 V2 头(可能图片尚未解密到本地,或微信版本不同)。\n` +
|
||||||
|
`扫描路径:${diag.userDir}\n` +
|
||||||
|
'请让用户在微信里打开 2-3 张不同的图片大图,等"原图"按钮可点击后再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`找到 ${diag.totalTFiles} 个 _t.dat,其中 ${diag.v2Count} 个是 V2 头,但没有长度 ≥ 0x1F 的有效模板。\n` +
|
||||||
|
`扫描路径:${diag.userDir}\n` +
|
||||||
|
'请在微信中查看更多图片后再试。'
|
||||||
|
}
|
||||||
|
}
|
||||||
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
|
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
|
||||||
|
|
||||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||||
@@ -1005,6 +1040,8 @@ export class KeyService {
|
|||||||
|
|
||||||
let ciphertext: Buffer | null = null
|
let ciphertext: Buffer | null = null
|
||||||
const tailCounts: Record<string, number> = {}
|
const tailCounts: Record<string, number> = {}
|
||||||
|
let v2Count = 0
|
||||||
|
let nonV2Count = 0
|
||||||
|
|
||||||
for (const f of files.slice(0, 32)) {
|
for (const f of files.slice(0, 32)) {
|
||||||
try {
|
try {
|
||||||
@@ -1013,8 +1050,11 @@ export class KeyService {
|
|||||||
|
|
||||||
// 统计末尾两字节用于 XOR 密钥
|
// 统计末尾两字节用于 XOR 密钥
|
||||||
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
||||||
|
v2Count++
|
||||||
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
||||||
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
||||||
|
} else {
|
||||||
|
nonV2Count++
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取密文(取第一个有效的)
|
// 提取密文(取第一个有效的)
|
||||||
@@ -1031,6 +1071,15 @@ export class KeyService {
|
|||||||
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
|
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么
|
||||||
|
const diag = {
|
||||||
|
userDir,
|
||||||
|
totalTFiles: files.length,
|
||||||
|
v2Count,
|
||||||
|
nonV2Count
|
||||||
|
}
|
||||||
|
;(this as { _imageTemplateDiag?: unknown })._imageTemplateDiag = diag
|
||||||
|
|
||||||
return { ciphertext, xorKey }
|
return { ciphertext, xorKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ type ShareContent = {
|
|||||||
appname?: string
|
appname?: string
|
||||||
typeVal?: string
|
typeVal?: string
|
||||||
}
|
}
|
||||||
|
type MiniProgramContent = {
|
||||||
|
type: 'miniProgram'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
appName?: string
|
||||||
|
iconUrl?: string
|
||||||
|
thumbMd5?: string
|
||||||
|
thumbDatName?: string
|
||||||
|
thumbDataUrl?: string
|
||||||
|
}
|
||||||
|
type RedPacketContent = {
|
||||||
|
type: 'redPacket'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
type ImageContent = {
|
type ImageContent = {
|
||||||
type: 'image'
|
type: 'image'
|
||||||
@@ -24,6 +40,15 @@ type ImageContent = {
|
|||||||
aeskey?: string
|
aeskey?: string
|
||||||
encrypVer?: number
|
encrypVer?: number
|
||||||
}
|
}
|
||||||
|
type VideoContent = {
|
||||||
|
type: 'video'
|
||||||
|
md5?: string
|
||||||
|
newMd5?: string
|
||||||
|
rawMd5?: string
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
type StickerContent = {
|
type StickerContent = {
|
||||||
type: 'sticker'
|
type: 'sticker'
|
||||||
md5?: string
|
md5?: string
|
||||||
@@ -40,8 +65,23 @@ type QuoteContent = {
|
|||||||
quotedContent?: string
|
quotedContent?: string
|
||||||
quotedSender?: string
|
quotedSender?: string
|
||||||
quotedType?: string
|
quotedType?: string
|
||||||
|
quotedImageMd5?: string
|
||||||
|
quotedImageDatName?: string
|
||||||
|
}
|
||||||
|
type SystemContent = {
|
||||||
|
type: 'system'
|
||||||
|
content: string
|
||||||
|
raw?: string
|
||||||
|
pat?: boolean
|
||||||
|
recall?: {
|
||||||
|
targetId?: string
|
||||||
|
targetIds?: string[]
|
||||||
|
replacement: string
|
||||||
|
actor?: string
|
||||||
|
sessionId?: string
|
||||||
|
recallTime?: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
type SystemContent = { type: 'system'; content: string; raw?: string }
|
|
||||||
type UnknownContent = { type: 'unknown'; raw: string }
|
type UnknownContent = { type: 'unknown'; raw: string }
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
@@ -50,8 +90,11 @@ export type ParsedContent =
|
|||||||
| LocationContent
|
| LocationContent
|
||||||
| CardContent
|
| CardContent
|
||||||
| ShareContent
|
| ShareContent
|
||||||
|
| MiniProgramContent
|
||||||
|
| RedPacketContent
|
||||||
| VoipContent
|
| VoipContent
|
||||||
| ImageContent
|
| ImageContent
|
||||||
|
| VideoContent
|
||||||
| StickerContent
|
| StickerContent
|
||||||
| QuoteContent
|
| QuoteContent
|
||||||
| SystemContent
|
| SystemContent
|
||||||
@@ -69,6 +112,8 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
return parseImageMessage(normalized)
|
return parseImageMessage(normalized)
|
||||||
case 42:
|
case 42:
|
||||||
return parseCardMessage(normalized)
|
return parseCardMessage(normalized)
|
||||||
|
case 43:
|
||||||
|
return parseVideoMessage(normalized)
|
||||||
case 47:
|
case 47:
|
||||||
return parseStickerMessage(normalized)
|
return parseStickerMessage(normalized)
|
||||||
case 48:
|
case 48:
|
||||||
@@ -85,9 +130,31 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseVideoMessage(content: string): ParsedContent {
|
||||||
|
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||||
|
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
||||||
|
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
|
||||||
|
const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5'))
|
||||||
|
if (!md5 && !newMd5 && !rawMd5) return { type: 'unknown', raw: content }
|
||||||
|
|
||||||
|
const duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined
|
||||||
|
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
|
||||||
|
const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || undefined
|
||||||
|
return { type: 'video', md5, newMd5, rawMd5, duration, width, height }
|
||||||
|
}
|
||||||
|
|
||||||
function parseSystemMessage(content: string): ParsedContent {
|
function parseSystemMessage(content: string): ParsedContent {
|
||||||
const stripped = stripChatroomPrefix(content)
|
const stripped = stripChatroomPrefix(content)
|
||||||
const decoded = decodeXmlEntities(stripped)
|
const decoded = decodeXmlEntities(stripped)
|
||||||
|
const recall = extractRecallMessage(decoded)
|
||||||
|
if (recall) {
|
||||||
|
return {
|
||||||
|
type: 'system',
|
||||||
|
content: recall.replacement,
|
||||||
|
raw: content,
|
||||||
|
recall
|
||||||
|
}
|
||||||
|
}
|
||||||
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
||||||
if (delChatroomMemberText) {
|
if (delChatroomMemberText) {
|
||||||
return {
|
return {
|
||||||
@@ -113,6 +180,50 @@ function parseSystemMessage(content: string): ParsedContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractRecallMessage(xml: string):
|
||||||
|
| {
|
||||||
|
targetId?: string
|
||||||
|
targetIds?: string[]
|
||||||
|
replacement: string
|
||||||
|
actor?: string
|
||||||
|
sessionId?: string
|
||||||
|
recallTime?: number
|
||||||
|
}
|
||||||
|
| undefined {
|
||||||
|
if (!/<revokemsg\b/i.test(xml)) return undefined
|
||||||
|
|
||||||
|
const replacement = normalizeSystemText(
|
||||||
|
extractXmlValue(xml, 'replacemsg') ||
|
||||||
|
extractXmlNodeText(xml, 'replacemsg') ||
|
||||||
|
extractXmlValue(xml, 'content') ||
|
||||||
|
extractXmlNodeText(xml, 'content')
|
||||||
|
)
|
||||||
|
if (!replacement) return undefined
|
||||||
|
|
||||||
|
const actorMatch =
|
||||||
|
/^["“](.+?)["”]\s*撤回了一条消息/.exec(replacement) ||
|
||||||
|
/^(.+?)\s*撤回了一条消息/.exec(replacement)
|
||||||
|
|
||||||
|
const targetIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
extractXmlValue(xml, 'newmsgid'),
|
||||||
|
extractXmlValue(xml, 'msgid'),
|
||||||
|
extractXmlValue(xml, 'clientmsgid')
|
||||||
|
].filter(Boolean)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetId: targetIds[0] || undefined,
|
||||||
|
targetIds,
|
||||||
|
replacement,
|
||||||
|
actor: actorMatch?.[1]?.trim() || undefined,
|
||||||
|
sessionId: extractXmlValue(xml, 'session') || undefined,
|
||||||
|
recallTime: Number(extractXmlValue(xml, 'revoketime')) || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseImageMessage(content: string): ParsedContent {
|
function parseImageMessage(content: string): ParsedContent {
|
||||||
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
||||||
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
||||||
@@ -301,6 +412,10 @@ function parseLocationMessage(content: string): ParsedContent {
|
|||||||
|
|
||||||
function parseShareMessage(content: string): ParsedContent {
|
function parseShareMessage(content: string): ParsedContent {
|
||||||
const appMsgType = extractAppMsgType(content)
|
const appMsgType = extractAppMsgType(content)
|
||||||
|
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
||||||
|
const sticker = parseStickerMessage(content)
|
||||||
|
if (sticker.type === 'sticker') return sticker
|
||||||
|
}
|
||||||
if (appMsgType === '57' || content.includes('<refermsg>')) {
|
if (appMsgType === '57' || content.includes('<refermsg>')) {
|
||||||
const quote = parseQuoteMessage(content)
|
const quote = parseQuoteMessage(content)
|
||||||
const title = extractXmlValue(content, 'title') || undefined
|
const title = extractXmlValue(content, 'title') || undefined
|
||||||
@@ -310,7 +425,34 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
content: title,
|
content: title,
|
||||||
quotedContent: quote.content || '[引用消息]',
|
quotedContent: quote.content || '[引用消息]',
|
||||||
quotedSender: quote.sender,
|
quotedSender: quote.sender,
|
||||||
quotedType: quote.type
|
quotedType: quote.type,
|
||||||
|
quotedImageMd5: quote.imageMd5,
|
||||||
|
quotedImageDatName: quote.imageDatName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appMsgType === '33' || appMsgType === '36') {
|
||||||
|
return {
|
||||||
|
type: 'miniProgram',
|
||||||
|
title: extractXmlValue(content, 'title') || '小程序',
|
||||||
|
description: extractXmlValue(content, 'des') || undefined,
|
||||||
|
appName:
|
||||||
|
extractXmlValue(content, 'sourcedisplayname') ||
|
||||||
|
extractXmlValue(content, 'appname') ||
|
||||||
|
'小程序',
|
||||||
|
iconUrl: decodeXmlUrl(extractXmlValue(content, 'weappiconurl')) || undefined,
|
||||||
|
thumbMd5: normalizeMd5(
|
||||||
|
extractXmlValue(content, 'cdnthumbmd5') || extractXmlValue(content, 'md5')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appMsgType === '2001') {
|
||||||
|
return {
|
||||||
|
type: 'redPacket',
|
||||||
|
title: extractXmlValue(content, 'title') || '微信红包',
|
||||||
|
description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利',
|
||||||
|
url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +469,13 @@ function parseShareMessage(content: string): ParsedContent {
|
|||||||
return { type: 'share', title, des, url, appname, typeVal }
|
return { type: 'share', title, des, url, appname, typeVal }
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
|
function parseQuoteMessage(content: string): {
|
||||||
|
content?: string
|
||||||
|
sender?: string
|
||||||
|
type?: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
} {
|
||||||
const referMsgStart = content.indexOf('<refermsg>')
|
const referMsgStart = content.indexOf('<refermsg>')
|
||||||
const referMsgEnd = content.indexOf('</refermsg>')
|
const referMsgEnd = content.indexOf('</refermsg>')
|
||||||
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
||||||
@@ -343,8 +491,16 @@ function parseQuoteMessage(content: string): { content?: string; sender?: string
|
|||||||
switch (referType) {
|
switch (referType) {
|
||||||
case '1':
|
case '1':
|
||||||
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
|
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
|
||||||
case '3':
|
case '3': {
|
||||||
return { sender, content: '[图片]', type: referType }
|
const image = parseImageMessage(referContent)
|
||||||
|
return {
|
||||||
|
sender,
|
||||||
|
content: '[图片]',
|
||||||
|
type: referType,
|
||||||
|
imageMd5: image.type === 'image' ? image.md5 : undefined,
|
||||||
|
imageDatName: image.type === 'image' ? image.datName : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
case '34':
|
case '34':
|
||||||
return { sender, content: '[语音]', type: referType }
|
return { sender, content: '[语音]', type: referType }
|
||||||
case '43':
|
case '43':
|
||||||
@@ -372,6 +528,10 @@ function extractAppMsgType(content: string): string {
|
|||||||
const inner = appmsgMatch[1]
|
const inner = appmsgMatch[1]
|
||||||
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||||
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||||
|
.replace(/<weappinfo[\s\S]*?<\/weappinfo>/gi, '')
|
||||||
|
.replace(/<appattach[\s\S]*?<\/appattach>/gi, '')
|
||||||
|
.replace(/<wcpayinfo[\s\S]*?<\/wcpayinfo>/gi, '')
|
||||||
|
.replace(/<findernamecard[\s\S]*?<\/findernamecard>/gi, '')
|
||||||
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
||||||
return typeMatch?.[1]?.trim() || ''
|
return typeMatch?.[1]?.trim() || ''
|
||||||
}
|
}
|
||||||
@@ -433,7 +593,10 @@ function extractXmlValue(xml: string, tagName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||||
const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i')
|
const pattern = new RegExp(
|
||||||
|
`<${tagName}\\b[^>]*?(?:\\s|^)${attrName}\\s*=\\s*["']([^"']*)["']`,
|
||||||
|
'i'
|
||||||
|
)
|
||||||
const match = xml.match(pattern)
|
const match = xml.match(pattern)
|
||||||
return match ? match[1].trim() : ''
|
return match ? match[1].trim() : ''
|
||||||
}
|
}
|
||||||
@@ -550,6 +713,71 @@ export function parseImageDatNameFromRow(row: Record<string, unknown>): string |
|
|||||||
return hexMatch?.[1]?.toLowerCase()
|
return hexMatch?.[1]?.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseImageBufferDataUrlFromRow(
|
||||||
|
row: Record<string, unknown>
|
||||||
|
): string | undefined {
|
||||||
|
const raw = pickRowString(row, [
|
||||||
|
'ImgBuf',
|
||||||
|
'imgBuf',
|
||||||
|
'img_buf',
|
||||||
|
'imageBuffer',
|
||||||
|
'image_buffer',
|
||||||
|
'thumbBuffer',
|
||||||
|
'thumb_buffer',
|
||||||
|
'WCDB_CT_img_buf',
|
||||||
|
'WCDB_CT_ImgBuf'
|
||||||
|
])
|
||||||
|
const buffer = decodeInlineImageBuffer(raw)
|
||||||
|
if (!buffer || buffer.length === 0) return undefined
|
||||||
|
const mime = detectImageMime(buffer)
|
||||||
|
return mime ? `data:${mime};base64,${buffer.toString('base64')}` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeInlineImageBuffer(raw: unknown): Buffer | null {
|
||||||
|
if (!raw) return null
|
||||||
|
if (Buffer.isBuffer(raw)) return raw
|
||||||
|
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||||
|
if (Array.isArray(raw)) return Buffer.from(raw)
|
||||||
|
if (typeof raw === 'object') {
|
||||||
|
const record = raw as { buffer?: unknown; data?: unknown }
|
||||||
|
return decodeInlineImageBuffer(record.buffer ?? record.data)
|
||||||
|
}
|
||||||
|
if (typeof raw !== 'string') return null
|
||||||
|
const value = raw.trim()
|
||||||
|
const dataUrl = /^data:image\/[a-z0-9.+-]+;base64,(.+)$/i.exec(value)
|
||||||
|
const encoded = dataUrl?.[1] || value
|
||||||
|
if (!/^[a-z0-9+/]+={0,2}$/i.test(encoded)) return null
|
||||||
|
try {
|
||||||
|
return Buffer.from(encoded, 'base64')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectImageMime(buffer: Buffer): string | undefined {
|
||||||
|
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||||
|
return 'image/jpeg'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buffer.length >= 8 &&
|
||||||
|
buffer[0] === 0x89 &&
|
||||||
|
buffer.subarray(1, 4).toString('ascii') === 'PNG'
|
||||||
|
) {
|
||||||
|
return 'image/png'
|
||||||
|
}
|
||||||
|
if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString('ascii'))) {
|
||||||
|
return 'image/gif'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buffer.length >= 12 &&
|
||||||
|
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return 'image/webp'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
export function isPackagedRuntime(): boolean {
|
||||||
|
if (app.isPackaged) return true
|
||||||
|
|
||||||
|
return (
|
||||||
|
existsSync(join(process.resourcesPath, 'app.asar')) &&
|
||||||
|
existsSync(join(process.resourcesPath, 'app-update.yml'))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
import type { AppSettings } from './settings-store'
|
import type { AppSettings } from './settings-store'
|
||||||
import { generateAgentGroupReport } from './agent-group-report-service'
|
import { generateAgentGroupReport } from './agent-group-report-service'
|
||||||
import { AIProviderService } from './ai-provider-service'
|
import { AIProviderService } from './ai-provider-service'
|
||||||
|
import { isPackagedRuntime } from '../runtime-mode'
|
||||||
import {
|
import {
|
||||||
getGroupSnapshot,
|
getGroupSnapshot,
|
||||||
isReady,
|
isReady,
|
||||||
@@ -68,7 +69,7 @@ const agentAIProvider = new AIProviderService()
|
|||||||
function resolveBundledBinary(
|
function resolveBundledBinary(
|
||||||
resourceSegments: string[],
|
resourceSegments: string[],
|
||||||
executable: string,
|
executable: string,
|
||||||
packaged = app.isPackaged,
|
packaged = isPackagedRuntime(),
|
||||||
platform = process.platform,
|
platform = process.platform,
|
||||||
arch = process.arch
|
arch = process.arch
|
||||||
): string {
|
): string {
|
||||||
@@ -80,7 +81,7 @@ function resolveBundledBinary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWechatConnectorBinaryPath(
|
export function resolveWechatConnectorBinaryPath(
|
||||||
packaged = app.isPackaged,
|
packaged = isPackagedRuntime(),
|
||||||
platform = process.platform,
|
platform = process.platform,
|
||||||
arch = process.arch
|
arch = process.arch
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export class AIProviderService {
|
|||||||
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
|
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
|
||||||
|
|
||||||
list(): AIProviderListResult {
|
list(): AIProviderListResult {
|
||||||
this.ensureEnvironmentMigration()
|
|
||||||
try {
|
try {
|
||||||
const data = this.readMetadata()
|
const data = this.readMetadata()
|
||||||
return {
|
return {
|
||||||
@@ -338,18 +337,6 @@ export class AIProviderService {
|
|||||||
if (changed) this.writeMetadata(data)
|
if (changed) this.writeMetadata(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureEnvironmentMigration(): void {
|
|
||||||
const data = this.readMetadata()
|
|
||||||
if (data.providers.length) return
|
|
||||||
const apiKey = String(import.meta.env.VITE_DEEPSEEK_API_KEY || '').trim()
|
|
||||||
if (!apiKey) return
|
|
||||||
this.migrateLegacy({
|
|
||||||
apiKey,
|
|
||||||
baseUrl: String(import.meta.env.VITE_AI_BASE_URL || ''),
|
|
||||||
model: String(import.meta.env.VITE_AI_MODEL || '')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private toSummary(
|
private toSummary(
|
||||||
provider: Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>,
|
provider: Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>,
|
||||||
defaultProviderId?: string
|
defaultProviderId?: string
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
import { autoUpdater, type ProgressInfo } from 'electron-updater'
|
||||||
|
import type { AppUpdateCheckResult, AppUpdateState } from '../../shared/app-update'
|
||||||
|
import { isPackagedRuntime } from '../runtime-mode'
|
||||||
|
|
||||||
|
export class AppUpdateService {
|
||||||
|
private state: AppUpdateState = {
|
||||||
|
status: 'idle',
|
||||||
|
currentVersion: app.getVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
autoUpdater.autoDownload = false
|
||||||
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
|
autoUpdater.on('checking-for-update', () => this.setState({ status: 'checking' }))
|
||||||
|
autoUpdater.on('update-available', (info) =>
|
||||||
|
this.setState({ status: 'available', version: info.version, message: '发现新版本' })
|
||||||
|
)
|
||||||
|
autoUpdater.on('update-not-available', () =>
|
||||||
|
this.setState({ status: 'not-available', message: '当前已是最新版本' })
|
||||||
|
)
|
||||||
|
autoUpdater.on('download-progress', (progress: ProgressInfo) =>
|
||||||
|
this.setState({
|
||||||
|
status: 'downloading',
|
||||||
|
percent: progress.percent,
|
||||||
|
transferred: progress.transferred,
|
||||||
|
total: progress.total,
|
||||||
|
bytesPerSecond: progress.bytesPerSecond
|
||||||
|
})
|
||||||
|
)
|
||||||
|
autoUpdater.on('update-downloaded', (info) =>
|
||||||
|
this.setState({
|
||||||
|
status: 'downloaded',
|
||||||
|
version: info.version,
|
||||||
|
percent: 100,
|
||||||
|
message: '更新已下载'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
autoUpdater.on('error', (error) =>
|
||||||
|
this.setState({ status: 'error', message: error.message || '更新失败' })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): AppUpdateState {
|
||||||
|
return { ...this.state, currentVersion: app.getVersion() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(): Promise<AppUpdateCheckResult> {
|
||||||
|
if (!isPackagedRuntime()) {
|
||||||
|
const state = this.setState({
|
||||||
|
status: 'unsupported',
|
||||||
|
message: '开发模式不执行安装包更新,请在正式安装包中检查更新'
|
||||||
|
})
|
||||||
|
return { success: false, state }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await autoUpdater.checkForUpdates()
|
||||||
|
if (result?.updateInfo.version) {
|
||||||
|
this.setState({
|
||||||
|
status: 'available',
|
||||||
|
version: result.updateInfo.version,
|
||||||
|
message: '发现新版本'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { success: true, state: this.getState() }
|
||||||
|
} catch (error) {
|
||||||
|
const state = this.setState({
|
||||||
|
status: 'error',
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return { success: false, state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(): Promise<AppUpdateCheckResult> {
|
||||||
|
if (!isPackagedRuntime()) {
|
||||||
|
const state = this.setState({ status: 'unsupported', message: '开发模式不能下载更新' })
|
||||||
|
return { success: false, state }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.setState({ status: 'downloading', percent: 0 })
|
||||||
|
await autoUpdater.downloadUpdate()
|
||||||
|
return { success: true, state: this.getState() }
|
||||||
|
} catch (error) {
|
||||||
|
const state = this.setState({
|
||||||
|
status: 'error',
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
return { success: false, state }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
install(): { success: boolean; error?: string } {
|
||||||
|
if (this.state.status !== 'downloaded') {
|
||||||
|
return { success: false, error: '更新包尚未下载完成' }
|
||||||
|
}
|
||||||
|
autoUpdater.quitAndInstall()
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
handleState(callback: (state: AppUpdateState) => void): () => void {
|
||||||
|
this.listeners.add(callback)
|
||||||
|
callback(this.getState())
|
||||||
|
return () => this.listeners.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private listeners = new Set<(state: AppUpdateState) => void>()
|
||||||
|
|
||||||
|
private setState(patch: Partial<AppUpdateState>): AppUpdateState {
|
||||||
|
this.state = { ...this.state, ...patch, currentVersion: app.getVersion() }
|
||||||
|
const state = this.getState()
|
||||||
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
|
if (!window.isDestroyed()) window.webContents.send('app-update:state', state)
|
||||||
|
}
|
||||||
|
for (const listener of this.listeners) listener(state)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appUpdateService = new AppUpdateService()
|
||||||
@@ -11,6 +11,19 @@ export interface CachedSelfInfo {
|
|||||||
accountRoot: string
|
accountRoot: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CachedGroupSnapshot {
|
||||||
|
roomId: string
|
||||||
|
memberCount: number
|
||||||
|
members: {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
interface CachedMessageBucket {
|
interface CachedMessageBucket {
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
startTime?: number
|
startTime?: number
|
||||||
@@ -26,11 +39,12 @@ interface BootstrapCacheFile {
|
|||||||
self?: CachedSelfInfo
|
self?: CachedSelfInfo
|
||||||
contacts?: Contact[]
|
contacts?: Contact[]
|
||||||
messages?: Record<string, CachedMessageBucket>
|
messages?: Record<string, CachedMessageBucket>
|
||||||
|
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_VERSION = 1
|
const CACHE_VERSION = 1
|
||||||
const MAX_MESSAGE_BUCKETS = 24
|
const MAX_MESSAGE_BUCKETS = 768
|
||||||
const MAX_MESSAGES_PER_BUCKET = 1200
|
const MAX_MESSAGES_PER_BUCKET = 120
|
||||||
const WRITE_DEBOUNCE_MS = 300
|
const WRITE_DEBOUNCE_MS = 300
|
||||||
const memoryCache = new Map<string, BootstrapCacheFile>()
|
const memoryCache = new Map<string, BootstrapCacheFile>()
|
||||||
const writeTimers = new Map<string, NodeJS.Timeout>()
|
const writeTimers = new Map<string, NodeJS.Timeout>()
|
||||||
@@ -73,7 +87,9 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
|||||||
updatedAt: Number(raw.updatedAt) || 0,
|
updatedAt: Number(raw.updatedAt) || 0,
|
||||||
self: raw.self,
|
self: raw.self,
|
||||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
||||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
|
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {},
|
||||||
|
groupSnapshots:
|
||||||
|
raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {}
|
||||||
}
|
}
|
||||||
memoryCache.set(file, result)
|
memoryCache.set(file, result)
|
||||||
return result
|
return result
|
||||||
@@ -122,7 +138,8 @@ function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
|||||||
accountRoot: normalizedRoot,
|
accountRoot: normalizedRoot,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
contacts: [],
|
contacts: [],
|
||||||
messages: {}
|
messages: {},
|
||||||
|
groupSnapshots: {}
|
||||||
}
|
}
|
||||||
memoryCache.set(getCacheFile(normalizedRoot), created)
|
memoryCache.set(getCacheFile(normalizedRoot), created)
|
||||||
return created
|
return created
|
||||||
@@ -132,6 +149,33 @@ function messageBucketKey(userMd5: string, startTime?: number, endTime?: number)
|
|||||||
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cachedMessageIdentity(message: Message): string {
|
||||||
|
if (message.localId) return `local:${message.localId}`
|
||||||
|
if (message.serverId) return `server:${message.serverId}`
|
||||||
|
return `id:${message.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
|
||||||
|
return items.some((message) => {
|
||||||
|
const content = message.contentData
|
||||||
|
if (content?.type === 'system' && content.raw) {
|
||||||
|
return (
|
||||||
|
/<weappinfo\b/i.test(content.raw) &&
|
||||||
|
/<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
content?.type === 'share' &&
|
||||||
|
((content.typeVal === '3' && !content.url) || content.typeVal === '2001')
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (content?.type !== 'sticker') return false
|
||||||
|
const url = String(content.url || content.thumbUrl || '')
|
||||||
|
return /wxapp\.tenpay\.com\/mmpayhb|mp\.weixin\.qq\.com\/mp\/waerrpage/i.test(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
||||||
const entries = Object.entries(messages)
|
const entries = Object.entries(messages)
|
||||||
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
||||||
@@ -260,6 +304,78 @@ export function getCachedMessages(
|
|||||||
return bucket?.items || []
|
return bucket?.items || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCachedMessagePage(
|
||||||
|
accountRoot: string,
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
|
||||||
|
const cache = readCacheFile(accountRoot)
|
||||||
|
const key = messageBucketKey(userMd5, startTime, endTime)
|
||||||
|
let bucket = cache?.messages?.[key]
|
||||||
|
if (!bucket && cache?.messages && startTime === undefined && endTime === undefined) {
|
||||||
|
const merged = new Map<string, Message>()
|
||||||
|
for (const [cachedKey, candidate] of Object.entries(cache.messages)) {
|
||||||
|
if (!cachedKey.startsWith(`${userMd5}:`)) continue
|
||||||
|
for (const message of candidate.items || []) {
|
||||||
|
merged.set(cachedMessageIdentity(message), message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const migratedMessages = Array.from(merged.values())
|
||||||
|
.sort((left, right) => (left.createTime || 0) - (right.createTime || 0))
|
||||||
|
.slice(-MAX_MESSAGES_PER_BUCKET)
|
||||||
|
if (migratedMessages.length > 0) {
|
||||||
|
bucket = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
items: migratedMessages
|
||||||
|
}
|
||||||
|
cache.messages[key] = bucket
|
||||||
|
cache.updatedAt = Date.now()
|
||||||
|
pruneMessageBuckets(cache.messages)
|
||||||
|
writeCacheFile(cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []),
|
||||||
|
messages: bucket?.items || [],
|
||||||
|
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCachedGroupSnapshot(
|
||||||
|
accountRoot: string,
|
||||||
|
userMd5: string,
|
||||||
|
snapshot: CachedGroupSnapshot
|
||||||
|
): void {
|
||||||
|
const cache = loadOrCreate(accountRoot)
|
||||||
|
if (!cache) return
|
||||||
|
cache.groupSnapshots ||= {}
|
||||||
|
cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot }
|
||||||
|
cache.updatedAt = Date.now()
|
||||||
|
writeCacheFile(cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flushBootstrapCacheWritesSync(): void {
|
||||||
|
for (const [file, cache] of memoryCache) {
|
||||||
|
const timer = writeTimers.get(file)
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
writeTimers.delete(file)
|
||||||
|
try {
|
||||||
|
fs.ensureDirSync(path.dirname(file))
|
||||||
|
fs.writeFileSync(file, JSON.stringify(cache), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[BootstrapCache] flush failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBootstrapCache(): void {
|
||||||
|
for (const timer of writeTimers.values()) clearTimeout(timer)
|
||||||
|
writeTimers.clear()
|
||||||
|
writeQueues.clear()
|
||||||
|
memoryCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
export function saveCachedMessages(
|
export function saveCachedMessages(
|
||||||
accountRoot: string,
|
accountRoot: string,
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { app, session } from 'electron'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import { clearBootstrapCache } from './bootstrap-cache'
|
||||||
|
import type { CacheClearScope, CacheSummary, CacheSummaryItem } from '../../shared/cache'
|
||||||
|
|
||||||
|
export type { CacheClearScope } from '../../shared/cache'
|
||||||
|
|
||||||
|
const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap')
|
||||||
|
|
||||||
|
function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } {
|
||||||
|
if (!fs.existsSync(directory)) return { sizeBytes: 0, fileCount: 0 }
|
||||||
|
let sizeBytes = 0
|
||||||
|
let fileCount = 0
|
||||||
|
const visit = (current: string): void => {
|
||||||
|
let entries: fs.Dirent[]
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const target = path.join(current, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
visit(target)
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
try {
|
||||||
|
sizeBytes += fs.statSync(target).size
|
||||||
|
fileCount += 1
|
||||||
|
} catch {
|
||||||
|
// A cache file can disappear while it is being inspected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(directory)
|
||||||
|
return { sizeBytes, fileCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCacheSummary(): CacheSummary {
|
||||||
|
const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR)
|
||||||
|
const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache'))
|
||||||
|
const items: CacheSummaryItem[] = [
|
||||||
|
{
|
||||||
|
id: 'bootstrap',
|
||||||
|
label: '启动与聊天缓存',
|
||||||
|
description: '联系人、头像、群成员和最近聊天记录的本地副本。',
|
||||||
|
...bootstrap
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'electron',
|
||||||
|
label: '应用临时缓存',
|
||||||
|
description: 'Electron 页面资源缓存,清理后会自动重新生成。',
|
||||||
|
...electron
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
totalBytes: items.reduce((total, item) => total + item.sizeBytes, 0),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCache(scope: CacheClearScope): Promise<CacheSummary> {
|
||||||
|
if (scope === 'bootstrap' || scope === 'all') {
|
||||||
|
clearBootstrapCache()
|
||||||
|
await fs.remove(BOOTSTRAP_CACHE_DIR)
|
||||||
|
}
|
||||||
|
if (scope === 'electron' || scope === 'all') {
|
||||||
|
await session.defaultSession.clearCache()
|
||||||
|
}
|
||||||
|
return getCacheSummary()
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { WechatDb, WechatMessage } from '../wechat-db'
|
import { WechatDb, WechatMessage } from '../wechat-db'
|
||||||
import {
|
import {
|
||||||
|
parseImageBufferDataUrlFromRow,
|
||||||
parseImageDatNameFromRow,
|
parseImageDatNameFromRow,
|
||||||
parseMessageContent,
|
parseMessageContent,
|
||||||
parseStickerMessageFromRow
|
parseStickerMessageFromRow
|
||||||
@@ -8,6 +9,7 @@ import type {
|
|||||||
DatabaseKeyValidationCode,
|
DatabaseKeyValidationCode,
|
||||||
DatabaseKeyValidationResult
|
DatabaseKeyValidationResult
|
||||||
} from '../../shared/database-key'
|
} from '../../shared/database-key'
|
||||||
|
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
|
||||||
|
|
||||||
export function getCurrentKey(): string {
|
export function getCurrentKey(): string {
|
||||||
if (!dbRef) return ''
|
if (!dbRef) return ''
|
||||||
@@ -33,6 +35,8 @@ export interface FormattedContact {
|
|||||||
md5: string
|
md5: string
|
||||||
type: 'user' | 'group'
|
type: 'user' | 'group'
|
||||||
avatar?: string
|
avatar?: string
|
||||||
|
wechatNickname?: string
|
||||||
|
remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormattedMessage {
|
export interface FormattedMessage {
|
||||||
@@ -48,9 +52,17 @@ export interface FormattedMessage {
|
|||||||
contentData?: ReturnType<typeof parseMessageContent>
|
contentData?: ReturnType<typeof parseMessageContent>
|
||||||
voiceDataUrl?: string
|
voiceDataUrl?: string
|
||||||
voiceDuration?: number
|
voiceDuration?: number
|
||||||
|
exportMediaUrl?: string
|
||||||
|
exportMediaType?: 'image' | 'video' | 'sticker'
|
||||||
|
exportShowAvatar?: boolean
|
||||||
|
exportMediaError?: string
|
||||||
|
exportAvatarUrl?: string
|
||||||
localId?: number
|
localId?: number
|
||||||
|
serverId?: string
|
||||||
createTime?: number
|
createTime?: number
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
|
recalled?: boolean
|
||||||
|
recalledBy?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupSnapshot {
|
export interface GroupSnapshot {
|
||||||
@@ -126,10 +138,15 @@ export function listContacts(filter?: string): FormattedContact[] {
|
|||||||
m_nsNickName: user.nickname || '未知用户',
|
m_nsNickName: user.nickname || '未知用户',
|
||||||
md5,
|
md5,
|
||||||
type: isGroup ? 'group' : 'user',
|
type: isGroup ? 'group' : 'user',
|
||||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
|
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
||||||
|
wechatNickname: user.wechatNickname,
|
||||||
|
remark: user.remark
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The session list already covers normal conversations. Only scan Chat_*
|
||||||
|
// tables as a recovery fallback when the session query returned nothing.
|
||||||
|
if (userList.length === 0) {
|
||||||
const chatTables = dbRef.getAllChatTables()
|
const chatTables = dbRef.getAllChatTables()
|
||||||
for (const table of chatTables) {
|
for (const table of chatTables) {
|
||||||
if (!table.name.startsWith('Chat_')) continue
|
if (!table.name.startsWith('Chat_')) continue
|
||||||
@@ -151,6 +168,7 @@ export function listContacts(filter?: string): FormattedContact[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return contacts
|
return contacts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,11 +181,12 @@ export function getContactAvatars(usernames: string[]): Record<string, string> {
|
|||||||
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMessages(
|
function listSourceMessages(
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number,
|
endTime?: number,
|
||||||
options?: { limit?: number }
|
options?: { limit?: number },
|
||||||
|
rawMessagesOverride?: WechatMessage[]
|
||||||
): FormattedMessage[] {
|
): FormattedMessage[] {
|
||||||
if (!dbRef) return []
|
if (!dbRef) return []
|
||||||
|
|
||||||
@@ -178,7 +197,8 @@ export function listMessages(
|
|||||||
console.log(
|
console.log(
|
||||||
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
|
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
|
||||||
)
|
)
|
||||||
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime, options)
|
const rawMessages =
|
||||||
|
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
|
||||||
console.log(
|
console.log(
|
||||||
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
||||||
)
|
)
|
||||||
@@ -216,17 +236,62 @@ export function listMessages(
|
|||||||
|
|
||||||
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
||||||
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
||||||
|
const rawContent = String(content || '')
|
||||||
|
const isPatMessage =
|
||||||
|
/<patinfo\b|<type>\s*62\s*<\/type>/i.test(rawContent) ||
|
||||||
|
([10000, 10002].includes(msgType) && /拍了拍/i.test(rawContent))
|
||||||
|
if (isPatMessage) {
|
||||||
|
const system = parseMessageContent(content, 10000)
|
||||||
|
const patContent =
|
||||||
|
system.type === 'system'
|
||||||
|
? { ...system, pat: true }
|
||||||
|
: { type: 'system' as const, content: String(content || '').replace(/<[^>]+>/g, '').trim(), pat: true }
|
||||||
|
contentData = patContent
|
||||||
|
content = patContent.content
|
||||||
|
displayType = '系统消息'
|
||||||
|
}
|
||||||
const inferredMsgType =
|
const inferredMsgType =
|
||||||
typeof content === 'string' &&
|
typeof content === 'string' &&
|
||||||
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
||||||
? 49
|
? 49
|
||||||
: msgType
|
: msgType
|
||||||
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||||
try {
|
try {
|
||||||
const parsed =
|
const isQuotePayload = /<refermsg\b/i.test(content)
|
||||||
inferredMsgType === 47
|
const hasStickerPayload =
|
||||||
|
/<(?:emoji|sticker|emoticon)\b/i.test(content) ||
|
||||||
|
/<type>\s*47\s*<\/type>/i.test(content)
|
||||||
|
const rowSticker =
|
||||||
|
inferredMsgType === 47 ||
|
||||||
|
(inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
||||||
? parseStickerMessageFromRow(msg, content)
|
? parseStickerMessageFromRow(msg, content)
|
||||||
: parseMessageContent(content, inferredMsgType)
|
: undefined
|
||||||
|
const parsedContent = parseMessageContent(content, inferredMsgType)
|
||||||
|
const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : ''
|
||||||
|
const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : ''
|
||||||
|
const redPacketUrl = rowStickerUrl || parsedShareUrl
|
||||||
|
const isRedPacketFallback =
|
||||||
|
(parsedContent.type === 'share' && parsedContent.typeVal === '2001') ||
|
||||||
|
/wxapp\.tenpay\.com\/mmpayhb/i.test(redPacketUrl)
|
||||||
|
const parsed: ReturnType<typeof parseMessageContent> =
|
||||||
|
parsedContent.type === 'miniProgram' || parsedContent.type === 'redPacket'
|
||||||
|
? parsedContent
|
||||||
|
: isRedPacketFallback
|
||||||
|
? {
|
||||||
|
type: 'redPacket',
|
||||||
|
title:
|
||||||
|
parsedContent.type === 'share' && parsedContent.title
|
||||||
|
? parsedContent.title
|
||||||
|
: '微信红包',
|
||||||
|
description:
|
||||||
|
parsedContent.type === 'share' && parsedContent.des
|
||||||
|
? parsedContent.des
|
||||||
|
: '恭喜发财,大吉大利',
|
||||||
|
url: redPacketUrl || undefined
|
||||||
|
}
|
||||||
|
: rowSticker?.type === 'sticker'
|
||||||
|
? rowSticker
|
||||||
|
: parsedContent
|
||||||
if (parsed.type === 'system') {
|
if (parsed.type === 'system') {
|
||||||
content = parsed.content
|
content = parsed.content
|
||||||
contentData = parsed
|
contentData = parsed
|
||||||
@@ -236,6 +301,13 @@ export function listMessages(
|
|||||||
if (parsed.type === 'image') {
|
if (parsed.type === 'image') {
|
||||||
const imageDatName = parseImageDatNameFromRow(msg)
|
const imageDatName = parseImageDatNameFromRow(msg)
|
||||||
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
||||||
|
} else if (parsed.type === 'miniProgram') {
|
||||||
|
contentData = {
|
||||||
|
...parsed,
|
||||||
|
thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg),
|
||||||
|
thumbDataUrl:
|
||||||
|
parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
|
||||||
|
}
|
||||||
} else if (parsed.type !== 'system') {
|
} else if (parsed.type !== 'system') {
|
||||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
||||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||||
@@ -245,6 +317,17 @@ export function listMessages(
|
|||||||
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
|
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
|
||||||
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
|
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
|
||||||
}
|
}
|
||||||
|
if (parsed.type === 'quote') displayType = '引用消息'
|
||||||
|
if (parsed.type === 'sticker') displayType = '表情包'
|
||||||
|
if (parsed.type === 'miniProgram') displayType = '小程序'
|
||||||
|
if (parsed.type === 'redPacket') displayType = '微信红包'
|
||||||
|
if (parsed.type === 'share') {
|
||||||
|
if (parsed.typeVal === '5') displayType = '公众号链接'
|
||||||
|
if (parsed.typeVal === '6') displayType = '文件'
|
||||||
|
if (parsed.typeVal === '74') displayType = '文件发送中'
|
||||||
|
if (parsed.typeVal === '51') displayType = '视频号'
|
||||||
|
if (parsed.typeVal === '2000') displayType = '转账'
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
@@ -264,8 +347,12 @@ export function listMessages(
|
|||||||
|
|
||||||
if (msgType === 34) content = '[语音消息]'
|
if (msgType === 34) content = '[语音消息]'
|
||||||
|
|
||||||
|
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: msg.mesLocalID || Math.random().toString(),
|
id: recoveredFromRecallJournal
|
||||||
|
? `recovered:${msg.mesLocalID || msg.serverId || createTime}`
|
||||||
|
: msg.mesLocalID || Math.random().toString(),
|
||||||
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
||||||
isSender: isMine,
|
isSender: isMine,
|
||||||
type: displayType,
|
type: displayType,
|
||||||
@@ -276,7 +363,9 @@ export function listMessages(
|
|||||||
senderId,
|
senderId,
|
||||||
sessionId: username,
|
sessionId: username,
|
||||||
localId,
|
localId,
|
||||||
|
serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined,
|
||||||
createTime,
|
createTime,
|
||||||
|
recoveredFromRecallJournal,
|
||||||
contentData
|
contentData
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -287,6 +376,39 @@ export function listMessages(
|
|||||||
return formatted
|
return formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listMessages(
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: { limit?: number }
|
||||||
|
): FormattedMessage[] {
|
||||||
|
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options)
|
||||||
|
if (!dbRef) return sourceMessages
|
||||||
|
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||||
|
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||||
|
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMessagesAsync(
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: { limit?: number }
|
||||||
|
): Promise<FormattedMessage[]> {
|
||||||
|
if (!dbRef) return []
|
||||||
|
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
|
||||||
|
const sourceMessages = listSourceMessages(
|
||||||
|
userMd5,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
options,
|
||||||
|
rawMessages
|
||||||
|
)
|
||||||
|
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||||
|
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||||
|
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||||
|
}
|
||||||
|
|
||||||
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||||
if (!dbRef) return null
|
if (!dbRef) return null
|
||||||
const wcdb4Client = dbRef.getWcdb4Client()
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
@@ -308,6 +430,25 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
|||||||
return { roomId, memberCount: members.length, members }
|
return { roomId, memberCount: members.length, members }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
|
||||||
|
if (!dbRef) return null
|
||||||
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
|
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
||||||
|
if (!roomId || !roomId.endsWith('@chatroom')) return null
|
||||||
|
|
||||||
|
const members = (await wcdb4Client.getGroupMembersAsync(roomId))
|
||||||
|
.filter((member) => member?.m_nsUsrName)
|
||||||
|
.map((member) => ({
|
||||||
|
wxid: member.m_nsUsrName,
|
||||||
|
nickname: member.nickname || '',
|
||||||
|
groupNickname: member.groupNickname || '',
|
||||||
|
wechatNickname: member.wechatNickname || '',
|
||||||
|
remark: member.remark || '',
|
||||||
|
avatar: member.m_nsHeadImgUrl || ''
|
||||||
|
}))
|
||||||
|
return { roomId, memberCount: members.length, members }
|
||||||
|
}
|
||||||
|
|
||||||
export function searchMessages(keyword: string): string | null {
|
export function searchMessages(keyword: string): string | null {
|
||||||
if (!dbRef) return null
|
if (!dbRef) return null
|
||||||
return dbRef.searchAllMessages(keyword)
|
return dbRef.searchAllMessages(keyword)
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import { isWechatRunning } from './wechat-process-status'
|
|||||||
export async function inspectImageDecryptionStatus(
|
export async function inspectImageDecryptionStatus(
|
||||||
config: ImageKeyConfigResult
|
config: ImageKeyConfigResult
|
||||||
): Promise<ImageDecryptionStatus> {
|
): Promise<ImageDecryptionStatus> {
|
||||||
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot
|
// 状态面板的"图片资源目录"始终等于当前识别到的微信账号根目录;
|
||||||
|
// 仅在微信未连接时回退到上次配置中的 resourceRoot,避免空白。
|
||||||
|
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot || ''
|
||||||
const imageDirectoryFound = hasImageDirectory(accountRoot)
|
const imageDirectoryFound = hasImageDirectory(accountRoot)
|
||||||
const stickerCacheFound =
|
const stickerCacheFound =
|
||||||
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
||||||
@@ -65,7 +67,10 @@ export function testImageDecryption(
|
|||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.contentData?.type === 'image')
|
.find((message) => message.contentData?.type === 'image')
|
||||||
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
|
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
|
||||||
return failure('NO_IMAGE_MESSAGE', '所选聊天最近没有可测试的图片消息')
|
return failure(
|
||||||
|
'NO_IMAGE_MESSAGE',
|
||||||
|
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = new ImageDecryptService(
|
const service = new ImageDecryptService(
|
||||||
@@ -74,26 +79,51 @@ export function testImageDecryption(
|
|||||||
chat.getChatDb()?.getWcdb4Client()
|
chat.getChatDb()?.getWcdb4Client()
|
||||||
)
|
)
|
||||||
const image = imageMessage.contentData
|
const image = imageMessage.contentData
|
||||||
let filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: false })
|
// 测试时优先使用用户在下方"图片资源目录"输入框填写的目录;
|
||||||
|
// 找不到再退回默认 accountDir。
|
||||||
|
const testAccountDir = normalized.resourceRoot || undefined
|
||||||
|
let filePath = service.findImageFile(image.md5, image.datName, {
|
||||||
|
allowThumbnail: false,
|
||||||
|
accountDir: testAccountDir
|
||||||
|
})
|
||||||
if (!filePath)
|
if (!filePath)
|
||||||
filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: true })
|
filePath = service.findImageFile(image.md5, image.datName, {
|
||||||
|
allowThumbnail: true,
|
||||||
|
accountDir: testAccountDir
|
||||||
|
})
|
||||||
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
|
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
|
||||||
|
|
||||||
const data = service.decryptImageToBase64(filePath)
|
const data = service.decryptImageToBase64(filePath)
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
// 三步联动:解密失败 → fileFound/decrypted/readable 都为 false。
|
||||||
return {
|
return {
|
||||||
...failure('DECRYPT_FAILED', '无法解析媒体文件'),
|
success: false,
|
||||||
fileFound: true
|
code: 'DECRYPT_FAILED',
|
||||||
|
error: '无法解析媒体文件',
|
||||||
|
fileFound: false,
|
||||||
|
decrypted: false,
|
||||||
|
readable: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const readable = data.startsWith('data:image/')
|
const readable = data.startsWith('data:image/')
|
||||||
|
if (!readable) {
|
||||||
|
// 三步联动:解密成功但字节流不可读 → 前一步打勾(确实找到了 dat),
|
||||||
|
// 但 decrypted/readable 全为 false,让 UI 表达"找到但解析失败"。
|
||||||
return {
|
return {
|
||||||
success: readable,
|
success: false,
|
||||||
code: readable ? undefined : 'DECRYPT_FAILED',
|
code: 'DECRYPT_FAILED',
|
||||||
error: readable ? undefined : '图片解密结果不可读取',
|
error: '图片解密结果不可读取',
|
||||||
|
fileFound: true,
|
||||||
|
decrypted: false,
|
||||||
|
readable: false,
|
||||||
|
isThumbnail: service.isThumbnailFile(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
fileFound: true,
|
fileFound: true,
|
||||||
decrypted: true,
|
decrypted: true,
|
||||||
readable,
|
readable: true,
|
||||||
isThumbnail: service.isThumbnailFile(filePath)
|
isThumbnail: service.isThumbnailFile(filePath)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -92,9 +92,10 @@ export class ImageKeyConfigService {
|
|||||||
if (!result.success || !result.entry) {
|
if (!result.success || !result.entry) {
|
||||||
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
|
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
|
||||||
}
|
}
|
||||||
|
// 注意:normalized.resourceRoot 不再写回 imageKeyRoot。
|
||||||
|
// 下方的"图片资源目录"输入框仅用于本次手动测试,不再污染状态面板上方显示。
|
||||||
saveSettings({
|
saveSettings({
|
||||||
...settings,
|
...settings,
|
||||||
imageKeyRoot: normalized.resourceRoot,
|
|
||||||
imageXorKey: '',
|
imageXorKey: '',
|
||||||
imageAesKey: '',
|
imageAesKey: '',
|
||||||
imageKeyFallbackDisabled: false
|
imageKeyFallbackDisabled: false
|
||||||
@@ -106,7 +107,9 @@ export class ImageKeyConfigService {
|
|||||||
encryptionAvailable: true,
|
encryptionAvailable: true,
|
||||||
source: 'secure-storage',
|
source: 'secure-storage',
|
||||||
accountId: context.accountId,
|
accountId: context.accountId,
|
||||||
resourceRoot: normalized.resourceRoot,
|
// 返回的 resourceRoot 始终是识别到的默认目录,与状态面板一致;
|
||||||
|
// 不返回 normalized.resourceRoot,避免把用户输入的测试目录当成状态写回。
|
||||||
|
resourceRoot: context.resourceRoot,
|
||||||
xorKey: result.entry.xorKey,
|
xorKey: result.entry.xorKey,
|
||||||
aesKey: result.entry.aesKey,
|
aesKey: result.entry.aesKey,
|
||||||
updatedAt: result.entry.updatedAt
|
updatedAt: result.entry.updatedAt
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import type { Message } from '../../shared/types'
|
||||||
|
|
||||||
|
type RecallRecord = {
|
||||||
|
targetIds: string[]
|
||||||
|
actor?: string
|
||||||
|
noticeId: string
|
||||||
|
createTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchivedMessage = Message & {
|
||||||
|
archivedAt: number
|
||||||
|
recallNotice?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionArchive = {
|
||||||
|
username: string
|
||||||
|
updatedAt: number
|
||||||
|
messages: ArchivedMessage[]
|
||||||
|
recalls: RecallRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecallArchiveFile = {
|
||||||
|
version: 1
|
||||||
|
accountRoot: string
|
||||||
|
updatedAt: number
|
||||||
|
sessions: Record<string, SessionArchive>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARCHIVE_VERSION = 1
|
||||||
|
const MAX_MESSAGES_PER_SESSION = 3000
|
||||||
|
const MAX_RECALLS_PER_SESSION = 500
|
||||||
|
const WRITE_DEBOUNCE_MS = 250
|
||||||
|
|
||||||
|
let archive: RecallArchiveFile | null = null
|
||||||
|
let archivePath = ''
|
||||||
|
let writeTimer: NodeJS.Timeout | null = null
|
||||||
|
let writeQueue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
|
function messageIdentity(message: Message): string {
|
||||||
|
if (message.recoveredFromRecallJournal) {
|
||||||
|
return `recovered:${message.localId || 0}:${message.serverId || message.id}`
|
||||||
|
}
|
||||||
|
if (message.localId) return `local:${message.localId}`
|
||||||
|
if (message.serverId) return `server:${message.serverId}`
|
||||||
|
if (message.id) return `id:${message.id}`
|
||||||
|
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageTargetIds(message: Message): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
message.serverId,
|
||||||
|
message.localId ? String(message.localId) : '',
|
||||||
|
message.id ? String(message.id) : ''
|
||||||
|
].filter((value): value is string => Boolean(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecallNotice(message: Message): boolean {
|
||||||
|
return message.contentData?.type === 'system' && Boolean(message.contentData.recall)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRecall(message: Message, recall: RecallRecord): boolean {
|
||||||
|
const messageIds = messageTargetIds(message)
|
||||||
|
return recall.targetIds.some((targetId) => messageIds.includes(targetId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRecallTarget(
|
||||||
|
messages: Iterable<ArchivedMessage>,
|
||||||
|
notice: Message,
|
||||||
|
actor?: string,
|
||||||
|
claimedTargetIds: Set<string> = new Set()
|
||||||
|
): ArchivedMessage | undefined {
|
||||||
|
const noticeTime = notice.createTime || 0
|
||||||
|
const expectsMine = /^(你|我)$/.test(String(actor || '').trim()) || /^你撤回/.test(notice.content)
|
||||||
|
const eligible = Array.from(messages)
|
||||||
|
.filter((message) => {
|
||||||
|
if (message.type === '系统消息' || message.from === 'system' || message.recallNotice)
|
||||||
|
return false
|
||||||
|
if (!message.recoveredFromRecallJournal) return false
|
||||||
|
if (messageTargetIds(message).some((targetId) => claimedTargetIds.has(targetId))) return false
|
||||||
|
if (noticeTime && message.createTime && message.createTime > noticeTime) return false
|
||||||
|
if (noticeTime && message.createTime && noticeTime - message.createTime > 180) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const timeDelta = (right.createTime || 0) - (left.createTime || 0)
|
||||||
|
if (timeDelta) return timeDelta
|
||||||
|
return (right.localId || 0) - (left.localId || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const strict = eligible.find((message) => {
|
||||||
|
if (expectsMine) return message.isSender
|
||||||
|
if (!actor) return true
|
||||||
|
const names = [message.name, message.senderId].map((value) => String(value || '').trim())
|
||||||
|
return !names.some(Boolean) || names.includes(actor)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Journal rows are captured at deletion time, so the fallback never points at
|
||||||
|
// an unrelated visible message merely because it happened to be nearby.
|
||||||
|
return strict || eligible[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotateFromRecall(message: Message, recalls: RecallRecord[]): Message {
|
||||||
|
if (isRecallNotice(message)) return message
|
||||||
|
const recall = recalls.find((item) => matchesRecall(message, item))
|
||||||
|
if (!recall) return message
|
||||||
|
const recalledByMe = /^(你|我)$/.test(String(recall.actor || '').trim())
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
from: recalledByMe ? 'assistant' : message.from,
|
||||||
|
isSender: recalledByMe ? true : message.isSender,
|
||||||
|
recalled: true,
|
||||||
|
recalledBy: recall.actor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWrite(): void {
|
||||||
|
if (!archive || !archivePath) return
|
||||||
|
if (writeTimer) clearTimeout(writeTimer)
|
||||||
|
writeTimer = setTimeout(() => {
|
||||||
|
writeTimer = null
|
||||||
|
if (!archive || !archivePath) return
|
||||||
|
const targetPath = archivePath
|
||||||
|
const serialized = JSON.stringify(archive)
|
||||||
|
writeQueue = writeQueue
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
await fs.ensureDir(path.dirname(targetPath))
|
||||||
|
const temporaryPath = `${targetPath}.tmp`
|
||||||
|
await fs.writeFile(temporaryPath, serialized, 'utf8')
|
||||||
|
await fs.move(temporaryPath, targetPath, { overwrite: true })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('[RecallArchive] write failed:', error)
|
||||||
|
})
|
||||||
|
}, WRITE_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadArchive(accountRoot: string): RecallArchiveFile {
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha1')
|
||||||
|
.update(`${process.platform}:${accountRoot}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16)
|
||||||
|
archivePath = path.join(app.getPath('userData'), 'recall-archive', `${hash}.json`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(archivePath)) {
|
||||||
|
const stored = fs.readJsonSync(archivePath) as Partial<RecallArchiveFile>
|
||||||
|
if (
|
||||||
|
stored.version === ARCHIVE_VERSION &&
|
||||||
|
stored.accountRoot === accountRoot &&
|
||||||
|
stored.sessions &&
|
||||||
|
typeof stored.sessions === 'object'
|
||||||
|
) {
|
||||||
|
return stored as RecallArchiveFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[RecallArchive] read failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: ARCHIVE_VERSION,
|
||||||
|
accountRoot,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
sessions: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureRecallArchive(accountRoot: string): void {
|
||||||
|
const normalizedRoot = String(accountRoot || '').trim()
|
||||||
|
if (!normalizedRoot) {
|
||||||
|
archive = null
|
||||||
|
archivePath = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (archive?.accountRoot === normalizedRoot) return
|
||||||
|
archive = loadArchive(normalizedRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRecallArchiveMessages(
|
||||||
|
sessionMd5: string,
|
||||||
|
username: string,
|
||||||
|
messages: Message[]
|
||||||
|
): void {
|
||||||
|
if (!archive || !sessionMd5 || messages.length === 0) return
|
||||||
|
|
||||||
|
const bucket =
|
||||||
|
archive.sessions[sessionMd5] ||
|
||||||
|
({
|
||||||
|
username,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: [],
|
||||||
|
recalls: []
|
||||||
|
} satisfies SessionArchive)
|
||||||
|
if (username) bucket.username = username
|
||||||
|
|
||||||
|
const byIdentity = new Map(bucket.messages.map((message) => [messageIdentity(message), message]))
|
||||||
|
let changed = false
|
||||||
|
const targetlessNoticeIds = new Set(
|
||||||
|
messages
|
||||||
|
.filter((message) => {
|
||||||
|
const recall =
|
||||||
|
message.contentData?.type === 'system' ? message.contentData.recall : undefined
|
||||||
|
return Boolean(recall && !(recall.targetIds?.length || recall.targetId))
|
||||||
|
})
|
||||||
|
.map(messageIdentity)
|
||||||
|
)
|
||||||
|
if (targetlessNoticeIds.size > 0) {
|
||||||
|
const retained = bucket.recalls.filter((record) => !targetlessNoticeIds.has(record.noticeId))
|
||||||
|
if (retained.length !== bucket.recalls.length) {
|
||||||
|
bucket.recalls = retained
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sourceMessage of messages) {
|
||||||
|
const recallData =
|
||||||
|
sourceMessage.contentData?.type === 'system' ? sourceMessage.contentData.recall : undefined
|
||||||
|
if (recallData) {
|
||||||
|
let targetIds = Array.from(
|
||||||
|
new Set([...(recallData.targetIds || []), recallData.targetId || ''].filter(Boolean))
|
||||||
|
)
|
||||||
|
const noticeId = messageIdentity(sourceMessage)
|
||||||
|
const noticeTargetIds = new Set(messageTargetIds(sourceMessage))
|
||||||
|
const existingRecordIndex = bucket.recalls.findIndex((record) => record.noticeId === noticeId)
|
||||||
|
const existingRecord =
|
||||||
|
existingRecordIndex >= 0 ? bucket.recalls[existingRecordIndex] : undefined
|
||||||
|
const existingTargetsNotice =
|
||||||
|
existingRecord &&
|
||||||
|
existingRecord.targetIds.length > 0 &&
|
||||||
|
existingRecord.targetIds.every((targetId) => noticeTargetIds.has(targetId))
|
||||||
|
|
||||||
|
if (
|
||||||
|
targetIds.length === 0 &&
|
||||||
|
existingRecord &&
|
||||||
|
!existingTargetsNotice &&
|
||||||
|
!targetlessNoticeIds.has(noticeId)
|
||||||
|
) {
|
||||||
|
targetIds = existingRecord.targetIds
|
||||||
|
}
|
||||||
|
if (targetIds.length === 0) {
|
||||||
|
const claimedTargetIds = new Set(bucket.recalls.flatMap((record) => record.targetIds))
|
||||||
|
const inferred = inferRecallTarget(
|
||||||
|
byIdentity.values(),
|
||||||
|
sourceMessage,
|
||||||
|
recallData.actor,
|
||||||
|
claimedTargetIds
|
||||||
|
)
|
||||||
|
if (inferred) targetIds = messageTargetIds(inferred)
|
||||||
|
}
|
||||||
|
if (targetIds.length > 0) {
|
||||||
|
const nextRecord: RecallRecord = {
|
||||||
|
targetIds,
|
||||||
|
actor: recallData.actor,
|
||||||
|
noticeId,
|
||||||
|
createTime: sourceMessage.createTime || 0
|
||||||
|
}
|
||||||
|
const unchanged =
|
||||||
|
existingRecord &&
|
||||||
|
existingRecord.targetIds.length === targetIds.length &&
|
||||||
|
existingRecord.targetIds.every((targetId) => targetIds.includes(targetId)) &&
|
||||||
|
existingRecord.actor === nextRecord.actor
|
||||||
|
if (!unchanged) {
|
||||||
|
if (existingRecordIndex >= 0) bucket.recalls.splice(existingRecordIndex, 1, nextRecord)
|
||||||
|
else bucket.recalls.push(nextRecord)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotated = annotateFromRecall(sourceMessage, bucket.recalls)
|
||||||
|
const next: ArchivedMessage = {
|
||||||
|
...annotated,
|
||||||
|
archivedAt: Date.now(),
|
||||||
|
recallNotice: isRecallNotice(sourceMessage)
|
||||||
|
}
|
||||||
|
const identity = messageIdentity(next)
|
||||||
|
const previous = byIdentity.get(identity)
|
||||||
|
if (
|
||||||
|
!previous ||
|
||||||
|
previous.recalled !== next.recalled ||
|
||||||
|
previous.content !== next.content ||
|
||||||
|
previous.serverId !== next.serverId
|
||||||
|
) {
|
||||||
|
byIdentity.set(identity, next)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recalls = bucket.recalls.slice(-MAX_RECALLS_PER_SESSION)
|
||||||
|
const nextMessages = Array.from(byIdentity.values())
|
||||||
|
.map((message) => annotateFromRecall(message, recalls) as ArchivedMessage)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
|
||||||
|
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
|
||||||
|
})
|
||||||
|
.slice(-MAX_MESSAGES_PER_SESSION)
|
||||||
|
|
||||||
|
if (!changed && nextMessages.every((message, index) => message === bucket.messages[index])) return
|
||||||
|
|
||||||
|
bucket.messages = nextMessages
|
||||||
|
bucket.recalls = recalls
|
||||||
|
bucket.updatedAt = Date.now()
|
||||||
|
archive.sessions[sessionMd5] = bucket
|
||||||
|
archive.updatedAt = Date.now()
|
||||||
|
scheduleWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeRecallArchiveMessages<T extends Message>(
|
||||||
|
sessionMd5: string,
|
||||||
|
sourceMessages: T[],
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
limit?: number
|
||||||
|
): T[] {
|
||||||
|
const bucket = archive?.sessions[sessionMd5]
|
||||||
|
if (!bucket) return sourceMessages
|
||||||
|
|
||||||
|
const merged = new Map<string, Message>()
|
||||||
|
for (const message of sourceMessages) {
|
||||||
|
const archived = bucket.messages.find(
|
||||||
|
(candidate) => messageIdentity(candidate) === messageIdentity(message)
|
||||||
|
)
|
||||||
|
merged.set(
|
||||||
|
messageIdentity(message),
|
||||||
|
annotateFromRecall(
|
||||||
|
archived?.recalled
|
||||||
|
? { ...message, recalled: true, recalledBy: archived.recalledBy }
|
||||||
|
: message,
|
||||||
|
bucket.recalls
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const archived of bucket.messages) {
|
||||||
|
if (!archived.recalled && !archived.recallNotice) continue
|
||||||
|
const createTime = archived.createTime || 0
|
||||||
|
if (startTime && createTime < startTime) continue
|
||||||
|
if (endTime && createTime > endTime) continue
|
||||||
|
const identity = messageIdentity(archived)
|
||||||
|
if (!merged.has(identity)) merged.set(identity, archived)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Array.from(merged.values()).sort((left, right) => {
|
||||||
|
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
|
||||||
|
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
|
||||||
|
})
|
||||||
|
const visible = limit && result.length > limit ? result.slice(-limit) : result
|
||||||
|
return visible as T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractChangedConversationMd5(payload: string): string[] {
|
||||||
|
const matches = String(payload || '').match(/(?:Chat_|chat_)?([a-f0-9]{32})/gi) || []
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
matches
|
||||||
|
.map((value) => value.replace(/^chat_/i, '').toLowerCase())
|
||||||
|
.filter((value) => /^[a-f0-9]{32}$/.test(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchiveContact = {
|
||||||
|
md5: string
|
||||||
|
m_nsUsrName: string
|
||||||
|
type: 'user' | 'group'
|
||||||
|
activityKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecallArchiveMonitor {
|
||||||
|
private pending = new Set<string>()
|
||||||
|
private activityBySession = new Map<string, string>()
|
||||||
|
private timer: NodeJS.Timeout | null = null
|
||||||
|
private stopped = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly listContacts: () => ArchiveContact[],
|
||||||
|
private readonly loadMessages: (sessionMd5: string) => Message[]
|
||||||
|
) {}
|
||||||
|
|
||||||
|
seedAll(): void {
|
||||||
|
const groups = this.conversationContacts()
|
||||||
|
this.activityBySession = new Map(
|
||||||
|
groups.map((contact) => [contact.md5, String(contact.activityKey || '')])
|
||||||
|
)
|
||||||
|
const allowed = new Set(groups.map((contact) => contact.md5))
|
||||||
|
if (archive) {
|
||||||
|
let pruned = false
|
||||||
|
for (const sessionMd5 of Object.keys(archive.sessions)) {
|
||||||
|
if (allowed.has(sessionMd5)) continue
|
||||||
|
delete archive.sessions[sessionMd5]
|
||||||
|
pruned = true
|
||||||
|
}
|
||||||
|
if (pruned) {
|
||||||
|
archive.updatedAt = Date.now()
|
||||||
|
scheduleWrite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDatabaseChange(payload: string): void {
|
||||||
|
// Ordinary message inserts are already visible in the main reader. The
|
||||||
|
// archive scan is only needed when the native event indicates a recall.
|
||||||
|
if (!/(recall|revoke|revokemsg|撤回)/i.test(String(payload || ''))) return
|
||||||
|
const groups = this.conversationContacts()
|
||||||
|
const knownMd5 = new Set(groups.map((contact) => contact.md5.toLowerCase()))
|
||||||
|
const payloadTargets = extractChangedConversationMd5(payload).filter((md5) => knownMd5.has(md5))
|
||||||
|
const changedActivity = groups
|
||||||
|
.filter((contact) => {
|
||||||
|
const nextKey = String(contact.activityKey || '')
|
||||||
|
const previousKey = this.activityBySession.get(contact.md5)
|
||||||
|
this.activityBySession.set(contact.md5, nextKey)
|
||||||
|
return Boolean(nextKey && previousKey !== undefined && previousKey !== nextKey)
|
||||||
|
})
|
||||||
|
.map((contact) => contact.md5)
|
||||||
|
const targets = Array.from(new Set([...payloadTargets, ...changedActivity]))
|
||||||
|
if (targets.length > 0) {
|
||||||
|
console.log(`[RecallArchive] database change queued=${targets.length}`)
|
||||||
|
this.enqueue(targets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private conversationContacts(): ArchiveContact[] {
|
||||||
|
return this.listContacts().filter(
|
||||||
|
(contact) =>
|
||||||
|
contact.m_nsUsrName &&
|
||||||
|
!contact.m_nsUsrName.startsWith('@placeholder') &&
|
||||||
|
contact.m_nsUsrName !== 'brandsessionholder' &&
|
||||||
|
contact.m_nsUsrName !== 'brandservicesessionholder'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopped = true
|
||||||
|
this.pending.clear()
|
||||||
|
if (this.timer) clearTimeout(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueue(sessionMd5s: string[]): void {
|
||||||
|
if (this.stopped) return
|
||||||
|
for (const md5 of sessionMd5s) {
|
||||||
|
if (md5) this.pending.add(md5)
|
||||||
|
}
|
||||||
|
this.schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(): void {
|
||||||
|
if (this.stopped || this.timer || this.pending.size === 0) return
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null
|
||||||
|
const md5 = this.pending.values().next().value as string | undefined
|
||||||
|
if (!md5) return
|
||||||
|
this.pending.delete(md5)
|
||||||
|
try {
|
||||||
|
this.loadMessages(md5)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[RecallArchive] background scan failed md5=${md5}:`, error)
|
||||||
|
}
|
||||||
|
this.schedule()
|
||||||
|
}, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,22 @@ import { app } from 'electron'
|
|||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import { discoverWindowsDbRoots } from '../windows-db-root-discovery'
|
|
||||||
|
/**
|
||||||
|
* 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到
|
||||||
|
* "...\\Documents\\xwechat_files"(V4)。如果 xwechat_files 不存在则保留原值。
|
||||||
|
* 仅支持 WeChat 4.0:自动纠正用户机器上残留的旧路径。
|
||||||
|
*/
|
||||||
|
function redirectLegacyWeChatFilesToXwechat(candidate: string): string {
|
||||||
|
if (!candidate) return candidate
|
||||||
|
const normalized = candidate.replace(/[\\/]+$/, '')
|
||||||
|
const lowered = normalized.toLowerCase()
|
||||||
|
const legacyMarker = `${path.sep}wechat files`
|
||||||
|
if (!lowered.endsWith(legacyMarker)) return candidate
|
||||||
|
const redirected = `${normalized.slice(0, -legacyMarker.length)}${path.sep}xwechat_files`
|
||||||
|
if (fs.existsSync(redirected)) return redirected
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
dbRoot: string
|
dbRoot: string
|
||||||
@@ -13,8 +28,13 @@ export interface AppSettings {
|
|||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
imageKeyFallbackDisabled: boolean
|
imageKeyFallbackDisabled: boolean
|
||||||
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
autoLoginPreferenceSet: boolean
|
autoLoginPreferenceSet: boolean
|
||||||
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
|
compactMode: boolean
|
||||||
|
showStartupProgress: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultDbRoot(): string {
|
function getDefaultDbRoot(): string {
|
||||||
@@ -25,21 +45,20 @@ function getDefaultDbRoot(): string {
|
|||||||
|
|
||||||
function getDefaultDbRootCandidates(home: string): string[] {
|
function getDefaultDbRootCandidates(home: string): string[] {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
|
// macOS 仅支持 WeChat 4.0 路径(xwechat_files)
|
||||||
return [
|
return [
|
||||||
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
|
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仅支持 WeChat 4.0:剔除 V3 时代的 "WeChat Files" 目录,
|
||||||
|
// 只认 xwechat_files(含 Documents\ 和 AppData\Roaming\Tencent\ 两种合法位置)。
|
||||||
const candidates = [
|
const candidates = [
|
||||||
...getWeflowDbPathCandidates(home),
|
...getWeflowDbPathCandidates(home),
|
||||||
path.join(home, 'Documents', 'WeChat Files'),
|
|
||||||
path.join(home, 'Documents', 'xwechat_files'),
|
path.join(home, 'Documents', 'xwechat_files'),
|
||||||
path.join(home, 'WeChat Files'),
|
|
||||||
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||||
]
|
]
|
||||||
|
|
||||||
candidates.push(...discoverWindowsDbRoots())
|
|
||||||
|
|
||||||
return unique(candidates)
|
return unique(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +108,17 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
imageXorKey: '',
|
imageXorKey: '',
|
||||||
imageAesKey: '',
|
imageAesKey: '',
|
||||||
imageKeyFallbackDisabled: false,
|
imageKeyFallbackDisabled: false,
|
||||||
|
recallProtectionEnabled: false,
|
||||||
|
debugEnabled: false,
|
||||||
autoLogin: ['1', 'true', 'yes', 'on'].includes(
|
autoLogin: ['1', 'true', 'yes', 'on'].includes(
|
||||||
String(import.meta.env.VITE_AUTO_LOGIN || '')
|
String(import.meta.env.VITE_AUTO_LOGIN || '')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
),
|
),
|
||||||
autoLoginPreferenceSet: false
|
autoLoginPreferenceSet: false,
|
||||||
|
appearanceTheme: 'system',
|
||||||
|
compactMode: false,
|
||||||
|
showStartupProgress: true
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_FILE = path.join(
|
const SETTINGS_FILE = path.join(
|
||||||
@@ -123,9 +147,19 @@ export function loadSettings(): AppSettings {
|
|||||||
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
|
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
|
||||||
cache.dbRoot = getDefaultDbRoot()
|
cache.dbRoot = getDefaultDbRoot()
|
||||||
}
|
}
|
||||||
if (!cache.imageKeyRoot) {
|
// 同步:imageKeyRoot 必须跟随 dbRoot 更新,
|
||||||
|
// 否则自动获取会扫错目录(旧 bug:状态面板显示 D 盘,自动获取扫 C 盘)。
|
||||||
|
if (!cache.imageKeyRoot || !isUsableDbRoot(cache.imageKeyRoot)) {
|
||||||
cache.imageKeyRoot = cache.dbRoot
|
cache.imageKeyRoot = cache.dbRoot
|
||||||
}
|
}
|
||||||
|
// V4-only 兜底:如果 imageKeyRoot 指向旧的 "WeChat Files"(V3 路径),
|
||||||
|
// 重定向到同一父目录下的 xwechat_files(V4)。
|
||||||
|
if (cache.imageKeyRoot) {
|
||||||
|
cache.imageKeyRoot = redirectLegacyWeChatFilesToXwechat(cache.imageKeyRoot)
|
||||||
|
}
|
||||||
|
if (cache.dbRoot) {
|
||||||
|
cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot)
|
||||||
|
}
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { app, shell } from 'electron'
|
import { app, shell } from 'electron'
|
||||||
import { existsSync, promises as fs } from 'fs'
|
import { existsSync, promises as fs } from 'fs'
|
||||||
import { dirname, join } from 'path'
|
import { dirname, join } from 'path'
|
||||||
|
import { isPackagedRuntime } from '../runtime-mode'
|
||||||
|
|
||||||
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
|
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
|
||||||
const GITHUB_URL =
|
const GITHUB_URL =
|
||||||
@@ -23,7 +24,7 @@ function getSkillCandidates(): { path: string; source: 'development' | 'bundled'
|
|||||||
join(dirname(app.getAppPath()), SKILL_RELATIVE_PATH),
|
join(dirname(app.getAppPath()), SKILL_RELATIVE_PATH),
|
||||||
join(dirname(process.execPath), 'resources', SKILL_RELATIVE_PATH)
|
join(dirname(process.execPath), 'resources', SKILL_RELATIVE_PATH)
|
||||||
]
|
]
|
||||||
return app.isPackaged
|
return isPackagedRuntime()
|
||||||
? bundledPaths.map((path) => ({ path, source: 'bundled' as const }))
|
? bundledPaths.map((path) => ({ path, source: 'bundled' as const }))
|
||||||
: [
|
: [
|
||||||
{ path: developmentPath, source: 'development' as const },
|
{ path: developmentPath, source: 'development' as const },
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ export class StickerService {
|
|||||||
const normalizedMd5 = this.normalizeMd5(md5)
|
const normalizedMd5 = this.normalizeMd5(md5)
|
||||||
let url = String(cdnUrl || '').trim()
|
let url = String(cdnUrl || '').trim()
|
||||||
|
|
||||||
|
const cacheKey =
|
||||||
|
normalizedMd5 || (url ? crypto.createHash('md5').update(url).digest('hex') : '')
|
||||||
|
if (cacheKey) {
|
||||||
|
const cached = await this.readCached(cacheKey)
|
||||||
|
if (cached) return { success: true, data: cached }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedMd5 && this.wcdb4Client) {
|
||||||
|
const wechatCached = await this.readWechatEmoticonCache(normalizedMd5)
|
||||||
|
if (wechatCached) return { success: true, data: wechatCached }
|
||||||
|
}
|
||||||
|
|
||||||
if (!url && normalizedMd5 && this.wcdb4Client) {
|
if (!url && normalizedMd5 && this.wcdb4Client) {
|
||||||
url = this.wcdb4Client.resolveEmoticonCdnUrl(normalizedMd5) || ''
|
url = this.wcdb4Client.resolveEmoticonCdnUrl(normalizedMd5) || ''
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -32,30 +44,26 @@ export class StickerService {
|
|||||||
return { success: false, error: '未找到表情包 CDN URL' }
|
return { success: false, error: '未找到表情包 CDN URL' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = normalizedMd5 || crypto.createHash('md5').update(url).digest('hex')
|
const resolvedCacheKey = cacheKey || crypto.createHash('md5').update(url).digest('hex')
|
||||||
const cached = await this.readCached(cacheKey)
|
|
||||||
if (cached) return { success: true, data: cached }
|
|
||||||
|
|
||||||
if (normalizedMd5 && this.wcdb4Client) {
|
const pending = downloadCache.get(resolvedCacheKey)
|
||||||
const wechatCached = await this.readWechatEmoticonCache(normalizedMd5)
|
|
||||||
if (wechatCached) return { success: true, data: wechatCached }
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = downloadCache.get(cacheKey)
|
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
|
|
||||||
const task = this.downloadToDataUrl(url, cacheKey)
|
const task = this.downloadToDataUrl(url, resolvedCacheKey)
|
||||||
downloadCache.set(cacheKey, task)
|
downloadCache.set(resolvedCacheKey, task)
|
||||||
try {
|
try {
|
||||||
return await task
|
return await task
|
||||||
} finally {
|
} finally {
|
||||||
downloadCache.delete(cacheKey)
|
downloadCache.delete(resolvedCacheKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readCached(cacheKey: string): Promise<string | null> {
|
private async readCached(cacheKey: string): Promise<string | null> {
|
||||||
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
||||||
const cacheDirs = [this.cacheDir, path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')]
|
const cacheDirs = [
|
||||||
|
this.cacheDir,
|
||||||
|
path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis')
|
||||||
|
]
|
||||||
for (const cacheDir of cacheDirs) {
|
for (const cacheDir of cacheDirs) {
|
||||||
for (const ext of extensions) {
|
for (const ext of extensions) {
|
||||||
const filePath = path.join(cacheDir, `${cacheKey}${ext}`)
|
const filePath = path.join(cacheDir, `${cacheKey}${ext}`)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import type { Wcdb4Client } from './wcdb4-client'
|
||||||
|
|
||||||
|
type VideoAsset = {
|
||||||
|
filePath: string
|
||||||
|
posterPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VideoAssetService {
|
||||||
|
private readonly urlTokens = new Map<string, string>()
|
||||||
|
private index: Map<string, VideoAsset> | null = null
|
||||||
|
|
||||||
|
constructor(private readonly client: Wcdb4Client) {}
|
||||||
|
|
||||||
|
resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } {
|
||||||
|
const candidates = Array.from(
|
||||||
|
new Set(
|
||||||
|
hashes
|
||||||
|
.map((value) =>
|
||||||
|
String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
)
|
||||||
|
.filter((value) => /^[a-f0-9]{32}$/.test(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (candidates.length === 0) return { success: false, error: '视频标识为空' }
|
||||||
|
|
||||||
|
const hardlinkDb = path.join(
|
||||||
|
this.client.getAccountRoot(),
|
||||||
|
'db_storage',
|
||||||
|
'hardlink',
|
||||||
|
'hardlink.db'
|
||||||
|
)
|
||||||
|
const lookupKeys = [...candidates]
|
||||||
|
if (fs.existsSync(hardlinkDb)) {
|
||||||
|
for (const hash of candidates) {
|
||||||
|
const resolved = this.client.resolveVideoHardlink(hash, hardlinkDb)?.resolved_md5
|
||||||
|
if (resolved) lookupKeys.unshift(String(resolved).trim().toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.getIndex()
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
const asset = index.get(key) || index.get(`${key}_raw`)
|
||||||
|
if (!asset) continue
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url: this.createUrl(asset.filePath),
|
||||||
|
poster: asset.posterPath ? this.createUrl(asset.posterPath) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, error: '本地未找到该视频文件' }
|
||||||
|
}
|
||||||
|
|
||||||
|
pathForToken(token: string): string | undefined {
|
||||||
|
const filePath = this.urlTokens.get(token)
|
||||||
|
if (!filePath || !fs.existsSync(filePath)) return undefined
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private createUrl(filePath: string): string {
|
||||||
|
const token = crypto.randomBytes(18).toString('hex')
|
||||||
|
this.urlTokens.set(token, filePath)
|
||||||
|
if (this.urlTokens.size > 500) {
|
||||||
|
const first = this.urlTokens.keys().next().value
|
||||||
|
if (first) this.urlTokens.delete(first)
|
||||||
|
}
|
||||||
|
return `wxe-media://local/${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIndex(): Map<string, VideoAsset> {
|
||||||
|
if (this.index) return this.index
|
||||||
|
const result = new Map<string, VideoAsset>()
|
||||||
|
const root = path.join(this.client.getAccountRoot(), 'msg', 'video')
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
this.index = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const month of fs.readdirSync(root)) {
|
||||||
|
const monthPath = path.join(root, month)
|
||||||
|
if (!fs.statSync(monthPath).isDirectory()) continue
|
||||||
|
for (const name of fs.readdirSync(monthPath)) {
|
||||||
|
const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name)
|
||||||
|
if (!match) continue
|
||||||
|
const key = `${match[1].toLowerCase()}${match[2] || ''}`
|
||||||
|
const fullPath = path.join(monthPath, name)
|
||||||
|
const existing = result.get(key) || { filePath: '' }
|
||||||
|
if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath
|
||||||
|
else if (!existing.posterPath) existing.posterPath = fullPath
|
||||||
|
result.set(key, existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, asset] of result) {
|
||||||
|
if (!asset.filePath) result.delete(key)
|
||||||
|
}
|
||||||
|
this.index = result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { app } from 'electron'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { Wcdb4Client } from './wcdb4-client'
|
import { Wcdb4Client } from './wcdb4-client'
|
||||||
|
import { isPackagedRuntime } from './runtime-mode'
|
||||||
|
|
||||||
export class VoiceService {
|
export class VoiceService {
|
||||||
private wcdb4Client: Wcdb4Client
|
private wcdb4Client: Wcdb4Client
|
||||||
@@ -101,7 +102,7 @@ export class VoiceService {
|
|||||||
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
|
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
|
||||||
try {
|
try {
|
||||||
let wasmPath: string
|
let wasmPath: string
|
||||||
if (app.isPackaged) {
|
if (isPackagedRuntime()) {
|
||||||
wasmPath = join(
|
wasmPath = join(
|
||||||
process.resourcesPath,
|
process.resourcesPath,
|
||||||
'app.asar.unpacked',
|
'app.asar.unpacked',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import { discoverWindowsDbRoots } from './windows-db-root-discovery'
|
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { createRequire } from 'module'
|
import { createRequire } from 'module'
|
||||||
import { createConnection, Socket } from 'net'
|
import { createConnection, Socket } from 'net'
|
||||||
@@ -11,11 +10,14 @@ export interface Wcdb4Session {
|
|||||||
username: string
|
username: string
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
|
wechatNickname?: string
|
||||||
|
remark?: string
|
||||||
raw: Record<string, unknown>
|
raw: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Wcdb4Message {
|
export interface Wcdb4Message {
|
||||||
mesLocalID: string
|
mesLocalID: string
|
||||||
|
serverId?: string
|
||||||
mesDes: number
|
mesDes: number
|
||||||
messageType: string
|
messageType: string
|
||||||
msgCreateTime: string
|
msgCreateTime: string
|
||||||
@@ -30,6 +32,11 @@ export interface Wcdb4MessageQueryOptions {
|
|||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Wcdb4MessageStore = {
|
||||||
|
tableName: string
|
||||||
|
dbPath: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Wcdb4GroupMember {
|
export interface Wcdb4GroupMember {
|
||||||
m_nsUsrName: string
|
m_nsUsrName: string
|
||||||
nickname: string
|
nickname: string
|
||||||
@@ -45,6 +52,11 @@ export interface Wcdb4ImageHardlink {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Wcdb4VideoHardlink {
|
||||||
|
resolved_md5?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
type KoffiModule = {
|
type KoffiModule = {
|
||||||
load: (libraryPath: string) => KoffiLibrary
|
load: (libraryPath: string) => KoffiLibrary
|
||||||
decode: (ptr: unknown, type: string, length: number) => string
|
decode: (ptr: unknown, type: string, length: number) => string
|
||||||
@@ -56,6 +68,8 @@ type KoffiModule = {
|
|||||||
// library reference for every Wcdb4Client instance.
|
// library reference for every Wcdb4Client instance.
|
||||||
let wcdbBootstrapLib: KoffiLibrary | null = null
|
let wcdbBootstrapLib: KoffiLibrary | null = null
|
||||||
|
|
||||||
|
let wcdbBootstrapAsyncPromise: Promise<KoffiLibrary> | null = null
|
||||||
|
|
||||||
export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary {
|
export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary {
|
||||||
if (wcdbBootstrapLib) return wcdbBootstrapLib
|
if (wcdbBootstrapLib) return wcdbBootstrapLib
|
||||||
|
|
||||||
@@ -129,8 +143,82 @@ export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string):
|
|||||||
return lib
|
return lib
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bootstrapWcdbNativeAsync(
|
||||||
|
libPath?: string,
|
||||||
|
libDirOverride?: string
|
||||||
|
): Promise<KoffiLibrary> {
|
||||||
|
if (wcdbBootstrapLib) return Promise.resolve(wcdbBootstrapLib)
|
||||||
|
if (wcdbBootstrapAsyncPromise) return wcdbBootstrapAsyncPromise
|
||||||
|
|
||||||
|
wcdbBootstrapAsyncPromise = (async () => {
|
||||||
|
const koffi = nodeRequire('koffi') as KoffiModule
|
||||||
|
const resolvedLibPath = libPath || Wcdb4Client.resolveNativeLibrary()
|
||||||
|
const libDir = libDirOverride || path.dirname(resolvedLibPath)
|
||||||
|
for (const name of process.platform === 'win32'
|
||||||
|
? ['WCDB.dll', 'SDL2.dll']
|
||||||
|
: process.platform === 'darwin'
|
||||||
|
? ['libWCDB.dylib']
|
||||||
|
: []) {
|
||||||
|
const preloadPath = path.join(libDir, name)
|
||||||
|
if (!fs.existsSync(preloadPath)) continue
|
||||||
|
try {
|
||||||
|
koffi.load(preloadPath)
|
||||||
|
} catch {
|
||||||
|
// The main library may still resolve its dependencies through the loader.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lib = koffi.load(resolvedLibPath)
|
||||||
|
const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as (
|
||||||
|
resourcePath: string
|
||||||
|
) => number
|
||||||
|
const resourceRoots = Array.from(
|
||||||
|
new Set(
|
||||||
|
[libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter(
|
||||||
|
Boolean
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let initOk = false
|
||||||
|
for (const resourceRoot of resourceRoots) {
|
||||||
|
try {
|
||||||
|
if (Number(initProtection(resourceRoot)) === 0) {
|
||||||
|
initOk = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next resource root.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (initOk) {
|
||||||
|
const wcdbInit = lib.func('int32 wcdb_init()') as KoffiAsyncFunction
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
wcdbInit.async((error: unknown, result: unknown) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Number(result) !== 0) {
|
||||||
|
console.warn(`[WCDB4] async wcdb_init rc=${Number(result)}`)
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wcdbBootstrapLib = lib
|
||||||
|
return lib
|
||||||
|
})().catch((error) => {
|
||||||
|
wcdbBootstrapAsyncPromise = null
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
return wcdbBootstrapAsyncPromise
|
||||||
|
}
|
||||||
|
|
||||||
type KoffiLibrary = {
|
type KoffiLibrary = {
|
||||||
func: (signature: string) => (...args: unknown[]) => unknown
|
func: (signature: string) => KoffiAsyncFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & {
|
||||||
|
async: (...args: unknown[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type WcdbVoidOut = [unknown]
|
type WcdbVoidOut = [unknown]
|
||||||
@@ -158,6 +246,7 @@ export class Wcdb4Client {
|
|||||||
private wcdbOpenAccount:
|
private wcdbOpenAccount:
|
||||||
| ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number)
|
| ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
|
private wcdbOpenAccountAsync: KoffiAsyncFunction | null = null
|
||||||
private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null
|
private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null
|
||||||
private wcdbFreeString: ((ptr: unknown) => void) | null = null
|
private wcdbFreeString: ((ptr: unknown) => void) | null = null
|
||||||
private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null
|
private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null
|
||||||
@@ -217,6 +306,9 @@ export class Wcdb4Client {
|
|||||||
private wcdbResolveImageHardlink:
|
private wcdbResolveImageHardlink:
|
||||||
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
|
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
|
private wcdbResolveVideoHardlink:
|
||||||
|
| ((handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number)
|
||||||
|
| null = null
|
||||||
private wcdbGetEmoticonCdnUrl:
|
private wcdbGetEmoticonCdnUrl:
|
||||||
| ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number)
|
| ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
@@ -297,17 +389,17 @@ export class Wcdb4Client {
|
|||||||
private static getDefaultRootCandidates(): string[] {
|
private static getDefaultRootCandidates(): string[] {
|
||||||
const home = os.homedir()
|
const home = os.homedir()
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
|
// 仅支持 WeChat 4.0:只认 xwechat_files,V3 时代的 "WeChat Files" 不再加入候选
|
||||||
const candidates = [
|
const candidates = [
|
||||||
...Wcdb4Client.getWeflowDbPathCandidates(home),
|
...Wcdb4Client.getWeflowDbPathCandidates(home),
|
||||||
path.join(home, 'Documents', 'WeChat Files'),
|
|
||||||
path.join(home, 'Documents', 'xwechat_files'),
|
path.join(home, 'Documents', 'xwechat_files'),
|
||||||
path.join(home, 'WeChat Files'),
|
|
||||||
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||||
]
|
]
|
||||||
candidates.push(...discoverWindowsDbRoots())
|
|
||||||
return Array.from(new Set(candidates))
|
return Array.from(new Set(candidates))
|
||||||
}
|
}
|
||||||
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
|
return [
|
||||||
|
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getWeflowDbPathCandidates(home: string): string[] {
|
private static getWeflowDbPathCandidates(home: string): string[] {
|
||||||
@@ -331,7 +423,9 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
private static hasDbStorage(candidate: string): boolean {
|
private static hasDbStorage(candidate: string): boolean {
|
||||||
try {
|
try {
|
||||||
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
return (
|
||||||
|
fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -411,6 +505,39 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openAsync(): Promise<void> {
|
||||||
|
this.loadNativeLibrary()
|
||||||
|
if (!this.wcdbOpenAccountAsync) {
|
||||||
|
throw new Error('WCDB 4.0 native open async interface unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOut: WcdbHandleOut = [0]
|
||||||
|
const openResult = await new Promise<number>((resolve, reject) => {
|
||||||
|
this.wcdbOpenAccountAsync!.async(
|
||||||
|
this.sessionDbPath,
|
||||||
|
this.key,
|
||||||
|
handleOut,
|
||||||
|
(error: unknown, result: unknown) => {
|
||||||
|
if (error) reject(error)
|
||||||
|
else resolve(Number(result))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (openResult !== 0 || handleOut[0] <= 0) {
|
||||||
|
throw new Error(
|
||||||
|
`wcdb_open_account failed, code=${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.handle = handleOut[0]
|
||||||
|
if (this.wcdbSetMyWxid) {
|
||||||
|
try {
|
||||||
|
this.wcdbSetMyWxid(this.handle, this.wxid)
|
||||||
|
} catch {
|
||||||
|
// Optional helper. Failure does not block message reads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.stopMonitor()
|
this.stopMonitor()
|
||||||
if (this.handle === null || !this.wcdbShutdown) return
|
if (this.handle === null || !this.wcdbShutdown) return
|
||||||
@@ -588,6 +715,11 @@ export class Wcdb4Client {
|
|||||||
return this.cachedSessions
|
return this.cachedSessions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateSessionCache(): void {
|
||||||
|
this.cachedSessions = null
|
||||||
|
this.cachedChatTables = null
|
||||||
|
}
|
||||||
|
|
||||||
getChatTables(): { name: string; db_number: string }[] {
|
getChatTables(): { name: string; db_number: string }[] {
|
||||||
if (this.cachedChatTables) return this.cachedChatTables
|
if (this.cachedChatTables) return this.cachedChatTables
|
||||||
const sessions =
|
const sessions =
|
||||||
@@ -616,10 +748,12 @@ export class Wcdb4Client {
|
|||||||
try {
|
try {
|
||||||
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
||||||
if (cursorMessages) {
|
if (cursorMessages) {
|
||||||
|
const recoveredMessages = this.readRecallJournal(username, startTime, endTime)
|
||||||
|
const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows)
|
||||||
console.log(
|
console.log(
|
||||||
`[WCDB4] getMessages cursor ok username=${username} rows=${cursorMessages.length} cost=${Date.now() - startedAt}ms`
|
`[WCDB4] getMessages cursor ok username=${username} rows=${mergedMessages.length} recovered=${recoveredMessages.length} cost=${Date.now() - startedAt}ms`
|
||||||
)
|
)
|
||||||
return cursorMessages
|
return mergedMessages
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[WCDB4] cursor messages failed username=${username}:`, error)
|
console.warn(`[WCDB4] cursor messages failed username=${username}:`, error)
|
||||||
@@ -648,10 +782,7 @@ export class Wcdb4Client {
|
|||||||
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
|
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(`[WCDB4] get_messages failed username=${username} offset=${offset}:`, error)
|
||||||
`[WCDB4] get_messages failed username=${username} offset=${offset}:`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const batch = Array.isArray(rows) ? rows : []
|
const batch = Array.isArray(rows) ? rows : []
|
||||||
@@ -678,6 +809,21 @@ export class Wcdb4Client {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessagesAsync(
|
||||||
|
username: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options: Wcdb4MessageQueryOptions = {}
|
||||||
|
): Promise<Wcdb4Message[]> {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const maxRows = this.normalizeMessageLimit(options.limit)
|
||||||
|
const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
|
||||||
|
console.log(
|
||||||
|
`[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
|
||||||
|
)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
private readSessionRows(): Record<string, unknown>[] {
|
private readSessionRows(): Record<string, unknown>[] {
|
||||||
if (!this.wcdbGetSessions) return []
|
if (!this.wcdbGetSessions) return []
|
||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
@@ -694,17 +840,9 @@ export class Wcdb4Client {
|
|||||||
): Wcdb4Message[] {
|
): Wcdb4Message[] {
|
||||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
||||||
|
|
||||||
let tables: { tableName: string; dbPath: string }[] = []
|
let tables: Wcdb4MessageStore[] = []
|
||||||
try {
|
try {
|
||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
tables = this.listMessageStores(username)
|
||||||
this.wcdbGetMessageTableStats!(handle, username, outJson)
|
|
||||||
)
|
|
||||||
tables = (Array.isArray(rows) ? rows : [])
|
|
||||||
.map((row) => ({
|
|
||||||
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
|
||||||
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
|
|
||||||
}))
|
|
||||||
.filter((row) => row.tableName && row.dbPath)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[WCDB4] message table stats failed username=${username}:`, error)
|
console.warn(`[WCDB4] message table stats failed username=${username}:`, error)
|
||||||
return []
|
return []
|
||||||
@@ -739,6 +877,172 @@ export class Wcdb4Client {
|
|||||||
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
installRecallJournal(usernames: string[]): { installed: number; failed: number } {
|
||||||
|
const stores = new Map<string, Wcdb4MessageStore>()
|
||||||
|
for (const username of this.uniq(usernames)) {
|
||||||
|
try {
|
||||||
|
for (const store of this.listMessageStores(username)) {
|
||||||
|
stores.set(`${store.dbPath}\u0000${store.tableName}`, store)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[WCDB4] recall journal table discovery failed username=${username}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = 0
|
||||||
|
let failed = 0
|
||||||
|
for (const store of stores.values()) {
|
||||||
|
try {
|
||||||
|
const columns = this.readMessageColumns(store)
|
||||||
|
if (columns.length === 0) throw new Error('消息表没有可归档列')
|
||||||
|
this.ensureRecallJournalTable(store, columns)
|
||||||
|
this.createRecallJournalTrigger(store, columns)
|
||||||
|
installed += 1
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1
|
||||||
|
console.warn(
|
||||||
|
`[WCDB4] recall journal install failed db=${store.dbPath} table=${store.tableName}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { installed, failed }
|
||||||
|
}
|
||||||
|
|
||||||
|
private listMessageStores(username: string): Wcdb4MessageStore[] {
|
||||||
|
if (!this.wcdbGetMessageTableStats) return []
|
||||||
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
|
this.wcdbGetMessageTableStats!(handle, username, outJson)
|
||||||
|
)
|
||||||
|
return (Array.isArray(rows) ? rows : [])
|
||||||
|
.map((row) => ({
|
||||||
|
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
||||||
|
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
|
||||||
|
}))
|
||||||
|
.filter((row) => row.tableName && row.dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record<string, unknown>[] {
|
||||||
|
if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道')
|
||||||
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
|
this.wcdbExecQuery!(handle, 'message', store.dbPath, sql, outJson)
|
||||||
|
)
|
||||||
|
return Array.isArray(rows) ? rows : []
|
||||||
|
}
|
||||||
|
|
||||||
|
private readMessageColumns(store: Wcdb4MessageStore): { name: string; declaration: string }[] {
|
||||||
|
const rows = this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`PRAGMA table_info(${this.quoteSqlIdentifier(store.tableName)})`
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
.map((row) => {
|
||||||
|
const name = this.pickString(row, ['name'])
|
||||||
|
const type = this.pickString(row, ['type']).toUpperCase()
|
||||||
|
const declaration = /^(INTEGER|REAL|TEXT|BLOB|NUMERIC)$/.test(type) ? type : 'BLOB'
|
||||||
|
return { name, declaration }
|
||||||
|
})
|
||||||
|
.filter((column) => column.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureRecallJournalTable(
|
||||||
|
store: Wcdb4MessageStore,
|
||||||
|
columns: { name: string; declaration: string }[]
|
||||||
|
): void {
|
||||||
|
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
|
||||||
|
const definitions = columns
|
||||||
|
.map((column) => `${this.quoteSqlIdentifier(column.name)} ${column.declaration}`)
|
||||||
|
.join(', ')
|
||||||
|
this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`CREATE TABLE IF NOT EXISTS ${journal} (${definitions}, "_wxe_source_table" TEXT NOT NULL, "_wxe_captured_at" INTEGER NOT NULL)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRecallJournalTrigger(
|
||||||
|
store: Wcdb4MessageStore,
|
||||||
|
columns: { name: string; declaration: string }[]
|
||||||
|
): void {
|
||||||
|
const table = this.quoteSqlIdentifier(store.tableName)
|
||||||
|
const triggerName = this.quoteSqlIdentifier(
|
||||||
|
`_wxe_capture_${crypto.createHash('sha1').update(store.tableName).digest('hex').slice(0, 16)}`
|
||||||
|
)
|
||||||
|
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
|
||||||
|
const targetColumns = [
|
||||||
|
...columns.map((column) => this.quoteSqlIdentifier(column.name)),
|
||||||
|
'"_wxe_source_table"',
|
||||||
|
'"_wxe_captured_at"'
|
||||||
|
].join(', ')
|
||||||
|
const sourceValues = [
|
||||||
|
...columns.map((column) => `OLD.${this.quoteSqlIdentifier(column.name)}`),
|
||||||
|
`'${store.tableName.replace(/'/g, "''")}'`,
|
||||||
|
`CAST(strftime('%s', 'now') AS INTEGER)`
|
||||||
|
].join(', ')
|
||||||
|
this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`CREATE TRIGGER IF NOT EXISTS ${triggerName} BEFORE DELETE ON ${table} BEGIN INSERT INTO ${journal} (${targetColumns}) VALUES (${sourceValues}); END`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private readRecallJournal(
|
||||||
|
username: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): Wcdb4Message[] {
|
||||||
|
const recoveredRows: Record<string, unknown>[] = []
|
||||||
|
for (const store of this.listMessageStores(username)) {
|
||||||
|
try {
|
||||||
|
const begin = this.normalizeTimestamp(startTime || 0)
|
||||||
|
const end = this.normalizeTimestamp(endTime || 0)
|
||||||
|
const where = [
|
||||||
|
`"_wxe_source_table" = '${store.tableName.replace(/'/g, "''")}'`,
|
||||||
|
begin > 0 ? `"create_time" >= ${begin}` : '',
|
||||||
|
end > 0 ? `"create_time" <= ${end}` : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
const rows = this.executeMessageSql(
|
||||||
|
store,
|
||||||
|
`SELECT *, 1 AS "_wxe_recovered" FROM ${this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))} WHERE ${where.join(' AND ')} ORDER BY "create_time" ASC LIMIT 500`
|
||||||
|
)
|
||||||
|
recoveredRows.push(...rows)
|
||||||
|
} catch {
|
||||||
|
// The journal is optional until installation has completed for this store.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.finalizeMessages(username, recoveredRows, startTime, endTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeMessageRows(
|
||||||
|
current: Wcdb4Message[],
|
||||||
|
recovered: Wcdb4Message[],
|
||||||
|
limit?: number
|
||||||
|
): Wcdb4Message[] {
|
||||||
|
const merged = new Map<string, Wcdb4Message>()
|
||||||
|
for (const message of [...recovered, ...current]) {
|
||||||
|
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
|
||||||
|
const identity = message.mesLocalID
|
||||||
|
? `local:${message.mesLocalID}`
|
||||||
|
: message.serverId
|
||||||
|
? `server:${message.serverId}`
|
||||||
|
: `${message.msgCreateTime}:${message.msgContent}`
|
||||||
|
const key = recoveredRow ? `recovered:${identity}` : identity
|
||||||
|
merged.set(key, message)
|
||||||
|
}
|
||||||
|
const messages = Array.from(merged.values()).sort(
|
||||||
|
(left, right) =>
|
||||||
|
Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) ||
|
||||||
|
Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0)
|
||||||
|
)
|
||||||
|
return limit && messages.length > limit ? messages.slice(-limit) : messages
|
||||||
|
}
|
||||||
|
|
||||||
|
private recallJournalTableName(messageTableName: string): string {
|
||||||
|
return `_wxe_recall_journal_${crypto
|
||||||
|
.createHash('sha1')
|
||||||
|
.update(messageTableName)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16)}`
|
||||||
|
}
|
||||||
|
|
||||||
getMyAvatarUrl(): string | undefined {
|
getMyAvatarUrl(): string | undefined {
|
||||||
const candidates = this.getMyUsernameCandidates()
|
const candidates = this.getMyUsernameCandidates()
|
||||||
this.hydrateAvatarUrls(candidates)
|
this.hydrateAvatarUrls(candidates)
|
||||||
@@ -856,8 +1160,7 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
|
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
|
||||||
|
|
||||||
return visibleMessages
|
return visibleMessages.map((message) => {
|
||||||
.map((message) => {
|
|
||||||
if (!message.sender) return message
|
if (!message.sender) return message
|
||||||
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
|
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
|
||||||
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
|
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
|
||||||
@@ -915,11 +1218,7 @@ export class Wcdb4Client {
|
|||||||
'contactRemark',
|
'contactRemark',
|
||||||
'contact_remark'
|
'contact_remark'
|
||||||
])
|
])
|
||||||
const memberNickname = this.pickString(row, [
|
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||||
'displayName',
|
|
||||||
'display_name',
|
|
||||||
'name'
|
|
||||||
])
|
|
||||||
const avatar = this.pickString(row, [
|
const avatar = this.pickString(row, [
|
||||||
'avatarUrl',
|
'avatarUrl',
|
||||||
'avatar_url',
|
'avatar_url',
|
||||||
@@ -933,8 +1232,7 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
m_nsUsrName: username,
|
m_nsUsrName: username,
|
||||||
nickname:
|
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||||
groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
|
||||||
groupNickname: groupNicknames.get(username) || '',
|
groupNickname: groupNicknames.get(username) || '',
|
||||||
wechatNickname: wechatNickname || memberNickname,
|
wechatNickname: wechatNickname || memberNickname,
|
||||||
remark,
|
remark,
|
||||||
@@ -966,6 +1264,140 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getMessagesByCursorAsync(
|
||||||
|
username: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
limit?: number
|
||||||
|
): Promise<Wcdb4Message[]> {
|
||||||
|
if (!this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch) return []
|
||||||
|
|
||||||
|
const handle = this.ensureHandle()
|
||||||
|
const batchSize = limit ? Math.min(500, limit) : 1000
|
||||||
|
const cursorOut: WcdbHandleOut = [0]
|
||||||
|
const begin = this.normalizeTimestamp(startTime || 0)
|
||||||
|
const end = this.normalizeTimestamp(endTime || 0)
|
||||||
|
const ascending = limit ? 0 : 1
|
||||||
|
const openResult = await this.callAsyncCode(
|
||||||
|
this.wcdbOpenMessageCursor as unknown as KoffiAsyncFunction,
|
||||||
|
handle,
|
||||||
|
username,
|
||||||
|
batchSize,
|
||||||
|
ascending,
|
||||||
|
begin,
|
||||||
|
end,
|
||||||
|
cursorOut
|
||||||
|
)
|
||||||
|
if (openResult !== 0 || cursorOut[0] <= 0) return []
|
||||||
|
|
||||||
|
const cursor = cursorOut[0]
|
||||||
|
const allRows: Record<string, unknown>[] = []
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const outJson: WcdbVoidOut = [null]
|
||||||
|
const outHasMore: [number] = [0]
|
||||||
|
const fetchResult = await this.callAsyncCode(
|
||||||
|
this.wcdbFetchMessageBatch as unknown as KoffiAsyncFunction,
|
||||||
|
handle,
|
||||||
|
cursor,
|
||||||
|
outJson,
|
||||||
|
outHasMore
|
||||||
|
)
|
||||||
|
if (fetchResult !== 0 || !outJson[0]) break
|
||||||
|
try {
|
||||||
|
const json = this.koffi!.decode(outJson[0], 'char', -1)
|
||||||
|
const batch = JSON.parse(json) as Record<string, unknown>[]
|
||||||
|
if (Array.isArray(batch)) allRows.push(...batch)
|
||||||
|
} finally {
|
||||||
|
this.wcdbFreeString?.(outJson[0])
|
||||||
|
}
|
||||||
|
if (!outHasMore[0] || (limit && allRows.length >= limit)) break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
this.wcdbCloseMessageCursor?.(handle, cursor)
|
||||||
|
} catch {
|
||||||
|
// Best-effort cursor cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupMembersAsync(chatroomId: string): Promise<Wcdb4GroupMember[]> {
|
||||||
|
if (!this.wcdbGetGroupMembers || !chatroomId) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupNicknames = await this.getGroupNicknamesAsync(chatroomId)
|
||||||
|
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||||
|
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
|
||||||
|
chatroomId
|
||||||
|
)
|
||||||
|
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
||||||
|
const username = this.pickString(row, [
|
||||||
|
'username',
|
||||||
|
'userName',
|
||||||
|
'user_name',
|
||||||
|
'member_username',
|
||||||
|
'm_nsUsrName'
|
||||||
|
])
|
||||||
|
const wechatNickname = this.pickString(row, [
|
||||||
|
'nickname',
|
||||||
|
'nickName',
|
||||||
|
'wechatNickname',
|
||||||
|
'wechat_nickname',
|
||||||
|
'm_nsNickName'
|
||||||
|
])
|
||||||
|
const remark = this.pickString(row, [
|
||||||
|
'remark',
|
||||||
|
'remarkName',
|
||||||
|
'remark_name',
|
||||||
|
'contactRemark',
|
||||||
|
'contact_remark'
|
||||||
|
])
|
||||||
|
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||||
|
const avatar = this.pickString(row, [
|
||||||
|
'avatarUrl',
|
||||||
|
'avatar_url',
|
||||||
|
'headImgUrl',
|
||||||
|
'm_nsHeadImgUrl'
|
||||||
|
])
|
||||||
|
if (username && avatar) this.avatarCache.set(username, avatar)
|
||||||
|
return {
|
||||||
|
m_nsUsrName: username,
|
||||||
|
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||||
|
groupNickname: groupNicknames.get(username) || '',
|
||||||
|
wechatNickname: wechatNickname || memberNickname,
|
||||||
|
remark,
|
||||||
|
m_nsHeadImgUrl: avatar
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const missingNames = members
|
||||||
|
.filter((member) => !member.nickname)
|
||||||
|
.map((member) => member.m_nsUsrName)
|
||||||
|
.filter(Boolean)
|
||||||
|
const missingAvatars = members
|
||||||
|
.filter((member) => !member.m_nsHeadImgUrl)
|
||||||
|
.map((member) => member.m_nsUsrName)
|
||||||
|
.filter(Boolean)
|
||||||
|
await Promise.all([
|
||||||
|
this.hydrateDisplayNamesAsync(missingNames),
|
||||||
|
this.hydrateAvatarUrlsAsync(missingAvatars)
|
||||||
|
])
|
||||||
|
return members.map((member) => ({
|
||||||
|
...member,
|
||||||
|
nickname:
|
||||||
|
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||||
|
wechatNickname:
|
||||||
|
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
|
||||||
|
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getGroupNicknames(chatroomId: string): Map<string, string> {
|
getGroupNicknames(chatroomId: string): Map<string, string> {
|
||||||
const cached = this.groupNicknameCache.get(chatroomId)
|
const cached = this.groupNicknameCache.get(chatroomId)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
@@ -994,6 +1426,28 @@ export class Wcdb4Client {
|
|||||||
return nicknames
|
return nicknames
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getGroupNicknamesAsync(chatroomId: string): Promise<Map<string, string>> {
|
||||||
|
const cached = this.groupNicknameCache.get(chatroomId)
|
||||||
|
if (cached) return cached
|
||||||
|
const nicknames = new Map<string, string>()
|
||||||
|
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
|
||||||
|
|
||||||
|
const rows = await this.callJsonAsync<
|
||||||
|
Record<string, string> | Record<string, unknown>[]
|
||||||
|
>(this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId)
|
||||||
|
this.readStringMap(rows, [
|
||||||
|
'nickname',
|
||||||
|
'nickName',
|
||||||
|
'displayName',
|
||||||
|
'display_name',
|
||||||
|
'groupNickname',
|
||||||
|
'group_nickname',
|
||||||
|
'name'
|
||||||
|
]).forEach((nickname, username) => nicknames.set(username, nickname))
|
||||||
|
this.groupNicknameCache.set(chatroomId, nicknames)
|
||||||
|
return nicknames
|
||||||
|
}
|
||||||
|
|
||||||
async getVoiceData(
|
async getVoiceData(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
createTime: number,
|
createTime: number,
|
||||||
@@ -1073,6 +1527,23 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null {
|
||||||
|
if (!this.wcdbResolveVideoHardlink) return null
|
||||||
|
const normalizedMd5 = String(md5 || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
if (!/^[a-f0-9]{32}$/.test(normalizedMd5) || !dbPath) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return this.callJson<Wcdb4VideoHardlink>((handle, outJson) =>
|
||||||
|
this.wcdbResolveVideoHardlink!(handle, normalizedMd5, dbPath, outJson)
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WCDB4] resolve video hardlink failed:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resolveEmoticonCdnUrl(md5: string): string | undefined {
|
resolveEmoticonCdnUrl(md5: string): string | undefined {
|
||||||
if (!this.wcdbGetEmoticonCdnUrl) {
|
if (!this.wcdbGetEmoticonCdnUrl) {
|
||||||
console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`)
|
console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`)
|
||||||
@@ -1164,9 +1635,11 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number
|
this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number
|
||||||
this.wcdbOpenAccount = lib.func(
|
const openAccount = lib.func(
|
||||||
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)'
|
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)'
|
||||||
) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number
|
) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number
|
||||||
|
this.wcdbOpenAccount = openAccount
|
||||||
|
this.wcdbOpenAccountAsync = openAccount as unknown as KoffiAsyncFunction
|
||||||
this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void
|
this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void
|
||||||
this.wcdbGetSessions = lib.func(
|
this.wcdbGetSessions = lib.func(
|
||||||
'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)'
|
'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)'
|
||||||
@@ -1286,6 +1759,14 @@ export class Wcdb4Client {
|
|||||||
this.wcdbResolveImageHardlink = null
|
this.wcdbResolveImageHardlink = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.wcdbResolveVideoHardlink = lib.func(
|
||||||
|
'int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)'
|
||||||
|
) as (handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number
|
||||||
|
} catch {
|
||||||
|
this.wcdbResolveVideoHardlink = null
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.wcdbGetEmoticonCdnUrl = lib.func(
|
this.wcdbGetEmoticonCdnUrl = lib.func(
|
||||||
'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)'
|
'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)'
|
||||||
@@ -1448,6 +1929,41 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> {
|
||||||
|
const handle = this.ensureHandle()
|
||||||
|
const outJson: WcdbVoidOut = [null]
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
fn.async(handle, ...args, outJson, (error: unknown, result: unknown) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const code = Number(result)
|
||||||
|
if (code !== 0 || !outJson[0]) {
|
||||||
|
reject(new Error(`WCDB async call failed, code: ${code}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const json = this.koffi!.decode(outJson[0], 'char', -1)
|
||||||
|
resolve(JSON.parse(json) as T)
|
||||||
|
} catch (decodeError) {
|
||||||
|
reject(decodeError)
|
||||||
|
} finally {
|
||||||
|
this.wcdbFreeString?.(outJson[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private callAsyncCode(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<number> {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
fn.async(...args, (error: unknown, result: unknown) => {
|
||||||
|
if (error) reject(error)
|
||||||
|
else resolve(Number(result))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private ensureHandle(): number {
|
private ensureHandle(): number {
|
||||||
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
|
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
|
||||||
return this.handle
|
return this.handle
|
||||||
@@ -1474,7 +1990,21 @@ export class Wcdb4Client {
|
|||||||
'remark',
|
'remark',
|
||||||
'name'
|
'name'
|
||||||
])
|
])
|
||||||
return { username, nickname, raw: row }
|
const wechatNickname = this.pickString(row, [
|
||||||
|
'wechatNickname',
|
||||||
|
'wechat_nickname',
|
||||||
|
'nickname',
|
||||||
|
'nickName',
|
||||||
|
'name'
|
||||||
|
])
|
||||||
|
const remark = this.pickString(row, [
|
||||||
|
'remark',
|
||||||
|
'remarkName',
|
||||||
|
'remark_name',
|
||||||
|
'contactRemark',
|
||||||
|
'contact_remark'
|
||||||
|
])
|
||||||
|
return { username, nickname, wechatNickname, remark, raw: row }
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
|
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
|
||||||
@@ -1520,6 +2050,19 @@ export class Wcdb4Client {
|
|||||||
'mesLocalID',
|
'mesLocalID',
|
||||||
'id'
|
'id'
|
||||||
])
|
])
|
||||||
|
const serverId = this.pickString(row, [
|
||||||
|
'server_id',
|
||||||
|
'serverId',
|
||||||
|
'svr_id',
|
||||||
|
'svrId',
|
||||||
|
'msg_svr_id',
|
||||||
|
'msgSvrId',
|
||||||
|
'message_id',
|
||||||
|
'messageId',
|
||||||
|
'new_msg_id',
|
||||||
|
'newMsgId',
|
||||||
|
'WCDB_CT_server_id'
|
||||||
|
])
|
||||||
const messageType = this.pickString(row, [
|
const messageType = this.pickString(row, [
|
||||||
'local_type',
|
'local_type',
|
||||||
'localType',
|
'localType',
|
||||||
@@ -1541,6 +2084,7 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
|
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
|
||||||
|
serverId: serverId || undefined,
|
||||||
mesDes: isSend ? 0 : 1,
|
mesDes: isSend ? 0 : 1,
|
||||||
messageType: messageType || '1',
|
messageType: messageType || '1',
|
||||||
msgCreateTime: String(createTime),
|
msgCreateTime: String(createTime),
|
||||||
@@ -1584,6 +2128,26 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
|
||||||
|
if (!this.wcdbGetDisplayNames) return
|
||||||
|
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
|
||||||
|
if (missing.length === 0) return
|
||||||
|
try {
|
||||||
|
const rows = await this.callJsonAsync<
|
||||||
|
Record<string, string> | Record<string, unknown>[]
|
||||||
|
>(this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||||
|
this.readStringMap(rows, [
|
||||||
|
'nickname',
|
||||||
|
'displayName',
|
||||||
|
'display_name',
|
||||||
|
'remark',
|
||||||
|
'name'
|
||||||
|
]).forEach((name, username) => this.displayNameCache.set(username, name))
|
||||||
|
} catch {
|
||||||
|
// Names are optional; usernames remain usable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private hydrateAvatarUrls(usernames: string[]): void {
|
private hydrateAvatarUrls(usernames: string[]): void {
|
||||||
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||||
if (missing.length === 0) return
|
if (missing.length === 0) return
|
||||||
@@ -1618,6 +2182,27 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async hydrateAvatarUrlsAsync(usernames: string[]): Promise<void> {
|
||||||
|
if (!this.wcdbGetAvatarUrls) return
|
||||||
|
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||||
|
if (missing.length === 0) return
|
||||||
|
try {
|
||||||
|
const rows = await this.callJsonAsync<
|
||||||
|
Record<string, string> | Record<string, unknown>[]
|
||||||
|
>(this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||||
|
this.readStringMap(rows, [
|
||||||
|
'avatarUrl',
|
||||||
|
'avatar_url',
|
||||||
|
'headImgUrl',
|
||||||
|
'm_nsHeadImgUrl',
|
||||||
|
'big_head_img_url',
|
||||||
|
'small_head_img_url'
|
||||||
|
]).forEach((avatar, username) => this.avatarCache.set(username, avatar))
|
||||||
|
} catch {
|
||||||
|
// Avatars are optional.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
|
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
|
||||||
const result = new Map<string, string>()
|
const result = new Map<string, string>()
|
||||||
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface UserContact {
|
|||||||
m_nsUsrName: string
|
m_nsUsrName: string
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
|
wechatNickname?: string
|
||||||
|
remark?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WechatMessage {
|
export interface WechatMessage {
|
||||||
@@ -33,20 +35,12 @@ export interface GroupMemberInfo {
|
|||||||
export class WechatDb {
|
export class WechatDb {
|
||||||
private wcdb4Client: Wcdb4Client
|
private wcdb4Client: Wcdb4Client
|
||||||
private chatMd5ToUsername = new Map<string, string>()
|
private chatMd5ToUsername = new Map<string, string>()
|
||||||
|
private chatTableMappingLoaded = false
|
||||||
|
|
||||||
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
|
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
|
||||||
// WCDB native init must run on the Electron main process; worker threads
|
|
||||||
// get -1006 from wcdb_init. Initialize the client synchronously here
|
|
||||||
// (and keep create() async for callers that already await it).
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const client = new Wcdb4Client(rawKey, accountRoot)
|
const client = new Wcdb4Client(rawKey, accountRoot)
|
||||||
client.open()
|
await client.openAsync()
|
||||||
resolve(new WechatDb(rawKey, accountRoot, client))
|
return new WechatDb(rawKey, accountRoot, client)
|
||||||
} catch (error) {
|
|
||||||
reject(error instanceof Error ? error : new Error(String(error)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -56,15 +50,25 @@ export class WechatDb {
|
|||||||
initialChatTables?: { name: string; db_number: string }[]
|
initialChatTables?: { name: string; db_number: string }[]
|
||||||
) {
|
) {
|
||||||
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
||||||
const client =
|
const client = clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
||||||
clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
|
||||||
if (!clientOverride) client.open()
|
if (!clientOverride) client.open()
|
||||||
this.wcdb4Client = client
|
this.wcdb4Client = client
|
||||||
for (const table of initialChatTables || client.getChatTables()) {
|
for (const table of initialChatTables || []) {
|
||||||
if (table.name.startsWith('Chat_')) {
|
if (table.name.startsWith('Chat_')) {
|
||||||
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.chatTableMappingLoaded = Boolean(initialChatTables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureChatTableMapping(): void {
|
||||||
|
if (this.chatTableMappingLoaded) return
|
||||||
|
for (const table of this.wcdb4Client.getChatTables()) {
|
||||||
|
if (table.name.startsWith('Chat_')) {
|
||||||
|
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.chatTableMappingLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
public getUserList(nicknameFilter?: string): UserContact[] {
|
public getUserList(nicknameFilter?: string): UserContact[] {
|
||||||
@@ -74,7 +78,9 @@ export class WechatDb {
|
|||||||
.map((session) => ({
|
.map((session) => ({
|
||||||
m_nsUsrName: session.username,
|
m_nsUsrName: session.username,
|
||||||
nickname: session.nickname || session.username,
|
nickname: session.nickname || session.username,
|
||||||
avatar: session.avatar
|
avatar: session.avatar,
|
||||||
|
wechatNickname: session.wechatNickname,
|
||||||
|
remark: session.remark
|
||||||
}))
|
}))
|
||||||
.filter((contact) => {
|
.filter((contact) => {
|
||||||
if (!keyword) return true
|
if (!keyword) return true
|
||||||
@@ -109,6 +115,7 @@ export class WechatDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getGroupMembersForChat(userMd5: string): Record<string, string> {
|
public getGroupMembersForChat(userMd5: string): Record<string, string> {
|
||||||
|
this.ensureChatTableMapping()
|
||||||
const username = this.chatMd5ToUsername.get(userMd5)
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
if (!username || !username.endsWith('@chatroom')) return {}
|
if (!username || !username.endsWith('@chatroom')) return {}
|
||||||
|
|
||||||
@@ -124,9 +131,8 @@ export class WechatDb {
|
|||||||
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
|
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
|
||||||
if (!chatroomId) return null
|
if (!chatroomId) return null
|
||||||
return (
|
return (
|
||||||
this.wcdb4Client
|
this.wcdb4Client.getGroupMembers(chatroomId).find((member) => member.m_nsUsrName === wxid) ||
|
||||||
.getGroupMembers(chatroomId)
|
null
|
||||||
.find((member) => member.m_nsUsrName === wxid) || null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +158,7 @@ export class WechatDb {
|
|||||||
endTime?: number,
|
endTime?: number,
|
||||||
options?: Wcdb4MessageQueryOptions
|
options?: Wcdb4MessageQueryOptions
|
||||||
): WechatMessage[] {
|
): WechatMessage[] {
|
||||||
|
this.ensureChatTableMapping()
|
||||||
const username = this.chatMd5ToUsername.get(userMd5)
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
if (!username) return []
|
if (!username) return []
|
||||||
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
||||||
@@ -160,6 +167,24 @@ export class WechatDb {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getUserMessagesAsync(
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
options?: Wcdb4MessageQueryOptions
|
||||||
|
): Promise<WechatMessage[]> {
|
||||||
|
this.ensureChatTableMapping()
|
||||||
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
|
if (!username) return []
|
||||||
|
const messages = await this.wcdb4Client.getMessagesAsync(
|
||||||
|
username,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
return messages.map((message) => ({ ...message, ...message.raw }))
|
||||||
|
}
|
||||||
|
|
||||||
public searchAllMessages(keyword: string): string | null {
|
public searchAllMessages(keyword: string): string | null {
|
||||||
const lowerKeyword = keyword.trim().toLowerCase()
|
const lowerKeyword = keyword.trim().toLowerCase()
|
||||||
if (!lowerKeyword) return null
|
if (!lowerKeyword) return null
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
const DB_ROOT_NAMES = new Set(['xwechat_files', 'wechat files'])
|
// 仅支持 WeChat 4.0:只扫描 xwechat_files;旧 V3 时代的 "WeChat Files" 不再纳入候选
|
||||||
|
const DB_ROOT_NAMES = new Set(['xwechat_files'])
|
||||||
const SKIPPED_DIRECTORY_NAMES = new Set([
|
const SKIPPED_DIRECTORY_NAMES = new Set([
|
||||||
'$recycle.bin',
|
'$recycle.bin',
|
||||||
'system volume information',
|
'system volume information',
|
||||||
|
|||||||
@@ -39,15 +39,45 @@ import type {
|
|||||||
} from '../shared/image-insight'
|
} from '../shared/image-insight'
|
||||||
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
import type { AppUpdateCheckResult, AppUpdateState } from '../shared/app-update'
|
||||||
|
import type { CacheSummary } from '../shared/cache'
|
||||||
|
import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export'
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
| { type: 'voice'; duration?: number }
|
| { type: 'voice'; duration?: number }
|
||||||
| { type: 'location'; poiname?: string; label?: string; lat: number; lng: number }
|
| { type: 'location'; poiname?: string; label?: string; lat: number; lng: number }
|
||||||
| { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
| { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||||
| { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string }
|
| {
|
||||||
|
type: 'share'
|
||||||
|
title: string
|
||||||
|
des?: string
|
||||||
|
url: string
|
||||||
|
appname?: string
|
||||||
|
typeVal?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'miniProgram'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
appName?: string
|
||||||
|
iconUrl?: string
|
||||||
|
thumbMd5?: string
|
||||||
|
thumbDatName?: string
|
||||||
|
thumbDataUrl?: string
|
||||||
|
}
|
||||||
|
| { type: 'redPacket'; title: string; description?: string; url?: string }
|
||||||
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||||
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
||||||
|
| {
|
||||||
|
type: 'video'
|
||||||
|
md5?: string
|
||||||
|
newMd5?: string
|
||||||
|
rawMd5?: string
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'sticker'
|
type: 'sticker'
|
||||||
md5?: string
|
md5?: string
|
||||||
@@ -75,6 +105,13 @@ declare global {
|
|||||||
writeAppLog: (entry: AppLogEntry) => Promise<void>
|
writeAppLog: (entry: AppLogEntry) => Promise<void>
|
||||||
getAppLogPath: () => Promise<string>
|
getAppLogPath: () => Promise<string>
|
||||||
revealAppLog: () => Promise<void>
|
revealAppLog: () => Promise<void>
|
||||||
|
getAppUpdateState: () => Promise<AppUpdateState>
|
||||||
|
checkAppUpdate: () => Promise<AppUpdateCheckResult>
|
||||||
|
downloadAppUpdate: () => Promise<AppUpdateCheckResult>
|
||||||
|
installAppUpdate: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
|
||||||
|
getCacheSummary: () => Promise<CacheSummary>
|
||||||
|
clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise<CacheSummary>
|
||||||
initDb: (
|
initDb: (
|
||||||
key: string
|
key: string
|
||||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||||
@@ -83,6 +120,11 @@ declare global {
|
|||||||
contacts: Contact[]
|
contacts: Contact[]
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
} | null>
|
} | null>
|
||||||
|
getStartupCache: () => Promise<{
|
||||||
|
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||||
|
contacts: Contact[]
|
||||||
|
updatedAt: number
|
||||||
|
} | null>
|
||||||
getContacts: (filter?: string) => Promise<Contact[]>
|
getContacts: (filter?: string) => Promise<Contact[]>
|
||||||
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
||||||
getCachedMessages: (
|
getCachedMessages: (
|
||||||
@@ -90,6 +132,26 @@ declare global {
|
|||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number
|
endTime?: number
|
||||||
) => Promise<Message[]>
|
) => Promise<Message[]>
|
||||||
|
getCachedMessagePage: (
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
) => Promise<{
|
||||||
|
hit: boolean
|
||||||
|
messages: Message[]
|
||||||
|
groupSnapshot?: {
|
||||||
|
roomId: string
|
||||||
|
memberCount: number
|
||||||
|
members: {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}>
|
||||||
getMessages: (
|
getMessages: (
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
@@ -143,7 +205,7 @@ declare global {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: string
|
data?: string
|
||||||
@@ -151,10 +213,17 @@ declare global {
|
|||||||
isThumb?: boolean
|
isThumb?: boolean
|
||||||
filePath?: string
|
filePath?: string
|
||||||
}>
|
}>
|
||||||
|
getVideo: (
|
||||||
|
hashes: string[]
|
||||||
|
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
|
||||||
getSticker: (
|
getSticker: (
|
||||||
cdnUrl?: string,
|
cdnUrl?: string,
|
||||||
md5?: string
|
md5?: string
|
||||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
|
startExport: (request: ExportRequest) => Promise<ExportResult>
|
||||||
|
cancelExport: (jobId: string) => Promise<{ success: boolean }>
|
||||||
|
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
|
||||||
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||||
listGeneratedReports: () => Promise<ReportHistoryResult>
|
listGeneratedReports: () => Promise<ReportHistoryResult>
|
||||||
saveGeneratedReport: (
|
saveGeneratedReport: (
|
||||||
@@ -191,8 +260,13 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
autoLoginPreferenceSet: boolean
|
autoLoginPreferenceSet: boolean
|
||||||
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
|
compactMode: boolean
|
||||||
|
showStartupProgress: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -217,8 +291,13 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
autoLoginPreferenceSet: boolean
|
autoLoginPreferenceSet: boolean
|
||||||
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
|
compactMode: boolean
|
||||||
|
showStartupProgress: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
@@ -231,8 +310,13 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
autoLoginPreferenceSet: boolean
|
autoLoginPreferenceSet: boolean
|
||||||
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
|
compactMode: boolean
|
||||||
|
showStartupProgress: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}>
|
}>
|
||||||
@@ -243,8 +327,13 @@ declare global {
|
|||||||
apiHost: string
|
apiHost: string
|
||||||
apiPort: number
|
apiPort: number
|
||||||
imageKeyRoot: string
|
imageKeyRoot: string
|
||||||
|
recallProtectionEnabled: boolean
|
||||||
|
debugEnabled: boolean
|
||||||
autoLogin: boolean
|
autoLogin: boolean
|
||||||
autoLoginPreferenceSet: boolean
|
autoLoginPreferenceSet: boolean
|
||||||
|
appearanceTheme: 'system' | 'light' | 'dark'
|
||||||
|
compactMode: boolean
|
||||||
|
showStartupProgress: boolean
|
||||||
imageXorKey: string
|
imageXorKey: string
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,18 +17,37 @@ import type {
|
|||||||
} from '../shared/image-insight'
|
} from '../shared/image-insight'
|
||||||
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
import type { AppUpdateState } from '../shared/app-update'
|
||||||
|
import type { CacheSummary } from '../shared/cache'
|
||||||
|
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry),
|
writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry),
|
||||||
getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'),
|
getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'),
|
||||||
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
||||||
|
getAppUpdateState: (): Promise<AppUpdateState> => ipcRenderer.invoke('app-update:getState'),
|
||||||
|
checkAppUpdate: () => ipcRenderer.invoke('app-update:check'),
|
||||||
|
downloadAppUpdate: () => ipcRenderer.invoke('app-update:download'),
|
||||||
|
installAppUpdate: () => ipcRenderer.invoke('app-update:install'),
|
||||||
|
onAppUpdateState: (callback: (state: AppUpdateState) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, state: AppUpdateState): void =>
|
||||||
|
callback(state)
|
||||||
|
ipcRenderer.on('app-update:state', listener)
|
||||||
|
return () => ipcRenderer.removeListener('app-update:state', listener)
|
||||||
|
},
|
||||||
|
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
|
||||||
|
clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise<CacheSummary> =>
|
||||||
|
ipcRenderer.invoke('cache:clear', scope),
|
||||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||||
|
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
|
||||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||||
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
||||||
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
|
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
|
||||||
|
getCachedMessagePage: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
|
ipcRenderer.invoke('db:getCachedMessagePage', userMd5, startTime, endTime),
|
||||||
getMessages: (
|
getMessages: (
|
||||||
userMd5: string,
|
userMd5: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
@@ -57,9 +76,19 @@ const api = {
|
|||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
options?: { force?: boolean }
|
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||||
|
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
|
||||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||||
|
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
||||||
|
cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
|
||||||
|
revealExport: (path: string) => ipcRenderer.invoke('export:reveal', path),
|
||||||
|
onExportProgress: (callback: (progress: ExportJobProgress) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, progress: ExportJobProgress): void =>
|
||||||
|
callback(progress)
|
||||||
|
ipcRenderer.on('export:progress', listener)
|
||||||
|
return () => ipcRenderer.removeListener('export:progress', listener)
|
||||||
|
},
|
||||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||||
ipcRenderer.invoke('report:export', request),
|
ipcRenderer.invoke('report:export', request),
|
||||||
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
|
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' blob: data: wxe-media:;"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Sidebar } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import ChatWindow from './components/ChatWindow'
|
import ChatWindow from './components/ChatWindow'
|
||||||
import { AppShell } from './components/layout/AppShell'
|
import { AppShell } from './components/layout/AppShell'
|
||||||
@@ -20,6 +20,9 @@ import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportG
|
|||||||
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
|
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
|
||||||
import { Contact, Message } from '../../shared/types'
|
import { Contact, Message } from '../../shared/types'
|
||||||
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
|
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
|
||||||
|
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
||||||
|
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
|
||||||
|
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 260
|
const SIDEBAR_MIN_WIDTH = 260
|
||||||
const SIDEBAR_MAX_WIDTH = 380
|
const SIDEBAR_MAX_WIDTH = 380
|
||||||
@@ -38,13 +41,77 @@ interface SelfInfo {
|
|||||||
|
|
||||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
||||||
const VIEW_MESSAGE_LIMIT = 600
|
const INITIAL_MESSAGE_COUNT = 20
|
||||||
|
const MESSAGE_PAGE_SIZE = 100
|
||||||
|
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
|
||||||
|
const EXPORT_PREVIEW_LIMIT = 20
|
||||||
const getMessageIdentity = (message: Message): string => {
|
const getMessageIdentity = (message: Message): string => {
|
||||||
if (message.localId) return `local:${message.localId}`
|
if (message.localId) return `local:${message.localId}`
|
||||||
if (message.id) return `id:${message.id}`
|
if (message.id) return `id:${message.id}`
|
||||||
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeQuotedText = (value: string | undefined): string =>
|
||||||
|
String(value || '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
const isInternalReferenceSender = (value: string | undefined): boolean => {
|
||||||
|
const sender = String(value || '').trim()
|
||||||
|
return (
|
||||||
|
!sender ||
|
||||||
|
sender.endsWith('@chatroom') ||
|
||||||
|
sender.startsWith('wxid_') ||
|
||||||
|
/^[a-z0-9_@.-]{12,}$/i.test(sender)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]): Message[] => {
|
||||||
|
const imageDatNameByMd5 = new Map<string, string>()
|
||||||
|
const messagesByContent = new Map<string, Message[]>()
|
||||||
|
|
||||||
|
for (const message of referenceMessages) {
|
||||||
|
if (
|
||||||
|
message.contentData?.type === 'image' &&
|
||||||
|
message.contentData.md5 &&
|
||||||
|
message.contentData.datName
|
||||||
|
) {
|
||||||
|
imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName)
|
||||||
|
}
|
||||||
|
const content = normalizeQuotedText(message.content)
|
||||||
|
if (!content) continue
|
||||||
|
const candidates = messagesByContent.get(content) || []
|
||||||
|
candidates.push(message)
|
||||||
|
messagesByContent.set(content, candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.map((message) => {
|
||||||
|
if (message.contentData?.type !== 'quote') return message
|
||||||
|
const quote = message.contentData
|
||||||
|
let quotedImageDatName = quote.quotedImageDatName
|
||||||
|
if (!quotedImageDatName && quote.quotedImageMd5) {
|
||||||
|
quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5)
|
||||||
|
}
|
||||||
|
|
||||||
|
let quotedSender = quote.quotedSender
|
||||||
|
if (isInternalReferenceSender(quotedSender)) {
|
||||||
|
const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || []
|
||||||
|
const source = candidates
|
||||||
|
.filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity))
|
||||||
|
.sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0]
|
||||||
|
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quotedSender === quote.quotedSender && quotedImageDatName === quote.quotedImageDatName) {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
contentData: { ...quote, quotedSender, quotedImageDatName }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
||||||
if (left === right) return true
|
if (left === right) return true
|
||||||
if (left.length !== right.length) return false
|
if (left.length !== right.length) return false
|
||||||
@@ -76,7 +143,7 @@ type StartupProgress = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
|
||||||
member.nickname || member.wxid
|
member.groupNickname || member.nickname || member.remark || member.wechatNickname || member.wxid
|
||||||
|
|
||||||
const buildSyntheticGroupMessages = (
|
const buildSyntheticGroupMessages = (
|
||||||
previous: GroupSnapshot | null,
|
previous: GroupSnapshot | null,
|
||||||
@@ -132,6 +199,12 @@ const sortMessagesChronologically = (items: Message[]): Message[] =>
|
|||||||
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
|
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mergeMessagePages = (older: Message[], current: Message[]): Message[] => {
|
||||||
|
const merged = new Map<string, Message>()
|
||||||
|
for (const message of [...older, ...current]) merged.set(getMessageIdentity(message), message)
|
||||||
|
return sortMessagesChronologically(Array.from(merged.values()))
|
||||||
|
}
|
||||||
|
|
||||||
function App(): React.ReactElement {
|
function App(): React.ReactElement {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
||||||
@@ -141,7 +214,6 @@ function App(): React.ReactElement {
|
|||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
||||||
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
||||||
const [dateRange, setDateRange] = useState('today') // 默认今天
|
|
||||||
const [contentFilter, setContentFilter] = useState('')
|
const [contentFilter, setContentFilter] = useState('')
|
||||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||||
@@ -153,6 +225,7 @@ function App(): React.ReactElement {
|
|||||||
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
|
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
|
||||||
)
|
)
|
||||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
const [activePage, setActivePage] = useState<AppPage>('archive')
|
||||||
|
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
||||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||||
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
|
||||||
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
|
||||||
@@ -172,17 +245,49 @@ function App(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
||||||
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
||||||
|
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]')
|
||||||
|
return Array.isArray(stored) ? (stored as ExportTaskRecord[]).slice(0, 20) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
|
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
|
||||||
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
|
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
|
||||||
const [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null)
|
const [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null)
|
||||||
|
const [appearanceSettings, setAppearanceSettings] = React.useState<{
|
||||||
|
theme: 'system' | 'light' | 'dark'
|
||||||
|
compactMode: boolean
|
||||||
|
showStartupProgress: boolean
|
||||||
|
}>({ theme: 'system', compactMode: false, showStartupProgress: true })
|
||||||
|
const handleAppearanceChange = React.useCallback(
|
||||||
|
(settings: { theme: 'system' | 'light' | 'dark'; compactMode: boolean }) => {
|
||||||
|
setAppearanceSettings((current) => ({ ...current, ...settings }))
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
||||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||||
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
|
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
|
||||||
|
const messageHistoryRef = React.useRef<Message[]>([])
|
||||||
|
const messagesRef = React.useRef<Message[]>([])
|
||||||
|
const messagePrefetchRef = React.useRef<Promise<void> | null>(null)
|
||||||
|
messagesRef.current = messages
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!reportNotice) return
|
if (!reportNotice) return
|
||||||
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [reportNotice])
|
}, [reportNotice])
|
||||||
|
React.useEffect(() => {
|
||||||
|
void window.api.getSettings().then((result) => {
|
||||||
|
setAppearanceSettings({
|
||||||
|
theme: result.settings.appearanceTheme,
|
||||||
|
compactMode: result.settings.compactMode,
|
||||||
|
showStartupProgress: result.settings.showStartupProgress
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const loadAIConfig = async (): Promise<void> => {
|
const loadAIConfig = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -216,6 +321,84 @@ function App(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
const lastCapturedReportKeyRef = React.useRef('')
|
const lastCapturedReportKeyRef = React.useRef('')
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const unsubscribe = window.api.onExportProgress((progress: ExportJobProgress) => {
|
||||||
|
setExportTasks((current) =>
|
||||||
|
current.map((task) => {
|
||||||
|
if (task.jobId !== progress.jobId) return task
|
||||||
|
const status =
|
||||||
|
progress.phase === 'completed'
|
||||||
|
? 'completed'
|
||||||
|
: progress.phase === 'cancelled'
|
||||||
|
? 'cancelled'
|
||||||
|
: progress.phase === 'failed'
|
||||||
|
? 'failed'
|
||||||
|
: 'running'
|
||||||
|
return { ...task, status, progress }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return unsubscribe
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
localStorage.setItem('wxe_export_tasks', JSON.stringify(exportTasks.slice(0, 20)))
|
||||||
|
}, [exportTasks])
|
||||||
|
|
||||||
|
const handleStartExport = async (
|
||||||
|
request: ExportRequest
|
||||||
|
): Promise<import('../../shared/export').ExportResult> => {
|
||||||
|
const task: ExportTaskRecord = {
|
||||||
|
jobId: request.jobId,
|
||||||
|
contactId: request.userMd5,
|
||||||
|
contactName: request.name,
|
||||||
|
format: request.format,
|
||||||
|
status: 'running',
|
||||||
|
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
|
||||||
|
createdAt: Date.now()
|
||||||
|
}
|
||||||
|
setExportTasks((current) =>
|
||||||
|
[task, ...current.filter((item) => item.jobId !== task.jobId)].slice(0, 20)
|
||||||
|
)
|
||||||
|
const result = await window.api.startExport(request)
|
||||||
|
setExportTasks((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.jobId === request.jobId
|
||||||
|
? result.success
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
status: 'completed',
|
||||||
|
progress: {
|
||||||
|
...item.progress,
|
||||||
|
phase: 'completed',
|
||||||
|
processed: result.messageCount ?? item.progress.processed,
|
||||||
|
total: result.messageCount ?? item.progress.total,
|
||||||
|
percent: 100,
|
||||||
|
outputPath: result.outputPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...item,
|
||||||
|
status: 'failed',
|
||||||
|
progress: { ...item.progress, phase: 'failed', error: result.error }
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelExport = async (jobId: string): Promise<void> => {
|
||||||
|
await window.api.cancelExport(jobId)
|
||||||
|
setExportTasks((current) =>
|
||||||
|
current.map((task) =>
|
||||||
|
task.jobId === jobId
|
||||||
|
? { ...task, status: 'cancelled', progress: { ...task.progress, phase: 'cancelled' } }
|
||||||
|
: task
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const loadGeneratedReports = React.useCallback(async (): Promise<void> => {
|
const loadGeneratedReports = React.useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const result = await window.api.listGeneratedReports()
|
const result = await window.api.listGeneratedReports()
|
||||||
@@ -316,7 +499,7 @@ function App(): React.ReactElement {
|
|||||||
usernames.length ? 55 : 90
|
usernames.length ? 55 : 90
|
||||||
)
|
)
|
||||||
let loadedCount = 0
|
let loadedCount = 0
|
||||||
const chunkSize = 32
|
const chunkSize = 128
|
||||||
for (let index = 0; index < usernames.length; index += chunkSize) {
|
for (let index = 0; index < usernames.length; index += chunkSize) {
|
||||||
if (runId !== contactAvatarHydrationRunRef.current) return
|
if (runId !== contactAvatarHydrationRunRef.current) return
|
||||||
const chunk = usernames.slice(index, index + chunkSize)
|
const chunk = usernames.slice(index, index + chunkSize)
|
||||||
@@ -347,7 +530,7 @@ function App(): React.ReactElement {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Contacts] avatar hydrate failed:', error)
|
console.warn('[Contacts] avatar hydrate failed:', error)
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 50))
|
await new Promise((resolve) => window.setTimeout(resolve, 20))
|
||||||
}
|
}
|
||||||
if (usernames.length) onProgress?.('头像加载完成', 90)
|
if (usernames.length) onProgress?.('头像加载完成', 90)
|
||||||
}
|
}
|
||||||
@@ -395,7 +578,34 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
if (!autoLoginEnabled) return
|
if (!autoLoginEnabled) return
|
||||||
try {
|
try {
|
||||||
const result = await window.api.initDb(key)
|
const startupCacheReady = await loadStartupCache()
|
||||||
|
const initPromise = window.api.initDb(key)
|
||||||
|
if (startupCacheReady) {
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
setIsDatabaseConnected(false)
|
||||||
|
setBootState('login')
|
||||||
|
void initPromise
|
||||||
|
.then(async (result) => {
|
||||||
|
const success = typeof result === 'boolean' ? result : result.success
|
||||||
|
if (!success) {
|
||||||
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
|
setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`)
|
||||||
|
setDbKeyStatusKind('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||||
|
setIsDatabaseConnected(true)
|
||||||
|
setDbKeyStatus('已连接数据库')
|
||||||
|
// Cached contacts/self info are enough for startup. Native refresh is
|
||||||
|
// intentionally user-triggered so it cannot freeze the first session.
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('[Startup] background database init failed:', error)
|
||||||
|
setDbKeyStatusKind('error')
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await initPromise
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const success = typeof result === 'boolean' ? result : result.success
|
const success = typeof result === 'boolean' ? result : result.success
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -406,9 +616,13 @@ function App(): React.ReactElement {
|
|||||||
setIsDatabaseConnected(true)
|
setIsDatabaseConnected(true)
|
||||||
setDbKeyStatus('已自动连接')
|
setDbKeyStatus('已自动连接')
|
||||||
setDbKeyStatusKind('success')
|
setDbKeyStatusKind('success')
|
||||||
await loadContacts()
|
const hasBootstrap = await loadBootstrapCache()
|
||||||
await refreshSelfInfo(3)
|
if (hasBootstrap) {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
|
} else {
|
||||||
|
await Promise.all([loadContacts(), refreshSelfInfo(3)])
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const error = typeof result === 'boolean' ? '' : result.error
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
|
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
|
||||||
@@ -478,7 +692,7 @@ function App(): React.ReactElement {
|
|||||||
detail: '正在读取本地缓存',
|
detail: '正在读取本地缓存',
|
||||||
percent: 25
|
percent: 25
|
||||||
})
|
})
|
||||||
await loadBootstrapCache()
|
const hasBootstrap = await loadBootstrapCache()
|
||||||
// 持久化手动输入的密钥,供下次启动继续使用
|
// 持久化手动输入的密钥,供下次启动继续使用
|
||||||
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
||||||
void window.api.getSettings().then((current) => {
|
void window.api.getSettings().then((current) => {
|
||||||
@@ -494,15 +708,24 @@ function App(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
|
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
|
||||||
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
|
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
|
||||||
await loadContacts({ waitForAvatars: false })
|
if (hasBootstrap) {
|
||||||
await refreshSelfInfo(3)
|
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
void Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)]).catch(
|
||||||
|
(error) => {
|
||||||
|
console.warn('[Startup] background refresh failed:', error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)])
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
}
|
||||||
setStartupProgress({
|
setStartupProgress({
|
||||||
title: '加载完成',
|
title: '加载完成',
|
||||||
subtitle: '正在进入主页面',
|
subtitle: '正在进入主页面',
|
||||||
detail: '联系人和头像已准备好',
|
detail: '联系人和头像已准备好',
|
||||||
percent: 100
|
percent: 100
|
||||||
})
|
})
|
||||||
setIsAuthenticated(true)
|
|
||||||
setIsDatabaseConnected(true)
|
setIsDatabaseConnected(true)
|
||||||
setBootState('login')
|
setBootState('login')
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@@ -555,10 +778,14 @@ function App(): React.ReactElement {
|
|||||||
const applyGroupMemberMeta = React.useCallback(
|
const applyGroupMemberMeta = React.useCallback(
|
||||||
(contact: Contact | null, baseMessages: Message[]): Message[] => {
|
(contact: Contact | null, baseMessages: Message[]): Message[] => {
|
||||||
if (!contact || contact.type !== 'group') return baseMessages
|
if (!contact || contact.type !== 'group') return baseMessages
|
||||||
|
const enrichedMessages = enrichQuotedMessages(baseMessages, [
|
||||||
|
...messageHistoryRef.current,
|
||||||
|
...baseMessages
|
||||||
|
])
|
||||||
const memberMap = groupMemberMetaRef.current[contact.md5]
|
const memberMap = groupMemberMetaRef.current[contact.md5]
|
||||||
if (!memberMap || memberMap.size === 0) return baseMessages
|
if (!memberMap || memberMap.size === 0) return enrichedMessages
|
||||||
|
|
||||||
return baseMessages.map((message) => {
|
return enrichedMessages.map((message) => {
|
||||||
const senderId = String(message.senderId || message.name || '').trim()
|
const senderId = String(message.senderId || message.name || '').trim()
|
||||||
if (!senderId) return message
|
if (!senderId) return message
|
||||||
const member = memberMap.get(senderId)
|
const member = memberMap.get(senderId)
|
||||||
@@ -581,23 +808,57 @@ function App(): React.ReactElement {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const storeGroupMemberMeta = React.useCallback(
|
||||||
|
(contact: Contact, snapshot: GroupSnapshot): void => {
|
||||||
|
currentGroupSnapshotRef.current = snapshot
|
||||||
|
groupMemberMetaRef.current[contact.md5] = new Map(
|
||||||
|
snapshot.members.map((member) => [
|
||||||
|
member.wxid,
|
||||||
|
{ nickname: formatGroupMemberName(member), avatar: member.avatar || '' }
|
||||||
|
])
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const loadGroupMemberMeta = React.useCallback(
|
const loadGroupMemberMeta = React.useCallback(
|
||||||
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
|
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
|
||||||
if (!contact || contact.type !== 'group') return null
|
if (!contact || contact.type !== 'group') return null
|
||||||
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
|
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
|
||||||
if (!snapshot) return null
|
if (!snapshot) return null
|
||||||
currentGroupSnapshotRef.current = snapshot
|
storeGroupMemberMeta(contact, snapshot)
|
||||||
groupMemberMetaRef.current[contact.md5] = new Map(
|
|
||||||
snapshot.members.map((member) => [
|
|
||||||
member.wxid,
|
|
||||||
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
|
|
||||||
])
|
|
||||||
)
|
|
||||||
return snapshot
|
return snapshot
|
||||||
},
|
},
|
||||||
[logGroupSnapshot]
|
[logGroupSnapshot, storeGroupMemberMeta]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleReloadCurrentAvatars = React.useCallback(async (): Promise<void> => {
|
||||||
|
const contact = selectedContact
|
||||||
|
if (!contact) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName])
|
||||||
|
const avatar = avatars[contact.m_nsUsrName]
|
||||||
|
if (avatar) {
|
||||||
|
const updateContact = (item: Contact): Contact =>
|
||||||
|
item.md5 === contact.md5 ? { ...item, avatar } : item
|
||||||
|
setContacts((current) => current.map(updateContact))
|
||||||
|
setFilteredContacts((current) => current.map(updateContact))
|
||||||
|
setSelectedContact((current) =>
|
||||||
|
current?.md5 === contact.md5 ? updateContact(current) : current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Contacts] current avatar reload failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await loadGroupMemberMeta(contact)
|
||||||
|
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
setMessages((current) =>
|
||||||
|
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId))
|
||||||
|
)
|
||||||
|
}, [applyGroupMemberMeta, loadGroupMemberMeta, mergeSyntheticMessages, selectedContact])
|
||||||
|
|
||||||
const handleAutoGetDbKey = async (): Promise<void> => {
|
const handleAutoGetDbKey = async (): Promise<void> => {
|
||||||
if (isFetchingDbKey) return
|
if (isFetchingDbKey) return
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
@@ -670,65 +931,57 @@ function App(): React.ReactElement {
|
|||||||
setStartupProgress(null)
|
setStartupProgress(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDateRangeParams = (
|
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
|
||||||
range: string
|
setArchiveJumpTime(null)
|
||||||
): { startTime: number | undefined; endTime: number | undefined } => {
|
|
||||||
const now = new Date()
|
|
||||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
|
||||||
|
|
||||||
let startTime: number | undefined
|
|
||||||
let endTime: number | undefined
|
|
||||||
|
|
||||||
switch (range) {
|
|
||||||
case 'today':
|
|
||||||
startTime = startOfToday
|
|
||||||
break
|
|
||||||
case 'yesterday':
|
|
||||||
startTime = startOfToday - 86400
|
|
||||||
endTime = startOfToday - 1 // 昨天结束
|
|
||||||
break
|
|
||||||
case '7':
|
|
||||||
startTime = Math.floor(Date.now() / 1000) - 7 * 86400
|
|
||||||
break
|
|
||||||
case '30':
|
|
||||||
startTime = Math.floor(Date.now() / 1000) - 30 * 86400
|
|
||||||
break
|
|
||||||
case 'all':
|
|
||||||
startTime = undefined
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
startTime = startOfToday
|
|
||||||
}
|
|
||||||
return { startTime, endTime }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelectContact = async (contact: Contact): Promise<void> => {
|
|
||||||
setSelectedContact(contact)
|
setSelectedContact(contact)
|
||||||
selectedContactMd5Ref.current = contact.md5
|
selectedContactMd5Ref.current = contact.md5
|
||||||
currentGroupSnapshotRef.current = null
|
currentGroupSnapshotRef.current = null
|
||||||
setIsMessagesLoading(true)
|
setIsMessagesLoading(true)
|
||||||
const { startTime, endTime } = getDateRangeParams(dateRange)
|
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
|
||||||
const cachedMsgs = await window.api.getCachedMessages(contact.md5, startTime, endTime)
|
const cachedMsgs = cachedPage.messages
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
if (cachedMsgs.length) {
|
if (cachedPage.groupSnapshot) {
|
||||||
|
storeGroupMemberMeta(contact, cachedPage.groupSnapshot)
|
||||||
|
}
|
||||||
|
messageHistoryRef.current = cachedMsgs
|
||||||
setMessages(
|
setMessages(
|
||||||
applyGroupMemberMeta(
|
applyGroupMemberMeta(
|
||||||
contact,
|
contact,
|
||||||
mergeSyntheticMessages(contact, cachedMsgs.slice(-VIEW_MESSAGE_LIMIT))
|
mergeSyntheticMessages(contact, cachedMsgs.slice(-INITIAL_MESSAGE_COUNT))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
const needsLivePage = forceLive || isDatabaseConnected
|
||||||
setMessages([])
|
if (!needsLivePage) {
|
||||||
|
setIsMessagesLoading(false)
|
||||||
|
if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
void loadGroupMemberMeta(contact).then((snapshot) => {
|
||||||
|
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
setMessages((current) =>
|
||||||
|
applyGroupMemberMeta(
|
||||||
|
contact,
|
||||||
|
mergeSyntheticMessages(contact, current, snapshot.roomId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (cachedMsgs.length >= INITIAL_MESSAGE_COUNT) setIsMessagesLoading(false)
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
try {
|
const loadLivePage = async (): Promise<void> => {
|
||||||
const msgs = await window.api.getMessages(contact.md5, startTime, endTime, {
|
const msgs = await window.api.getMessages(contact.md5, undefined, undefined, {
|
||||||
limit: VIEW_MESSAGE_LIMIT
|
limit: MESSAGE_PREFETCH_COUNT
|
||||||
})
|
})
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
|
messageHistoryRef.current = msgs
|
||||||
setMessages(cachedMessages)
|
const visibleMessages = applyGroupMemberMeta(
|
||||||
if (contact.type === 'group') {
|
contact,
|
||||||
|
mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT))
|
||||||
|
)
|
||||||
|
setMessages(visibleMessages)
|
||||||
|
if (contact.type === 'group' && visibleMessages.length > 0) {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
void loadGroupMemberMeta(contact).then((snapshot) => {
|
void loadGroupMemberMeta(contact).then((snapshot) => {
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
@@ -742,42 +995,120 @@ function App(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
}, 120)
|
}, 120)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const prefetchPromise = loadLivePage()
|
||||||
|
messagePrefetchRef.current = prefetchPromise
|
||||||
|
try {
|
||||||
|
await prefetchPromise
|
||||||
|
} finally {
|
||||||
|
if (messagePrefetchRef.current === prefetchPromise) messagePrefetchRef.current = null
|
||||||
|
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenSearchEvidence = async (contact: Contact, createTime?: number): Promise<void> => {
|
||||||
|
setActivePage('archive')
|
||||||
|
await handleSelectContact(contact)
|
||||||
|
if (!createTime || selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const windowStart = Math.max(0, createTime - 12 * 3600)
|
||||||
|
const windowEnd = createTime + 12 * 3600
|
||||||
|
const nearbyMessages = await window.api.getMessages(contact.md5, windowStart, windowEnd)
|
||||||
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
const focusedMessages = sortMessagesChronologically(nearbyMessages)
|
||||||
|
messageHistoryRef.current = focusedMessages
|
||||||
|
setMessages(applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, focusedMessages)))
|
||||||
|
setArchiveJumpTime(createTime)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Search] evidence context load failed:', error)
|
||||||
|
setReportNotice('证据所在时间段加载失败,请在档案中手动查看')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isDatabaseConnected || !selectedContact) return
|
||||||
|
void handleSelectContact(selectedContact)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isDatabaseConnected])
|
||||||
|
|
||||||
|
const loadStartupCache = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const cache = await window.api.getStartupCache()
|
||||||
|
if (!cache?.contacts.length) return false
|
||||||
|
setContacts(cache.contacts)
|
||||||
|
setFilteredContacts(cache.contacts)
|
||||||
|
if (cache.self) setSelfInfo(cache.self)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[StartupCache] load failed:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadOlderMessages = async (): Promise<void> => {
|
||||||
|
const contact = selectedContact
|
||||||
|
if (!contact || messagesRef.current.length === 0) return
|
||||||
|
if (messagePrefetchRef.current) await messagePrefetchRef.current
|
||||||
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
const currentMessages = messagesRef.current
|
||||||
|
const historyMessages = messageHistoryRef.current
|
||||||
|
const firstVisibleIndex = historyMessages.findIndex(
|
||||||
|
(message) => getMessageIdentity(message) === getMessageIdentity(currentMessages[0])
|
||||||
|
)
|
||||||
|
if (firstVisibleIndex > 0) {
|
||||||
|
const inMemoryOlder = historyMessages.slice(
|
||||||
|
Math.max(0, firstVisibleIndex - MESSAGE_PAGE_SIZE),
|
||||||
|
firstVisibleIndex
|
||||||
|
)
|
||||||
|
setMessages((current) =>
|
||||||
|
applyGroupMemberMeta(
|
||||||
|
contact,
|
||||||
|
mergeSyntheticMessages(contact, mergeMessagePages(inMemoryOlder, current))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const oldestTime = currentMessages[0]?.createTime
|
||||||
|
if (!oldestTime) return
|
||||||
|
|
||||||
|
setIsMessagesLoading(true)
|
||||||
|
try {
|
||||||
|
const cachedPage = await window.api.getCachedMessagePage(
|
||||||
|
contact.md5,
|
||||||
|
undefined,
|
||||||
|
oldestTime - 1
|
||||||
|
)
|
||||||
|
const olderMessages = cachedPage.hit
|
||||||
|
? cachedPage.messages
|
||||||
|
: await window.api.getMessages(contact.md5, undefined, oldestTime - 1, {
|
||||||
|
limit: MESSAGE_PAGE_SIZE
|
||||||
|
})
|
||||||
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
|
||||||
|
setMessages((current) =>
|
||||||
|
applyGroupMemberMeta(
|
||||||
|
contact,
|
||||||
|
mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Messages] older page load failed:', error)
|
||||||
} finally {
|
} finally {
|
||||||
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDateRangeChange = (range: string): void => {
|
const loadExportPreview = async (contact: Contact): Promise<void> => {
|
||||||
setDateRange(range)
|
setSelectedContact(contact)
|
||||||
if (selectedContact) {
|
selectedContactMd5Ref.current = contact.md5
|
||||||
const { startTime, endTime } = getDateRangeParams(range)
|
try {
|
||||||
window.api
|
const previewMessages = await window.api.getMessages(contact.md5, undefined, undefined, {
|
||||||
.getCachedMessages(selectedContact.md5, startTime, endTime)
|
limit: EXPORT_PREVIEW_LIMIT
|
||||||
.then((cachedMessages) => {
|
|
||||||
if (!cachedMessages.length) return
|
|
||||||
setMessages(
|
|
||||||
applyGroupMemberMeta(
|
|
||||||
selectedContact,
|
|
||||||
mergeSyntheticMessages(selectedContact, cachedMessages.slice(-VIEW_MESSAGE_LIMIT))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
setIsMessagesLoading(true)
|
|
||||||
window.api
|
|
||||||
.getMessages(selectedContact.md5, startTime, endTime, { limit: VIEW_MESSAGE_LIMIT })
|
|
||||||
.then((nextMessages) => {
|
|
||||||
setMessages(
|
|
||||||
applyGroupMemberMeta(
|
|
||||||
selectedContact,
|
|
||||||
mergeSyntheticMessages(selectedContact, nextMessages)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
setIsMessagesLoading(false)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.warn('[Messages] date range load failed:', error)
|
|
||||||
setIsMessagesLoading(false)
|
|
||||||
})
|
})
|
||||||
|
if (selectedContactMd5Ref.current === contact.md5) setMessages(previewMessages)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Export] preview load failed:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,13 +1128,9 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
refreshInFlight = true
|
refreshInFlight = true
|
||||||
try {
|
try {
|
||||||
const range = getDateRangeParams(dateRange)
|
const latestMessages = await window.api.getMessages(contactMd5, undefined, undefined, {
|
||||||
const latestMessages = await window.api.getMessages(
|
limit: INITIAL_MESSAGE_COUNT
|
||||||
contactMd5,
|
})
|
||||||
range.startTime,
|
|
||||||
range.endTime,
|
|
||||||
{ limit: VIEW_MESSAGE_LIMIT }
|
|
||||||
)
|
|
||||||
const nextMessages = applyGroupMemberMeta(
|
const nextMessages = applyGroupMemberMeta(
|
||||||
selectedContact,
|
selectedContact,
|
||||||
mergeSyntheticMessages(selectedContact, latestMessages)
|
mergeSyntheticMessages(selectedContact, latestMessages)
|
||||||
@@ -827,7 +1154,12 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = window.api.onWcdbChange(() => {
|
const unsubscribe = window.api.onWcdbChange(({ json }) => {
|
||||||
|
const eventText = String(json || '').toLowerCase()
|
||||||
|
const targetIds = [contactMd5, selectedContact.m_nsUsrName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((value) => value.toLowerCase())
|
||||||
|
if (!targetIds.some((targetId) => eventText.includes(targetId))) return
|
||||||
if (refreshTimer) window.clearTimeout(refreshTimer)
|
if (refreshTimer) window.clearTimeout(refreshTimer)
|
||||||
refreshTimer = window.setTimeout(() => {
|
refreshTimer = window.setTimeout(() => {
|
||||||
refreshTimer = null
|
refreshTimer = null
|
||||||
@@ -841,7 +1173,6 @@ function App(): React.ReactElement {
|
|||||||
unsubscribe()
|
unsubscribe()
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
dateRange,
|
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isNativeMonitorActive,
|
isNativeMonitorActive,
|
||||||
selectedContact,
|
selectedContact,
|
||||||
@@ -869,6 +1200,8 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
const handlePageChange = (page: AppPage): void => {
|
const handlePageChange = (page: AppPage): void => {
|
||||||
setActivePage(page)
|
setActivePage(page)
|
||||||
|
if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact)
|
||||||
|
if (page === 'export' && selectedContact) void loadExportPreview(selectedContact)
|
||||||
if (page === 'settings') setSettingsCategory('account-database')
|
if (page === 'settings') setSettingsCategory('account-database')
|
||||||
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
|
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
|
||||||
setReportSourceContact(selectedContact)
|
setReportSourceContact(selectedContact)
|
||||||
@@ -1038,24 +1371,6 @@ function App(): React.ReactElement {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPlaceholderPage = (
|
|
||||||
page: Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>
|
|
||||||
): React.ReactElement => {
|
|
||||||
const labels: Record<Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>, string> = {
|
|
||||||
search: '检索',
|
|
||||||
export: '导出',
|
|
||||||
api: 'API',
|
|
||||||
settings: '设置'
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="app-page-placeholder">
|
|
||||||
<div className="app-page-placeholder-eyebrow">WechatExplorer</div>
|
|
||||||
<h2>{labels[page]}</h2>
|
|
||||||
<p>这个工作区会在后续 UI 重构阶段接入真实功能。</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderArchiveWorkspace = (): React.ReactElement => (
|
const renderArchiveWorkspace = (): React.ReactElement => (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -1065,8 +1380,6 @@ function App(): React.ReactElement {
|
|||||||
onSearch={handleSearchContacts}
|
onSearch={handleSearchContacts}
|
||||||
onContentFilter={setContentFilter}
|
onContentFilter={setContentFilter}
|
||||||
width={sidebarWidth}
|
width={sidebarWidth}
|
||||||
dateRange={dateRange}
|
|
||||||
onDateRangeChange={handleDateRangeChange}
|
|
||||||
selfInfo={selfInfo}
|
selfInfo={selfInfo}
|
||||||
dbReady={isDatabaseConnected}
|
dbReady={isDatabaseConnected}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
@@ -1078,12 +1391,14 @@ function App(): React.ReactElement {
|
|||||||
messages={messages}
|
messages={messages}
|
||||||
isLoadingMessages={isMessagesLoading}
|
isLoadingMessages={isMessagesLoading}
|
||||||
contentFilter={contentFilter}
|
contentFilter={contentFilter}
|
||||||
dateRange={dateRange}
|
|
||||||
onContentFilterChange={setContentFilter}
|
onContentFilterChange={setContentFilter}
|
||||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
|
||||||
onRefreshData={loadContacts}
|
onRefreshData={loadContacts}
|
||||||
|
onReloadAvatars={handleReloadCurrentAvatars}
|
||||||
|
onLoadOlderMessages={handleLoadOlderMessages}
|
||||||
onCreateGroupReport={handleOpenReportWorkspace}
|
onCreateGroupReport={handleOpenReportWorkspace}
|
||||||
isAiLoading={reportGeneration.isGenerating}
|
isAiLoading={reportGeneration.isGenerating}
|
||||||
|
jumpToTime={archiveJumpTime}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1198,11 +1513,39 @@ function App(): React.ReactElement {
|
|||||||
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)}
|
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)}
|
||||||
onNotice={setReportNotice}
|
onNotice={setReportNotice}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
|
onAppearanceChange={handleAppearanceChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 'search':
|
case 'search':
|
||||||
|
return (
|
||||||
|
<AISearchWorkspace
|
||||||
|
contacts={contacts}
|
||||||
|
selectedContact={selectedContact}
|
||||||
|
dbReady={isDatabaseConnected}
|
||||||
|
aiModelConfig={aiModelConfig}
|
||||||
|
onSelectContact={(contact) => void handleSelectContact(contact)}
|
||||||
|
onOpenEvidence={(contact, createTime) =>
|
||||||
|
void handleOpenSearchEvidence(contact, createTime)
|
||||||
|
}
|
||||||
|
onOpenAISettings={openModelSettings}
|
||||||
|
onNotice={setReportNotice}
|
||||||
|
/>
|
||||||
|
)
|
||||||
case 'export':
|
case 'export':
|
||||||
return renderPlaceholderPage(activePage)
|
return (
|
||||||
|
<ExportWorkspace
|
||||||
|
contacts={contacts}
|
||||||
|
selectedContact={selectedContact}
|
||||||
|
previewMessages={messages}
|
||||||
|
selfInfo={selfInfo}
|
||||||
|
dbReady={isDatabaseConnected}
|
||||||
|
onSelectContact={loadExportPreview}
|
||||||
|
onOpenSettings={openSettings}
|
||||||
|
exportTasks={exportTasks}
|
||||||
|
onStartExport={handleStartExport}
|
||||||
|
onCancelExport={handleCancelExport}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1257,7 +1600,7 @@ function App(): React.ReactElement {
|
|||||||
: '使用上次安全保存的密钥'
|
: '使用上次安全保存的密钥'
|
||||||
: 'WechatExplorer')
|
: 'WechatExplorer')
|
||||||
return (
|
return (
|
||||||
<div className="boot-splash">
|
<div className={`boot-splash ${appearanceSettings.showStartupProgress ? '' : 'is-quiet'}`}>
|
||||||
<div className="boot-splash-spinner" aria-hidden />
|
<div className="boot-splash-spinner" aria-hidden />
|
||||||
<div className="boot-splash-title">{title}</div>
|
<div className="boot-splash-title">{title}</div>
|
||||||
<div className="boot-splash-subtitle">{subtitle}</div>
|
<div className="boot-splash-subtitle">{subtitle}</div>
|
||||||
@@ -1308,6 +1651,8 @@ function App(): React.ReactElement {
|
|||||||
dbReady={isDatabaseConnected}
|
dbReady={isDatabaseConnected}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
|
appearanceTheme={appearanceSettings.theme}
|
||||||
|
compactMode={appearanceSettings.compactMode}
|
||||||
>
|
>
|
||||||
{reportNotice && <div className="app-toast">{reportNotice}</div>}
|
{reportNotice && <div className="app-toast">{reportNotice}</div>}
|
||||||
{renderCurrentWorkspace()}
|
{renderCurrentWorkspace()}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { ChatHeader } from './chat/ChatHeader'
|
|||||||
import { ChatStatusBar } from './chat/ChatStatusBar'
|
import { ChatStatusBar } from './chat/ChatStatusBar'
|
||||||
import { DataTrustBar } from './chat/DataTrustBar'
|
import { DataTrustBar } from './chat/DataTrustBar'
|
||||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||||
import { ExportRange } from './chat/ExportMenu'
|
|
||||||
import { MessageList } from './chat/MessageList'
|
import { MessageList } from './chat/MessageList'
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
@@ -12,50 +11,14 @@ interface ChatWindowProps {
|
|||||||
messages: Message[]
|
messages: Message[]
|
||||||
isLoadingMessages?: boolean
|
isLoadingMessages?: boolean
|
||||||
contentFilter?: string
|
contentFilter?: string
|
||||||
dateRange?: string
|
|
||||||
onContentFilterChange?: (keyword: string) => void
|
onContentFilterChange?: (keyword: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onRefreshData?: () => void
|
onRefreshData?: () => void
|
||||||
|
onReloadAvatars?: () => Promise<void>
|
||||||
|
onLoadOlderMessages?: () => Promise<void>
|
||||||
onCreateGroupReport?: () => void
|
onCreateGroupReport?: () => void
|
||||||
isAiLoading?: boolean
|
isAiLoading?: boolean
|
||||||
}
|
jumpToTime?: number | null
|
||||||
|
|
||||||
const DATE_RANGE_LABELS: Record<string, string> = {
|
|
||||||
today: '今天',
|
|
||||||
yesterday: '昨日',
|
|
||||||
'7': '7 天',
|
|
||||||
'30': '30 天',
|
|
||||||
all: '全部'
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatClock = (date: Date): string =>
|
|
||||||
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
|
||||||
|
|
||||||
const formatRangeDate = (date: Date, now: Date): string => {
|
|
||||||
const clock = formatClock(date)
|
|
||||||
if (date.getFullYear() === now.getFullYear()) {
|
|
||||||
return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
|
||||||
}
|
|
||||||
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChatHeaderRangeLabel = (range: string): string => {
|
|
||||||
const now = new Date()
|
|
||||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
||||||
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
|
|
||||||
|
|
||||||
if (range === 'today') return `今天 00:00—现在`
|
|
||||||
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
|
|
||||||
if (range === '7') {
|
|
||||||
const start = new Date(Date.now() - 7 * 86400000)
|
|
||||||
return `${formatRangeDate(start, now)}—现在`
|
|
||||||
}
|
|
||||||
if (range === '30') {
|
|
||||||
const start = new Date(Date.now() - 30 * 86400000)
|
|
||||||
return `${formatRangeDate(start, now)}—现在`
|
|
||||||
}
|
|
||||||
if (range === 'all') return '全部记录'
|
|
||||||
return DATE_RANGE_LABELS[range] || '当前范围'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||||
@@ -63,12 +26,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
messages,
|
messages,
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
contentFilter,
|
contentFilter,
|
||||||
dateRange = 'today',
|
|
||||||
onContentFilterChange,
|
onContentFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData,
|
onRefreshData,
|
||||||
|
onReloadAvatars,
|
||||||
|
onLoadOlderMessages,
|
||||||
onCreateGroupReport,
|
onCreateGroupReport,
|
||||||
isAiLoading = false
|
isAiLoading = false,
|
||||||
|
jumpToTime
|
||||||
}) => {
|
}) => {
|
||||||
const isGroupChat = Boolean(
|
const isGroupChat = Boolean(
|
||||||
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
||||||
@@ -85,6 +50,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
)
|
)
|
||||||
const [showAvatar, setShowAvatar] = useState(true)
|
const [showAvatar, setShowAvatar] = useState(true)
|
||||||
const [isAtLatest, setIsAtLatest] = useState(true)
|
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||||
|
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
|
||||||
|
const previousScrollTopRef = useRef(0)
|
||||||
|
|
||||||
const scrollToBottom = useCallback((): void => {
|
const scrollToBottom = useCallback((): void => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||||
@@ -94,13 +61,37 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
||||||
const target = event.currentTarget
|
const target = event.currentTarget
|
||||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||||
setIsAtLatest(distanceToBottom <= 24)
|
if (distanceToBottom <= 24) {
|
||||||
|
setIsAtLatest(true)
|
||||||
|
} else if (target.scrollTop < previousScrollTopRef.current - 1) {
|
||||||
|
setIsAtLatest(false)
|
||||||
|
}
|
||||||
|
previousScrollTopRef.current = target.scrollTop
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
previousScrollTopRef.current = 0
|
||||||
|
setIsAtLatest(true)
|
||||||
|
}, [contact?.md5])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (jumpToTime !== undefined && jumpToTime !== null) setIsAtLatest(false)
|
||||||
|
}, [jumpToTime])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
|
||||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||||
return () => window.cancelAnimationFrame(frame)
|
return () => window.cancelAnimationFrame(frame)
|
||||||
}, [messages, scrollToBottom])
|
}, [isAtLatest, jumpToTime, messages, scrollToBottom])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
|
||||||
|
const content = messageListRef.current?.querySelector('.virtual-message-list')
|
||||||
|
if (!content) return
|
||||||
|
const observer = new ResizeObserver(() => scrollToBottom())
|
||||||
|
observer.observe(content)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [contact?.md5, isAtLatest, jumpToTime, scrollToBottom])
|
||||||
|
|
||||||
const openImagePreview = (imageUrl: string): void => {
|
const openImagePreview = (imageUrl: string): void => {
|
||||||
setPreviewImage(imageUrl)
|
setPreviewImage(imageUrl)
|
||||||
@@ -153,6 +144,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
imageDragRef.current = null
|
imageDragRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReloadAvatars = async (): Promise<void> => {
|
||||||
|
if (!onReloadAvatars || isReloadingAvatars) return
|
||||||
|
setIsReloadingAvatars(true)
|
||||||
|
try {
|
||||||
|
await onReloadAvatars()
|
||||||
|
} finally {
|
||||||
|
setIsReloadingAvatars(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!previewImage) return
|
if (!previewImage) return
|
||||||
|
|
||||||
@@ -171,65 +172,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}
|
}
|
||||||
}, [previewImage])
|
}, [previewImage])
|
||||||
|
|
||||||
const handleExport = (days: ExportRange): void => {
|
|
||||||
if (!messages.length) return
|
|
||||||
|
|
||||||
let filtered = messages
|
|
||||||
if (days !== 'all') {
|
|
||||||
const now = new Date()
|
|
||||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
|
||||||
|
|
||||||
filtered = messages.filter((m) => {
|
|
||||||
const parsed = new Date(m.datetime).getTime()
|
|
||||||
if (isNaN(parsed)) return true
|
|
||||||
|
|
||||||
if (days === 0) {
|
|
||||||
// 今天
|
|
||||||
return parsed >= startOfDay
|
|
||||||
} else if (days === 1) {
|
|
||||||
// 昨天
|
|
||||||
const startOfYesterday = startOfDay - 86400000
|
|
||||||
return parsed >= startOfYesterday && parsed < startOfDay
|
|
||||||
} else if (days === 7) {
|
|
||||||
// 过去 7 天
|
|
||||||
const startOf7DaysAgo = startOfDay - 7 * 86400000
|
|
||||||
return parsed >= startOf7DaysAgo
|
|
||||||
} else if (days === 30) {
|
|
||||||
// 过去 30 天
|
|
||||||
const startOf30DaysAgo = startOfDay - 30 * 86400000
|
|
||||||
return parsed >= startOf30DaysAgo
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = ['发送者', '类型', '时间', '内容']
|
|
||||||
const csvContent = [
|
|
||||||
headers.join(','),
|
|
||||||
...filtered.map((m) => {
|
|
||||||
let prefix = ''
|
|
||||||
if (isGroupChat) {
|
|
||||||
prefix = m.name ? `${m.name}: ` : ''
|
|
||||||
} else {
|
|
||||||
const name = m.from === 'user' ? contact?.m_nsNickName || '未知' : '我'
|
|
||||||
prefix = `${name}: `
|
|
||||||
}
|
|
||||||
const fullContent = `${prefix}${m.content}`
|
|
||||||
const content = fullContent.replace(/"/g, '""').replace(/\n/g, ' ')
|
|
||||||
return `"${m.from}","${m.type}","${m.datetime}","${content}"`
|
|
||||||
})
|
|
||||||
].join('\n')
|
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.setAttribute('download', `${contact?.m_nsNickName || 'chat'}_export.csv`)
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredMessages = React.useMemo(() => {
|
const filteredMessages = React.useMemo(() => {
|
||||||
return messages.filter((msg) => {
|
return messages.filter((msg) => {
|
||||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||||
@@ -243,23 +185,18 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}, [messages, contentFilter])
|
}, [messages, contentFilter])
|
||||||
if (!contact) return <EmptyConversationState />
|
if (!contact) return <EmptyConversationState />
|
||||||
|
|
||||||
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-window">
|
<div className="chat-window">
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
contact={contact}
|
contact={contact}
|
||||||
isGroupChat={isGroupChat}
|
isGroupChat={isGroupChat}
|
||||||
dateRangeLabel={dateRangeLabel}
|
|
||||||
loadedCount={messages.length}
|
loadedCount={messages.length}
|
||||||
filteredCount={filteredMessages.length}
|
filteredCount={filteredMessages.length}
|
||||||
contentFilter={contentFilter || ''}
|
contentFilter={contentFilter || ''}
|
||||||
isAiLoading={isAiLoading}
|
isAiLoading={isAiLoading}
|
||||||
canExport={messages.length > 0}
|
|
||||||
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onRefreshData={onRefreshData}
|
onRefreshData={onRefreshData}
|
||||||
onExport={handleExport}
|
|
||||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||||
/>
|
/>
|
||||||
<DataTrustBar messageCount={messages.length} />
|
<DataTrustBar messageCount={messages.length} />
|
||||||
@@ -273,13 +210,17 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
listRef={messageListRef}
|
listRef={messageListRef}
|
||||||
bottomRef={messagesEndRef}
|
bottomRef={messagesEndRef}
|
||||||
onScroll={handleMessageListScroll}
|
onScroll={handleMessageListScroll}
|
||||||
|
onReachTop={onLoadOlderMessages}
|
||||||
onImageClick={openImagePreview}
|
onImageClick={openImagePreview}
|
||||||
|
jumpToTime={jumpToTime}
|
||||||
/>
|
/>
|
||||||
<ChatStatusBar
|
<ChatStatusBar
|
||||||
count={filteredMessages.length}
|
count={filteredMessages.length}
|
||||||
showAvatar={showAvatar}
|
showAvatar={showAvatar}
|
||||||
isAtLatest={isAtLatest}
|
isAtLatest={isAtLatest}
|
||||||
|
isReloadingAvatars={isReloadingAvatars}
|
||||||
onShowAvatarChange={setShowAvatar}
|
onShowAvatarChange={setShowAvatar}
|
||||||
|
onReloadAvatars={() => void handleReloadAvatars()}
|
||||||
onJumpToLatest={scrollToBottom}
|
onJumpToLatest={scrollToBottom}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import type { JSX, MouseEvent } from 'react'
|
import type { JSX, MouseEvent } from 'react'
|
||||||
|
import { getCachedLoadedImage, requestImage } from './image-loader'
|
||||||
|
|
||||||
interface ImageBubbleProps {
|
interface ImageBubbleProps {
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
isThumb?: boolean
|
isThumb?: boolean
|
||||||
|
fallbackUrl?: string
|
||||||
onImageClick?: (imageUrl: string) => void
|
onImageClick?: (imageUrl: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,39 +15,91 @@ export function ImageBubble({
|
|||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
sessionId,
|
sessionId,
|
||||||
isThumb = false,
|
fallbackUrl,
|
||||||
onImageClick
|
onImageClick
|
||||||
}: ImageBubbleProps): JSX.Element {
|
}: ImageBubbleProps): JSX.Element {
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
const initialCachedImage = getCachedLoadedImage(imageMd5, imageDatName, {
|
||||||
|
preferThumbnail: true
|
||||||
|
})
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [upgrading, setUpgrading] = useState(false)
|
const [upgrading, setUpgrading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isThumbnail, setIsThumbnail] = useState(false)
|
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||||
|
const [usingFallback, setUsingFallback] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
const backgroundUpgradeRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const upgradeOriginalInBackground = useCallback(() => {
|
||||||
|
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return
|
||||||
|
backgroundUpgradeRef.current = true
|
||||||
|
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1)
|
||||||
|
.then((original) => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setImageUrl(original.data)
|
||||||
|
setIsThumbnail(false)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [imageDatName, imageMd5, isThumbnail, sessionId])
|
||||||
|
|
||||||
const loadImage = useCallback(async () => {
|
const loadImage = useCallback(async () => {
|
||||||
if (imageUrl || loading) return
|
if (imageUrl || loading) return
|
||||||
if (!imageMd5 && !imageDatName) {
|
if (!imageMd5 && !imageDatName) {
|
||||||
|
if (fallbackUrl) {
|
||||||
|
setImageUrl(fallbackUrl)
|
||||||
|
setUsingFallback(true)
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
setError('缺少图片标识')
|
setError('缺少图片标识')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
const result = await requestImage(
|
||||||
if (result.success && result.data?.startsWith('data:image/')) {
|
imageMd5,
|
||||||
|
imageDatName,
|
||||||
|
sessionId,
|
||||||
|
{ preferThumbnail: true },
|
||||||
|
0
|
||||||
|
)
|
||||||
setImageUrl(result.data)
|
setImageUrl(result.data)
|
||||||
setIsThumbnail(Boolean(result.isThumb))
|
setUsingFallback(false)
|
||||||
|
setIsThumbnail(result.isThumbnail)
|
||||||
|
setError(null)
|
||||||
|
if (result.isThumbnail) upgradeOriginalInBackground()
|
||||||
|
} catch (error) {
|
||||||
|
if (fallbackUrl) {
|
||||||
|
setImageUrl(fallbackUrl)
|
||||||
|
setUsingFallback(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || '加载图片失败')
|
setError(error instanceof Error ? error.message : '加载图片失败')
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
setError('加载图片失败')
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
}, [
|
||||||
|
fallbackUrl,
|
||||||
|
imageDatName,
|
||||||
|
imageMd5,
|
||||||
|
imageUrl,
|
||||||
|
loading,
|
||||||
|
sessionId,
|
||||||
|
upgradeOriginalInBackground
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
|
||||||
|
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imageUrl || loading || error) return
|
if (imageUrl || loading || error) return
|
||||||
@@ -88,12 +142,11 @@ export function ImageBubble({
|
|||||||
|
|
||||||
setUpgrading(true)
|
setUpgrading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, {
|
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
|
||||||
force: true
|
if (result.data.startsWith('data:image/')) {
|
||||||
})
|
|
||||||
if (result.success && result.data?.startsWith('data:image/')) {
|
|
||||||
setImageUrl(result.data)
|
setImageUrl(result.data)
|
||||||
setIsThumbnail(Boolean(result.isThumb))
|
setUsingFallback(false)
|
||||||
|
setIsThumbnail(result.isThumbnail)
|
||||||
setError(null)
|
setError(null)
|
||||||
onImageClick?.(result.data)
|
onImageClick?.(result.data)
|
||||||
return
|
return
|
||||||
@@ -136,7 +189,11 @@ export function ImageBubble({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="image-bubble image-loaded" onClick={handleClick}>
|
<div className="image-bubble image-loaded" onClick={handleClick}>
|
||||||
<img src={imageUrl} alt="图片" className="image-content" />
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="图片"
|
||||||
|
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
|
||||||
|
/>
|
||||||
{(upgrading || isThumbnail) && (
|
{(upgrading || isThumbnail) && (
|
||||||
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,14 +2,21 @@ import { ParsedContent } from '../../../shared/types'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { JSX, MouseEvent } from 'react'
|
import type { JSX, MouseEvent } from 'react'
|
||||||
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
|
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
|
||||||
|
import { ImageBubble } from './ImageBubble'
|
||||||
|
|
||||||
const stickerDataUrlCache = new Map<string, string>()
|
const stickerDataUrlCache = new Map<string, string>()
|
||||||
|
|
||||||
interface RichMessageBubbleProps {
|
interface RichMessageBubbleProps {
|
||||||
contentData: ParsedContent
|
contentData: ParsedContent
|
||||||
|
sessionId?: string
|
||||||
|
onImageClick?: (imageUrl: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
|
export function RichMessageBubble({
|
||||||
|
contentData,
|
||||||
|
sessionId,
|
||||||
|
onImageClick
|
||||||
|
}: RichMessageBubbleProps): JSX.Element {
|
||||||
switch (contentData.type) {
|
switch (contentData.type) {
|
||||||
case 'location':
|
case 'location':
|
||||||
return <LocationBubble data={contentData} />
|
return <LocationBubble data={contentData} />
|
||||||
@@ -17,12 +24,22 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
|
|||||||
return <CardBubble data={contentData} />
|
return <CardBubble data={contentData} />
|
||||||
case 'share':
|
case 'share':
|
||||||
return <ShareBubble data={contentData} />
|
return <ShareBubble data={contentData} />
|
||||||
|
case 'miniProgram':
|
||||||
|
return (
|
||||||
|
<MiniProgramBubble
|
||||||
|
data={contentData}
|
||||||
|
sessionId={sessionId}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'redPacket':
|
||||||
|
return <RedPacketBubble data={contentData} />
|
||||||
case 'voip':
|
case 'voip':
|
||||||
return <VoipBubble data={contentData} />
|
return <VoipBubble data={contentData} />
|
||||||
case 'sticker':
|
case 'sticker':
|
||||||
return <StickerBubble data={contentData} />
|
return <StickerBubble data={contentData} />
|
||||||
case 'quote':
|
case 'quote':
|
||||||
return <QuoteBubble data={contentData} />
|
return <QuoteBubble data={contentData} sessionId={sessionId} onImageClick={onImageClick} />
|
||||||
case 'system':
|
case 'system':
|
||||||
return <SystemBubble data={contentData} />
|
return <SystemBubble data={contentData} />
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
@@ -110,6 +127,83 @@ function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MiniProgramBubble({
|
||||||
|
data,
|
||||||
|
sessionId,
|
||||||
|
onImageClick
|
||||||
|
}: {
|
||||||
|
data: Extract<ParsedContent, { type: 'miniProgram' }>
|
||||||
|
sessionId?: string
|
||||||
|
onImageClick?: (imageUrl: string) => void
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="mini-program-message">
|
||||||
|
<div className="mini-program-title">{data.title}</div>
|
||||||
|
{data.description && <div className="mini-program-description">{data.description}</div>}
|
||||||
|
{data.thumbDataUrl ? (
|
||||||
|
<div className="mini-program-preview">
|
||||||
|
<img
|
||||||
|
className="mini-program-inline-image"
|
||||||
|
src={data.thumbDataUrl}
|
||||||
|
alt={data.title}
|
||||||
|
onClick={() => onImageClick?.(data.thumbDataUrl || '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : data.thumbMd5 ? (
|
||||||
|
<div className="mini-program-preview">
|
||||||
|
<ImageBubble
|
||||||
|
imageMd5={data.thumbMd5}
|
||||||
|
imageDatName={data.thumbDatName}
|
||||||
|
sessionId={sessionId}
|
||||||
|
isThumb
|
||||||
|
fallbackUrl={data.iconUrl}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : data.iconUrl ? (
|
||||||
|
<img
|
||||||
|
className="mini-program-icon"
|
||||||
|
src={data.iconUrl}
|
||||||
|
alt=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="mini-program-footer">
|
||||||
|
<span aria-hidden>⌁</span>
|
||||||
|
<span>{data.appName || '小程序'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedPacketBubble({
|
||||||
|
data
|
||||||
|
}: {
|
||||||
|
data: Extract<ParsedContent, { type: 'redPacket' }>
|
||||||
|
}): JSX.Element {
|
||||||
|
const handleClick = (): void => {
|
||||||
|
if (data.url) window.open(data.url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`red-packet-message ${data.url ? 'is-clickable' : ''}`}
|
||||||
|
onClick={data.url ? handleClick : undefined}
|
||||||
|
>
|
||||||
|
<div className="red-packet-main">
|
||||||
|
<span className="red-packet-icon" aria-hidden>
|
||||||
|
¥
|
||||||
|
</span>
|
||||||
|
<span className="red-packet-copy">
|
||||||
|
<strong>{data.title}</strong>
|
||||||
|
<small>{data.description || '恭喜发财,大吉大利'}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="red-packet-footer">微信红包</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
|
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
|
||||||
const { status, roomType, duration } = data
|
const { status, roomType, duration } = data
|
||||||
const isVideo = roomType === 1
|
const isVideo = roomType === 1
|
||||||
@@ -201,7 +295,15 @@ function StickerBubble({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
|
function QuoteBubble({
|
||||||
|
data,
|
||||||
|
sessionId,
|
||||||
|
onImageClick
|
||||||
|
}: {
|
||||||
|
data: Extract<ParsedContent, { type: 'quote' }>
|
||||||
|
sessionId?: string
|
||||||
|
onImageClick?: (imageUrl: string) => void
|
||||||
|
}): JSX.Element {
|
||||||
const quotedText = data.quotedContent || data.content || '[引用消息]'
|
const quotedText = data.quotedContent || data.content || '[引用消息]'
|
||||||
const replyText = data.content || data.title || ''
|
const replyText = data.content || data.title || ''
|
||||||
const quotedSender = data.quotedSender || data.sender || ''
|
const quotedSender = data.quotedSender || data.sender || ''
|
||||||
@@ -210,7 +312,17 @@ function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }>
|
|||||||
<div className="quote-message">
|
<div className="quote-message">
|
||||||
<div className="quoted-message">
|
<div className="quoted-message">
|
||||||
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
|
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
|
||||||
|
{data.quotedImageMd5 || data.quotedImageDatName ? (
|
||||||
|
<ImageBubble
|
||||||
|
imageMd5={data.quotedImageMd5}
|
||||||
|
imageDatName={data.quotedImageDatName}
|
||||||
|
sessionId={sessionId}
|
||||||
|
isThumb
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
|
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
|
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
interface VideoBubbleProps {
|
||||||
|
md5?: string
|
||||||
|
newMd5?: string
|
||||||
|
rawMd5?: string
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoBubble({
|
||||||
|
md5,
|
||||||
|
newMd5,
|
||||||
|
rawMd5,
|
||||||
|
duration
|
||||||
|
}: VideoBubbleProps): React.ReactElement {
|
||||||
|
const hashes = useMemo(
|
||||||
|
() => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)),
|
||||||
|
[md5, newMd5, rawMd5]
|
||||||
|
)
|
||||||
|
const [media, setMedia] = useState<{ url?: string; poster?: string; error?: string }>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
window.api
|
||||||
|
.getVideo(hashes)
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) setMedia({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [hashes])
|
||||||
|
|
||||||
|
if (!media.url) {
|
||||||
|
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="video-message">
|
||||||
|
<video
|
||||||
|
className="video-content"
|
||||||
|
src={media.url}
|
||||||
|
poster={media.poster}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
{duration ? <span className="video-duration">{duration} 秒</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,38 +1,31 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
import { ConversationContentSearch } from './ConversationContentSearch'
|
import { ConversationContentSearch } from './ConversationContentSearch'
|
||||||
import { ExportMenu, ExportRange } from './ExportMenu'
|
|
||||||
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
contact: Contact
|
contact: Contact
|
||||||
isGroupChat: boolean
|
isGroupChat: boolean
|
||||||
dateRangeLabel: string
|
|
||||||
loadedCount: number
|
loadedCount: number
|
||||||
filteredCount: number
|
filteredCount: number
|
||||||
contentFilter: string
|
contentFilter: string
|
||||||
isAiLoading: boolean
|
isAiLoading: boolean
|
||||||
canExport: boolean
|
|
||||||
onContentFilterChange: (value: string) => void
|
onContentFilterChange: (value: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onRefreshData?: () => void
|
onRefreshData?: () => void
|
||||||
onExport: (range: ExportRange) => void
|
|
||||||
onOpenAiSettings: () => void
|
onOpenAiSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({
|
export function ChatHeader({
|
||||||
contact,
|
contact,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
dateRangeLabel,
|
|
||||||
loadedCount,
|
loadedCount,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
contentFilter,
|
contentFilter,
|
||||||
isAiLoading,
|
isAiLoading,
|
||||||
canExport,
|
|
||||||
onContentFilterChange,
|
onContentFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData,
|
onRefreshData,
|
||||||
onExport,
|
|
||||||
onOpenAiSettings
|
onOpenAiSettings
|
||||||
}: ChatHeaderProps): React.ReactElement {
|
}: ChatHeaderProps): React.ReactElement {
|
||||||
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
||||||
@@ -70,7 +63,6 @@ export function ChatHeader({
|
|||||||
<h2>{displayName}</h2>
|
<h2>{displayName}</h2>
|
||||||
<div className="chat-title-meta">
|
<div className="chat-title-meta">
|
||||||
<span>{typeLabel}</span>
|
<span>{typeLabel}</span>
|
||||||
<span>{dateRangeLabel}</span>
|
|
||||||
<span>{visibleCount} 条消息</span>
|
<span>{visibleCount} 条消息</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +88,6 @@ export function ChatHeader({
|
|||||||
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
|
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
</button>
|
</button>
|
||||||
<ExportMenu disabled={!canExport} onExport={onExport} />
|
|
||||||
<div className="chat-menu" ref={moreRef}>
|
<div className="chat-menu" ref={moreRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { RefreshIcon } from './icons'
|
||||||
|
|
||||||
interface ChatStatusBarProps {
|
interface ChatStatusBarProps {
|
||||||
count: number
|
count: number
|
||||||
showAvatar: boolean
|
showAvatar: boolean
|
||||||
isAtLatest: boolean
|
isAtLatest: boolean
|
||||||
|
isReloadingAvatars: boolean
|
||||||
onShowAvatarChange: (show: boolean) => void
|
onShowAvatarChange: (show: boolean) => void
|
||||||
|
onReloadAvatars: () => void
|
||||||
onJumpToLatest: () => void
|
onJumpToLatest: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,7 +15,9 @@ export function ChatStatusBar({
|
|||||||
count,
|
count,
|
||||||
showAvatar,
|
showAvatar,
|
||||||
isAtLatest,
|
isAtLatest,
|
||||||
|
isReloadingAvatars,
|
||||||
onShowAvatarChange,
|
onShowAvatarChange,
|
||||||
|
onReloadAvatars,
|
||||||
onJumpToLatest
|
onJumpToLatest
|
||||||
}: ChatStatusBarProps): React.ReactElement {
|
}: ChatStatusBarProps): React.ReactElement {
|
||||||
const jumpDisabled = isAtLatest || count === 0
|
const jumpDisabled = isAtLatest || count === 0
|
||||||
@@ -29,6 +34,22 @@ export function ChatStatusBar({
|
|||||||
/>
|
/>
|
||||||
<span>显示头像</span>
|
<span>显示头像</span>
|
||||||
</label>
|
</label>
|
||||||
|
<span
|
||||||
|
className="chat-avatar-note"
|
||||||
|
title="头像或名称缺失时,请先在微信中进入该聊天,再尝试重新加载"
|
||||||
|
>
|
||||||
|
头像或名称缺失时,请先在微信中进入该聊天
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-avatar-reload"
|
||||||
|
onClick={onReloadAvatars}
|
||||||
|
disabled={isReloadingAvatars}
|
||||||
|
title="尝试重新加载当前聊天的头像和名称"
|
||||||
|
>
|
||||||
|
<RefreshIcon className={isReloadingAvatars ? 'is-spinning' : ''} />
|
||||||
|
<span>{isReloadingAvatars ? '加载中' : '重新加载头像'}</span>
|
||||||
|
</button>
|
||||||
<span className="chat-status-separator" aria-hidden />
|
<span className="chat-status-separator" aria-hidden />
|
||||||
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
|
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
|
||||||
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
|
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Contact, Message } from '../../../../shared/types'
|
|||||||
import { ImageBubble } from '../ImageBubble'
|
import { ImageBubble } from '../ImageBubble'
|
||||||
import { RichMessageBubble } from '../RichMessageBubble'
|
import { RichMessageBubble } from '../RichMessageBubble'
|
||||||
import { VoicePlayer } from '../VoicePlayer'
|
import { VoicePlayer } from '../VoicePlayer'
|
||||||
|
import { VideoBubble } from '../VideoBubble'
|
||||||
import { renderWechatEmojiText } from '../../utils/wechatEmojiText'
|
import { renderWechatEmojiText } from '../../utils/wechatEmojiText'
|
||||||
import { formatMessageTime } from './messageGrouping'
|
import { formatMessageTime } from './messageGrouping'
|
||||||
|
|
||||||
@@ -13,9 +14,25 @@ interface MessageBubbleProps {
|
|||||||
isMine: boolean
|
isMine: boolean
|
||||||
showAvatarSpace: boolean
|
showAvatarSpace: boolean
|
||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
|
isJumpTarget?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息']
|
const RICH_MESSAGE_TYPES = [
|
||||||
|
'名片',
|
||||||
|
'位置',
|
||||||
|
'分享消息',
|
||||||
|
'小程序',
|
||||||
|
'微信红包',
|
||||||
|
'公众号链接',
|
||||||
|
'文件',
|
||||||
|
'文件发送中',
|
||||||
|
'视频号',
|
||||||
|
'转账',
|
||||||
|
'引用消息',
|
||||||
|
'通话',
|
||||||
|
'表情包',
|
||||||
|
'系统消息'
|
||||||
|
]
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
@@ -23,19 +40,23 @@ export function MessageBubble({
|
|||||||
isGroupChat,
|
isGroupChat,
|
||||||
isMine,
|
isMine,
|
||||||
showAvatarSpace,
|
showAvatarSpace,
|
||||||
onImageClick
|
onImageClick,
|
||||||
|
isJumpTarget
|
||||||
}: MessageBubbleProps): React.ReactElement {
|
}: MessageBubbleProps): React.ReactElement {
|
||||||
const isVoice = message.type === '语音'
|
const isVoice = message.type === '语音'
|
||||||
const isImage = message.type === '图片'
|
const isImage = message.type === '图片'
|
||||||
|
const isVideo = message.type === '视频'
|
||||||
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
||||||
const hoverTime = formatMessageTime(message)
|
const hoverTime = formatMessageTime(message)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'}`}>
|
<div
|
||||||
|
className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'} ${isJumpTarget ? 'archive-jump-message' : ''}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
||||||
isImage ? 'image-message-bubble' : ''
|
isImage ? 'image-message-bubble' : ''
|
||||||
}`}
|
} ${isVideo ? 'video-message-bubble' : ''}`}
|
||||||
>
|
>
|
||||||
{isVoice && message.sessionId ? (
|
{isVoice && message.sessionId ? (
|
||||||
<VoicePlayer
|
<VoicePlayer
|
||||||
@@ -50,12 +71,28 @@ export function MessageBubble({
|
|||||||
sessionId={message.sessionId}
|
sessionId={message.sessionId}
|
||||||
onImageClick={onImageClick}
|
onImageClick={onImageClick}
|
||||||
/>
|
/>
|
||||||
|
) : isVideo && message.contentData && message.contentData.type === 'video' ? (
|
||||||
|
<VideoBubble
|
||||||
|
md5={message.contentData.md5}
|
||||||
|
newMd5={message.contentData.newMd5}
|
||||||
|
rawMd5={message.contentData.rawMd5}
|
||||||
|
duration={message.contentData.duration}
|
||||||
|
/>
|
||||||
) : isRichMedia && message.contentData ? (
|
) : isRichMedia && message.contentData ? (
|
||||||
<RichMessageBubble contentData={message.contentData} />
|
<RichMessageBubble
|
||||||
|
contentData={message.contentData}
|
||||||
|
sessionId={message.sessionId}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
|
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{message.recalled && (
|
||||||
|
<span className="message-recalled-status" title={message.recalledBy || undefined}>
|
||||||
|
消息已撤回
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="message-hover-time">{hoverTime}</span>
|
<span className="message-hover-time">{hoverTime}</span>
|
||||||
{!isGroupChat && !isMine && contact.m_nsNickName && (
|
{!isGroupChat && !isMine && contact.m_nsNickName && (
|
||||||
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
|
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface MessageGroupProps {
|
|||||||
isGroupChat: boolean
|
isGroupChat: boolean
|
||||||
showAvatar: boolean
|
showAvatar: boolean
|
||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
|
jumpTargetMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageGroup({
|
export function MessageGroup({
|
||||||
@@ -16,7 +17,8 @@ export function MessageGroup({
|
|||||||
contact,
|
contact,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
showAvatar,
|
showAvatar,
|
||||||
onImageClick
|
onImageClick,
|
||||||
|
jumpTargetMessageId
|
||||||
}: MessageGroupProps): React.ReactElement {
|
}: MessageGroupProps): React.ReactElement {
|
||||||
const firstMessage = group.messages[0]
|
const firstMessage = group.messages[0]
|
||||||
|
|
||||||
@@ -57,9 +59,7 @@ export function MessageGroup({
|
|||||||
)}
|
)}
|
||||||
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
||||||
<div className="message-stack">
|
<div className="message-stack">
|
||||||
{!isMine && isGroupChat && shouldShowAvatar && (
|
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
|
||||||
<div className="message-sender-name">{displayName}</div>
|
|
||||||
)}
|
|
||||||
{group.messages.map((message, index) => (
|
{group.messages.map((message, index) => (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
key={message.id}
|
key={message.id}
|
||||||
@@ -69,6 +69,7 @@ export function MessageGroup({
|
|||||||
isMine={isMine}
|
isMine={isMine}
|
||||||
showAvatarSpace={index === 0}
|
showAvatarSpace={index === 0}
|
||||||
onImageClick={onImageClick}
|
onImageClick={onImageClick}
|
||||||
|
isJumpTarget={message.id === jumpTargetMessageId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ interface MessageListProps {
|
|||||||
listRef: React.RefObject<HTMLDivElement | null>
|
listRef: React.RefObject<HTMLDivElement | null>
|
||||||
bottomRef: React.RefObject<HTMLDivElement | null>
|
bottomRef: React.RefObject<HTMLDivElement | null>
|
||||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
||||||
|
onReachTop?: () => Promise<void>
|
||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
|
jumpToTime?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageList({
|
export function MessageList({
|
||||||
@@ -27,11 +29,14 @@ export function MessageList({
|
|||||||
listRef,
|
listRef,
|
||||||
bottomRef,
|
bottomRef,
|
||||||
onScroll,
|
onScroll,
|
||||||
onImageClick
|
onReachTop,
|
||||||
|
onImageClick,
|
||||||
|
jumpToTime
|
||||||
}: MessageListProps): React.ReactElement {
|
}: MessageListProps): React.ReactElement {
|
||||||
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||||
// TanStack Virtual intentionally exposes mutable measurement methods.
|
const groupsRef = React.useRef(groups)
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
const loadingOlderRef = React.useRef(false)
|
||||||
|
groupsRef.current = groups
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: groups.length,
|
count: groups.length,
|
||||||
getScrollElement: () => listRef.current,
|
getScrollElement: () => listRef.current,
|
||||||
@@ -40,9 +45,79 @@ export function MessageList({
|
|||||||
overscan: 8
|
overscan: 8
|
||||||
})
|
})
|
||||||
const virtualItems = virtualizer.getVirtualItems()
|
const virtualItems = virtualizer.getVirtualItems()
|
||||||
|
const jumpTarget = React.useMemo(() => {
|
||||||
|
if (jumpToTime === undefined || jumpToTime === null) return null
|
||||||
|
const groupIndex = groups.findIndex((group) =>
|
||||||
|
group.messages.some((message) => (message.createTime || 0) >= jumpToTime)
|
||||||
|
)
|
||||||
|
if (groupIndex < 0) return null
|
||||||
|
const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime)
|
||||||
|
return { groupIndex, messageId: message?.id }
|
||||||
|
}, [groups, jumpToTime])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!jumpTarget) return
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
virtualizer.scrollToIndex(jumpTarget.groupIndex, { align: 'center' })
|
||||||
|
})
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [jumpTarget, virtualizer])
|
||||||
|
|
||||||
|
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
|
||||||
|
onScroll(event)
|
||||||
|
const scrollElement = event.currentTarget
|
||||||
|
if (
|
||||||
|
(jumpToTime !== undefined && jumpToTime !== null) ||
|
||||||
|
scrollElement.scrollTop >= 48 ||
|
||||||
|
loadingOlderRef.current ||
|
||||||
|
isLoadingMessages ||
|
||||||
|
!onReachTop
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingOlderRef.current = true
|
||||||
|
const previousGroupCount = groups.length
|
||||||
|
const previousScrollTop = scrollElement.scrollTop
|
||||||
|
const previousScrollHeight = scrollElement.scrollHeight
|
||||||
|
const anchorMessageId = groups[0]?.messages[0]?.id
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await onReachTop()
|
||||||
|
for (let frame = 0; frame < 8; frame += 1) {
|
||||||
|
if (groupsRef.current.length > previousGroupCount) break
|
||||||
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||||
|
}
|
||||||
|
if (!anchorMessageId) return
|
||||||
|
const anchorIndex = groupsRef.current.findIndex((group) =>
|
||||||
|
group.messages.some((message) => message.id === anchorMessageId)
|
||||||
|
)
|
||||||
|
if (anchorIndex <= 0) return
|
||||||
|
virtualizer.measure()
|
||||||
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||||
|
const addedHeight = scrollElement.scrollHeight - previousScrollHeight
|
||||||
|
if (addedHeight > 0) {
|
||||||
|
scrollElement.scrollTop = previousScrollTop + addedHeight
|
||||||
|
} else {
|
||||||
|
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable-height groups can finish measuring one frame later. Preserve
|
||||||
|
// the same message anchor after that final measurement as well.
|
||||||
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||||
|
if (scrollElement.scrollTop < 48) {
|
||||||
|
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
loadingOlderRef.current = false
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}>
|
<div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}>
|
||||||
{isLoadingMessages && <div className="message-loading-pill">正在加载聊天记录...</div>}
|
{isLoadingMessages && <div className="message-loading-pill">正在加载聊天记录...</div>}
|
||||||
{hiddenMessageCount > 0 && (
|
{hiddenMessageCount > 0 && (
|
||||||
<div className="wechat-system-message-row">
|
<div className="wechat-system-message-row">
|
||||||
@@ -60,7 +135,7 @@ export function MessageList({
|
|||||||
key={virtualItem.key}
|
key={virtualItem.key}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
data-index={virtualItem.index}
|
data-index={virtualItem.index}
|
||||||
className="virtual-message-group"
|
className={`virtual-message-group ${jumpTarget?.groupIndex === virtualItem.index ? 'archive-jump-target-group' : ''}`}
|
||||||
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
||||||
>
|
>
|
||||||
<MessageGroup
|
<MessageGroup
|
||||||
@@ -69,6 +144,7 @@ export function MessageList({
|
|||||||
isGroupChat={isGroupChat}
|
isGroupChat={isGroupChat}
|
||||||
showAvatar={showAvatar}
|
showAvatar={showAvatar}
|
||||||
onImageClick={onImageClick}
|
onImageClick={onImageClick}
|
||||||
|
jumpTargetMessageId={jumpTarget?.messageId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
import { ConversationItem } from './ConversationItem'
|
import { ConversationItem } from './ConversationItem'
|
||||||
|
|
||||||
@@ -29,6 +30,31 @@ export function ConversationSection({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onSelectContact
|
onSelectContact
|
||||||
}: ConversationSectionProps): React.ReactElement {
|
}: ConversationSectionProps): React.ReactElement {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [scrollMargin, setScrollMargin] = useState(0)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const updateMargin = (): void => {
|
||||||
|
const list = listRef.current
|
||||||
|
const scrollElement = document.querySelector<HTMLDivElement>('.conversation-list')
|
||||||
|
if (!list || !scrollElement) return
|
||||||
|
setScrollMargin(list.getBoundingClientRect().top - scrollElement.getBoundingClientRect().top)
|
||||||
|
}
|
||||||
|
updateMargin()
|
||||||
|
window.addEventListener('resize', updateMargin)
|
||||||
|
return () => window.removeEventListener('resize', updateMargin)
|
||||||
|
}, [contacts.length, expanded])
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: expanded ? contacts.length : 0,
|
||||||
|
getScrollElement: () => document.querySelector<HTMLDivElement>('.conversation-list'),
|
||||||
|
scrollMargin,
|
||||||
|
estimateSize: () => 58,
|
||||||
|
overscan: 8
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expanded) virtualizer.measure()
|
||||||
|
}, [contacts.length, expanded, virtualizer])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="conversation-section">
|
<section className="conversation-section">
|
||||||
<button type="button" className="conversation-section-header" onClick={onToggle}>
|
<button type="button" className="conversation-section-header" onClick={onToggle}>
|
||||||
@@ -40,16 +66,29 @@ export function ConversationSection({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="conversation-section-list">
|
<div ref={listRef} className="conversation-section-list">
|
||||||
{contacts.length > 0 ? (
|
{contacts.length > 0 ? (
|
||||||
contacts.map((contact) => (
|
<div
|
||||||
|
className="conversation-section-virtual-content"
|
||||||
|
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
|
const contact = contacts[virtualItem.index]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
className="conversation-section-virtual-row"
|
||||||
|
style={{ transform: `translateY(${virtualItem.start - scrollMargin}px)` }}
|
||||||
|
>
|
||||||
<ConversationItem
|
<ConversationItem
|
||||||
key={contact.md5}
|
|
||||||
contact={contact}
|
contact={contact}
|
||||||
active={selectedContact?.md5 === contact.md5}
|
active={selectedContact?.md5 === contact.md5}
|
||||||
onSelect={onSelectContact}
|
onSelect={onSelectContact}
|
||||||
/>
|
/>
|
||||||
))
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="conversation-section-empty">{emptyText}</div>
|
<div className="conversation-section-empty">{emptyText}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useMemo, useRef, useState } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
import { AccountSummary } from '../account/AccountSummary'
|
import { AccountSummary } from '../account/AccountSummary'
|
||||||
import { ConversationSection } from './ConversationSection'
|
import { ConversationItem } from './ConversationItem'
|
||||||
import { ConversationSidebarHeader } from './ConversationSidebarHeader'
|
import { ConversationSidebarHeader } from './ConversationSidebarHeader'
|
||||||
|
|
||||||
interface SelfInfo {
|
interface SelfInfo {
|
||||||
@@ -18,30 +19,55 @@ export interface ConversationSidebarProps {
|
|||||||
onSearch: (keyword: string) => void
|
onSearch: (keyword: string) => void
|
||||||
onContentFilter: (keyword: string) => void
|
onContentFilter: (keyword: string) => void
|
||||||
width: number
|
width: number
|
||||||
dateRange: string
|
|
||||||
onDateRangeChange: (range: string) => void
|
|
||||||
selfInfo: SelfInfo | null
|
selfInfo: SelfInfo | null
|
||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SectionName = 'groups' | 'contacts'
|
||||||
|
type ConversationRow =
|
||||||
|
| { kind: 'header'; id: string; title: string; count: number; section: SectionName }
|
||||||
|
| { kind: 'contact'; id: string; contact: Contact }
|
||||||
|
|
||||||
export function ConversationSidebar({
|
export function ConversationSidebar({
|
||||||
contacts,
|
contacts,
|
||||||
selectedContact,
|
selectedContact,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
onSearch,
|
onSearch,
|
||||||
width,
|
width,
|
||||||
dateRange,
|
|
||||||
onDateRangeChange,
|
|
||||||
selfInfo,
|
selfInfo,
|
||||||
dbReady,
|
dbReady,
|
||||||
onOpenSettings
|
onOpenSettings
|
||||||
}: ConversationSidebarProps): React.ReactElement {
|
}: ConversationSidebarProps): React.ReactElement {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [expandedSection, setExpandedSection] = useState<'groups' | 'contacts'>('groups')
|
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
||||||
|
groups: true,
|
||||||
|
contacts: false
|
||||||
|
})
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const groups = contacts.filter((contact) => contact.type === 'group')
|
const groups = contacts.filter((contact) => contact.type === 'group')
|
||||||
const users = contacts.filter((contact) => contact.type === 'user')
|
const users = contacts.filter((contact) => contact.type === 'user')
|
||||||
|
const rows = useMemo<ConversationRow[]>(
|
||||||
|
() => [
|
||||||
|
{ kind: 'header', id: 'groups-header', title: '群聊', count: groups.length, section: 'groups' },
|
||||||
|
...(expandedSections.groups
|
||||||
|
? groups.map((contact) => ({ kind: 'contact' as const, id: `group-${contact.md5}`, contact }))
|
||||||
|
: []),
|
||||||
|
{ kind: 'header', id: 'contacts-header', title: '联系人', count: users.length, section: 'contacts' },
|
||||||
|
...(expandedSections.contacts
|
||||||
|
? users.map((contact) => ({ kind: 'contact' as const, id: `user-${contact.md5}`, contact }))
|
||||||
|
: [])
|
||||||
|
],
|
||||||
|
[expandedSections.contacts, expandedSections.groups, groups, users]
|
||||||
|
)
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => listRef.current,
|
||||||
|
estimateSize: (index) => (rows[index]?.kind === 'header' ? 38 : 58),
|
||||||
|
getItemKey: (index) => rows[index]?.id || index,
|
||||||
|
overscan: 10
|
||||||
|
})
|
||||||
|
|
||||||
const handleSearchChange = (term: string): void => {
|
const handleSearchChange = (term: string): void => {
|
||||||
setSearchTerm(term)
|
setSearchTerm(term)
|
||||||
@@ -53,30 +79,50 @@ export function ConversationSidebar({
|
|||||||
<ConversationSidebarHeader
|
<ConversationSidebarHeader
|
||||||
totalCount={contacts.length}
|
totalCount={contacts.length}
|
||||||
searchValue={searchTerm}
|
searchValue={searchTerm}
|
||||||
dateRange={dateRange}
|
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onDateRangeChange={onDateRangeChange}
|
|
||||||
/>
|
/>
|
||||||
<div className="conversation-list" aria-label="会话列表">
|
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
||||||
<ConversationSection
|
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||||
title="群聊"
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
contacts={groups}
|
const row = rows[virtualItem.index]
|
||||||
expanded={expandedSection === 'groups'}
|
if (!row) return null
|
||||||
selectedContact={selectedContact}
|
if (row.kind === 'header') {
|
||||||
emptyText="暂无群聊"
|
const expanded = expandedSections[row.section]
|
||||||
onToggle={() => setExpandedSection('groups')}
|
return (
|
||||||
onSelectContact={onSelectContact}
|
<button
|
||||||
/>
|
key={virtualItem.key}
|
||||||
<ConversationSection
|
type="button"
|
||||||
title="联系人"
|
className="conversation-section-header conversation-virtual-row"
|
||||||
contacts={users}
|
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||||
expanded={expandedSection === 'contacts'}
|
onClick={() =>
|
||||||
selectedContact={selectedContact}
|
setExpandedSections((current) => ({ ...current, [row.section]: !current[row.section] }))
|
||||||
emptyText="暂无联系人"
|
}
|
||||||
onToggle={() => setExpandedSection('contacts')}
|
>
|
||||||
onSelectContact={onSelectContact}
|
<span className="conversation-section-chevron" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 16 16" focusable="false">
|
||||||
|
<path d={expanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'} />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="conversation-section-title">{row.title} ({row.count})</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
className="conversation-virtual-row"
|
||||||
|
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||||
|
>
|
||||||
|
<ConversationItem
|
||||||
|
contact={row.contact}
|
||||||
|
active={selectedContact?.md5 === row.contact.md5}
|
||||||
|
onSelect={onSelectContact}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="conversation-sidebar-account">
|
<div className="conversation-sidebar-account">
|
||||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ConversationSearch } from './ConversationSearch'
|
import { ConversationSearch } from './ConversationSearch'
|
||||||
import { DateRangeSelector } from './DateRangeSelector'
|
|
||||||
|
|
||||||
interface ConversationSidebarHeaderProps {
|
interface ConversationSidebarHeaderProps {
|
||||||
totalCount: number
|
totalCount: number
|
||||||
searchValue: string
|
searchValue: string
|
||||||
dateRange: string
|
|
||||||
onSearchChange: (value: string) => void
|
onSearchChange: (value: string) => void
|
||||||
onDateRangeChange: (range: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationSidebarHeader({
|
export function ConversationSidebarHeader({
|
||||||
totalCount,
|
totalCount,
|
||||||
searchValue,
|
searchValue,
|
||||||
dateRange,
|
onSearchChange
|
||||||
onSearchChange,
|
|
||||||
onDateRangeChange
|
|
||||||
}: ConversationSidebarHeaderProps): React.ReactElement {
|
}: ConversationSidebarHeaderProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="conversation-sidebar-header">
|
<div className="conversation-sidebar-header">
|
||||||
@@ -24,7 +19,6 @@ export function ConversationSidebarHeader({
|
|||||||
<span>{totalCount} 个会话</span>
|
<span>{totalCount} 个会话</span>
|
||||||
</div>
|
</div>
|
||||||
<ConversationSearch value={searchValue} onChange={onSearchChange} />
|
<ConversationSearch value={searchValue} onChange={onSearchChange} />
|
||||||
<DateRangeSelector value={dateRange} onChange={onDateRangeChange} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
interface DateRangeSelectorProps {
|
|
||||||
value: string
|
|
||||||
onChange: (range: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATE_RANGE_OPTIONS = [
|
|
||||||
{ key: 'today', label: '今天' },
|
|
||||||
{ key: 'yesterday', label: '昨日' },
|
|
||||||
{ key: '7', label: '7 天' },
|
|
||||||
{ key: '30', label: '30 天' },
|
|
||||||
{ key: 'all', label: '全部' }
|
|
||||||
]
|
|
||||||
|
|
||||||
export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="conversation-date-range" aria-label="时间范围">
|
|
||||||
{DATE_RANGE_OPTIONS.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
type="button"
|
|
||||||
className={`conversation-date-range-button ${value === item.key ? 'active' : ''}`}
|
|
||||||
onClick={() => onChange(item.key)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type { Contact, SelfInfo } from './exportTypes'
|
||||||
|
import { displayName } from './exportUtils'
|
||||||
|
|
||||||
|
interface ExportContactPanelProps {
|
||||||
|
contacts: Contact[]
|
||||||
|
filteredContacts: Contact[]
|
||||||
|
activeContact: Contact | null
|
||||||
|
selfInfo: SelfInfo | null
|
||||||
|
dbReady: boolean
|
||||||
|
contactFilter: string
|
||||||
|
contactType: 'all' | 'group' | 'user'
|
||||||
|
onContactFilterChange: (value: string) => void
|
||||||
|
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
|
||||||
|
onSelectContact: (contact: Contact) => void
|
||||||
|
onOpenSettings: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportContactPanel({
|
||||||
|
contacts,
|
||||||
|
filteredContacts,
|
||||||
|
activeContact,
|
||||||
|
selfInfo,
|
||||||
|
dbReady,
|
||||||
|
contactFilter,
|
||||||
|
contactType,
|
||||||
|
onContactFilterChange,
|
||||||
|
onContactTypeChange,
|
||||||
|
onSelectContact,
|
||||||
|
onOpenSettings
|
||||||
|
}: ExportContactPanelProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<aside className="export-contact-panel">
|
||||||
|
<div className="export-panel-header">
|
||||||
|
<div className="export-panel-title-row">
|
||||||
|
<h2>选择聊天</h2>
|
||||||
|
<span className="export-count-badge">共 {contacts.length.toLocaleString()} 个</span>
|
||||||
|
</div>
|
||||||
|
<label className="export-search-field">
|
||||||
|
<span aria-hidden>⌕</span>
|
||||||
|
<input
|
||||||
|
value={contactFilter}
|
||||||
|
onChange={(event) => onContactFilterChange(event.target.value)}
|
||||||
|
placeholder="搜索群聊、联系人或 wxid"
|
||||||
|
aria-label="搜索聊天"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="export-filter-tabs" role="tablist" aria-label="聊天类型">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['all', '全部'],
|
||||||
|
['group', '群聊'],
|
||||||
|
['user', '联系人']
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={contactType === value ? 'active' : ''}
|
||||||
|
onClick={() => onContactTypeChange(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-contact-list">
|
||||||
|
{filteredContacts.map((contact) => {
|
||||||
|
const name = displayName(contact)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={contact.md5}
|
||||||
|
type="button"
|
||||||
|
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelectContact(contact)}
|
||||||
|
>
|
||||||
|
<span className="export-contact-avatar">
|
||||||
|
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<span className="export-contact-copy">
|
||||||
|
<strong>{name}</strong>
|
||||||
|
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="export-account-summary" onClick={onOpenSettings}>
|
||||||
|
<span className="export-account-avatar">
|
||||||
|
{selfInfo?.avatar ? (
|
||||||
|
<img src={selfInfo.avatar} alt="" />
|
||||||
|
) : (
|
||||||
|
(selfInfo?.nickname || '我').slice(0, 1)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{selfInfo?.nickname || '当前账号'}</strong>
|
||||||
|
<small className={dbReady ? 'ready' : ''}>
|
||||||
|
{dbReady ? '数据库已连接' : '数据库未连接'}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type { Message } from './exportTypes'
|
||||||
|
import type { ExportJobProgress, ExportStatus, SelfInfo } from './exportTypes'
|
||||||
|
import { formatPreviewTime } from './exportUtils'
|
||||||
|
|
||||||
|
interface ExportPreviewPanelProps {
|
||||||
|
status: ExportStatus
|
||||||
|
previewItems: Message[]
|
||||||
|
previewMediaCount: number
|
||||||
|
previewBytes: number
|
||||||
|
selfInfo: SelfInfo | null
|
||||||
|
progress: ExportJobProgress | null
|
||||||
|
jobId: string
|
||||||
|
onCancel: (jobId: string) => void
|
||||||
|
onReveal: (path: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportPreviewPanel({
|
||||||
|
status,
|
||||||
|
previewItems,
|
||||||
|
previewMediaCount,
|
||||||
|
previewBytes,
|
||||||
|
selfInfo,
|
||||||
|
progress,
|
||||||
|
jobId,
|
||||||
|
onCancel,
|
||||||
|
onReveal
|
||||||
|
}: ExportPreviewPanelProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
|
||||||
|
{status === 'idle' && (
|
||||||
|
<>
|
||||||
|
<div className="export-preview-heading">
|
||||||
|
<strong>导出预览</strong>
|
||||||
|
<span>仅预览最近 20 条</span>
|
||||||
|
</div>
|
||||||
|
<div className="export-message-preview">
|
||||||
|
<div className="export-preview-date">最近消息</div>
|
||||||
|
{(previewItems.length
|
||||||
|
? previewItems
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 'empty',
|
||||||
|
from: 'user',
|
||||||
|
content: '导出预览将在这里显示',
|
||||||
|
type: '文字',
|
||||||
|
datetime: '',
|
||||||
|
isSender: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
).map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
|
||||||
|
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="export-preview-avatar">
|
||||||
|
{message.img || (message.isSender && selfInfo?.avatar) ? (
|
||||||
|
<img src={message.img || selfInfo?.avatar} alt="" />
|
||||||
|
) : (
|
||||||
|
(message.isSender ? '我' : message.name || '友').slice(0, 1)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="export-preview-bubble">
|
||||||
|
<small>
|
||||||
|
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
|
||||||
|
{formatPreviewTime(message)}
|
||||||
|
</small>
|
||||||
|
{message.content || `[${message.type}]`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="export-preview-stats export-preview-real-stats">
|
||||||
|
<span>
|
||||||
|
预览消息<strong>{previewItems.length}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
媒体预览<strong>{previewMediaCount}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
预估文本大小
|
||||||
|
<strong>
|
||||||
|
{previewBytes < 1024
|
||||||
|
? `${previewBytes} B`
|
||||||
|
: `${(previewBytes / 1024).toFixed(1)} KB`}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="export-preview-stats">
|
||||||
|
<span>
|
||||||
|
消息总数<strong>待统计</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
媒体文件<strong>待统计</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
预计大小<strong>待统计</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'running' && (
|
||||||
|
<div className="export-job-state">
|
||||||
|
<h2>正在导出</h2>
|
||||||
|
<p>导出任务在后台运行,不影响档案浏览。</p>
|
||||||
|
<ol>
|
||||||
|
<li className="done">准备导出</li>
|
||||||
|
<li className="current">
|
||||||
|
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
|
||||||
|
</li>
|
||||||
|
<li>解析消息内容</li>
|
||||||
|
<li>处理媒体资源</li>
|
||||||
|
<li>生成档案</li>
|
||||||
|
</ol>
|
||||||
|
<div className="export-progress-bar" aria-label="导出进度">
|
||||||
|
<span style={{ width: `${progress?.percent ?? 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<strong>
|
||||||
|
{progress?.phase === 'writing'
|
||||||
|
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
|
||||||
|
: `正在读取消息... ${progress?.percent ?? 0}%`}
|
||||||
|
</strong>
|
||||||
|
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
|
||||||
|
取消导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'completed' && (
|
||||||
|
<div className="export-job-state completed">
|
||||||
|
<div className="export-success-icon">✓</div>
|
||||||
|
<h2>导出完成</h2>
|
||||||
|
<p>聊天档案已成功保存。</p>
|
||||||
|
<div className="export-complete-summary">
|
||||||
|
<span>
|
||||||
|
导出消息<strong>{progress?.processed.toLocaleString() || '已完成'}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
媒体资源<strong>按设置处理</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
输出位置<strong>已保存</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-primary-button"
|
||||||
|
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
|
||||||
|
>
|
||||||
|
打开档案
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-open-folder-button"
|
||||||
|
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
|
||||||
|
>
|
||||||
|
在文件夹中显示
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type { ExportTaskRecord } from './exportTypes'
|
||||||
|
|
||||||
|
interface ExportTaskCenterProps {
|
||||||
|
open: boolean
|
||||||
|
taskCount: number
|
||||||
|
tasks: ExportTaskRecord[]
|
||||||
|
onToggle: () => void
|
||||||
|
onCancel: (jobId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportTaskCenter({
|
||||||
|
open,
|
||||||
|
taskCount,
|
||||||
|
tasks,
|
||||||
|
onToggle,
|
||||||
|
onCancel
|
||||||
|
}: ExportTaskCenterProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className="export-task-center-button" onClick={onToggle}>
|
||||||
|
任务中心{taskCount > 0 ? ` (${taskCount})` : ''}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<section className="export-task-center">
|
||||||
|
<div className="export-section-heading">
|
||||||
|
<h3>导出任务</h3>
|
||||||
|
<span>{tasks.length} 条记录</span>
|
||||||
|
</div>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<p>暂无导出记录</p>
|
||||||
|
) : (
|
||||||
|
tasks.map((task) => (
|
||||||
|
<div className="export-task-row" key={task.jobId}>
|
||||||
|
<span>
|
||||||
|
<strong>{task.contactName}</strong>
|
||||||
|
<small>
|
||||||
|
{task.format.toUpperCase()} · {task.progress.phase}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<span className="export-task-progress">
|
||||||
|
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
|
||||||
|
<b>{task.progress.percent ?? 0}%</b>
|
||||||
|
</span>
|
||||||
|
{task.status === 'running' && (
|
||||||
|
<button type="button" onClick={() => onCancel(task.jobId)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
import React, { useMemo, useState } from 'react'
|
||||||
|
import type { Message } from '../../../../shared/types'
|
||||||
|
import type {
|
||||||
|
ExportJobProgress,
|
||||||
|
ExportMessageKind,
|
||||||
|
ExportNameMode
|
||||||
|
} from '../../../../shared/export'
|
||||||
|
import { ExportContactPanel } from './ExportContactPanel'
|
||||||
|
import { ExportPreviewPanel } from './ExportPreviewPanel'
|
||||||
|
import { ExportTaskCenter } from './ExportTaskCenter'
|
||||||
|
import type {
|
||||||
|
ExportFormat,
|
||||||
|
ExportRange,
|
||||||
|
ExportStatus,
|
||||||
|
ExportWorkspaceProps,
|
||||||
|
GroupMemberName
|
||||||
|
} from './exportTypes'
|
||||||
|
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
|
||||||
|
|
||||||
|
export function ExportWorkspace({
|
||||||
|
contacts,
|
||||||
|
selectedContact,
|
||||||
|
previewMessages,
|
||||||
|
selfInfo,
|
||||||
|
dbReady,
|
||||||
|
onSelectContact,
|
||||||
|
onOpenSettings,
|
||||||
|
exportTasks,
|
||||||
|
onStartExport,
|
||||||
|
onCancelExport
|
||||||
|
}: ExportWorkspaceProps): React.ReactElement {
|
||||||
|
const [contactFilter, setContactFilter] = useState('')
|
||||||
|
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
||||||
|
const [range, setRange] = useState<ExportRange>('today')
|
||||||
|
const [startDate, setStartDate] = useState('')
|
||||||
|
const [endDate, setEndDate] = useState('')
|
||||||
|
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
|
||||||
|
const [nameMode, setNameMode] = useState<ExportNameMode>('remark')
|
||||||
|
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([])
|
||||||
|
const [includeMedia, setIncludeMedia] = useState(true)
|
||||||
|
const [includeAvatars, setIncludeAvatars] = useState(true)
|
||||||
|
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||||
|
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
||||||
|
const [keepMissing, setKeepMissing] = useState(false)
|
||||||
|
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||||
|
const [zip, setZip] = useState(false)
|
||||||
|
const [fileName, setFileName] = useState('')
|
||||||
|
const [status, setStatus] = useState<ExportStatus>('idle')
|
||||||
|
const [jobId, setJobId] = useState('')
|
||||||
|
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
|
||||||
|
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
|
||||||
|
|
||||||
|
const filteredContacts = useMemo(() => {
|
||||||
|
const keyword = contactFilter.trim().toLowerCase()
|
||||||
|
return contacts.filter((contact) => {
|
||||||
|
if (contactType !== 'all' && contact.type !== contactType) return false
|
||||||
|
if (!keyword) return true
|
||||||
|
return [contact.m_nsNickName, contact.m_nsUsrName].some((value) =>
|
||||||
|
value.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [contactFilter, contactType, contacts])
|
||||||
|
|
||||||
|
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
|
||||||
|
const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5)
|
||||||
|
const taskCount = exportTasks.filter((task) => task.status === 'running').length
|
||||||
|
const activeName = displayName(activeContact)
|
||||||
|
const preview = previewMessages.slice(-20)
|
||||||
|
const previewMediaCount = preview.filter((message) =>
|
||||||
|
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '')
|
||||||
|
).length
|
||||||
|
const previewBytes = preview.reduce(
|
||||||
|
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const outputName = fileName.trim() || `${activeName}_聊天档案`
|
||||||
|
const nameOptions: { value: ExportNameMode; label: string }[] =
|
||||||
|
activeContact?.type === 'group'
|
||||||
|
? [
|
||||||
|
{ value: 'groupNickname', label: '群昵称' },
|
||||||
|
{ value: 'remark', label: '备注' },
|
||||||
|
{ value: 'wechatNickname', label: '微信名' }
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: 'remark', label: '备注' },
|
||||||
|
{ value: 'wechatNickname', label: '微信名' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const nameMap = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
if (activeContact?.type === 'group') {
|
||||||
|
for (const member of groupMembers) {
|
||||||
|
const value =
|
||||||
|
nameMode === 'groupNickname'
|
||||||
|
? member.groupNickname || member.nickname || member.wxid
|
||||||
|
: nameMode === 'remark'
|
||||||
|
? member.remark || member.wechatNickname || member.wxid
|
||||||
|
: member.wechatNickname || member.wxid
|
||||||
|
map[member.wxid] = value
|
||||||
|
}
|
||||||
|
} else if (activeContact) {
|
||||||
|
map[activeContact.m_nsUsrName] =
|
||||||
|
nameMode === 'remark'
|
||||||
|
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
|
||||||
|
: activeContact.wechatNickname || activeContact.m_nsUsrName
|
||||||
|
}
|
||||||
|
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
|
||||||
|
return map
|
||||||
|
}, [activeContact, groupMembers, nameMode, selfInfo])
|
||||||
|
|
||||||
|
const avatarUrls = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
if (activeContact?.m_nsUsrName && activeContact.avatar) {
|
||||||
|
map[activeContact.m_nsUsrName] = activeContact.avatar
|
||||||
|
}
|
||||||
|
for (const member of groupMembers) {
|
||||||
|
if (member.avatar) map[member.wxid] = member.avatar
|
||||||
|
}
|
||||||
|
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
|
||||||
|
return map
|
||||||
|
}, [activeContact, groupMembers, selfInfo])
|
||||||
|
|
||||||
|
const previewName = (message: Message): string =>
|
||||||
|
(message.senderId && nameMap[message.senderId]) ||
|
||||||
|
message.name ||
|
||||||
|
(message.isSender ? selfInfo?.nickname : undefined) ||
|
||||||
|
(message.isSender ? '我' : '联系人')
|
||||||
|
const previewAvatar = (message: Message): string | undefined =>
|
||||||
|
(message.senderId && avatarUrls[message.senderId]) ||
|
||||||
|
message.img ||
|
||||||
|
(message.isSender ? selfInfo?.avatar : undefined)
|
||||||
|
const previewItems = preview.map((message) => ({
|
||||||
|
...message,
|
||||||
|
name: previewName(message),
|
||||||
|
img: previewAvatar(message)
|
||||||
|
}))
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
|
||||||
|
setGroupMembers([])
|
||||||
|
if (!activeContact || activeContact.type !== 'group') return
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
|
||||||
|
setGroupMembers((snapshot?.members || []) as GroupMemberName[])
|
||||||
|
})
|
||||||
|
}, 300)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [activeContact])
|
||||||
|
|
||||||
|
const toggleKind = (value: string): void => {
|
||||||
|
setSelectedKinds((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (next.has(value)) next.delete(value)
|
||||||
|
else next.add(value)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStart = async (): Promise<void> => {
|
||||||
|
if (!activeContact || status === 'running') return
|
||||||
|
const nextJobId = `export-${Date.now()}`
|
||||||
|
setJobId(nextJobId)
|
||||||
|
setProgress(null)
|
||||||
|
setStatus('running')
|
||||||
|
let exportNameMap = nameMap
|
||||||
|
let exportAvatarUrls = avatarUrls
|
||||||
|
if (activeContact.type === 'group') {
|
||||||
|
const snapshot = await window.api.getGroupSnapshot(activeContact.md5)
|
||||||
|
const members = (snapshot?.members || []) as GroupMemberName[]
|
||||||
|
setGroupMembers(members)
|
||||||
|
exportNameMap = { ...nameMap }
|
||||||
|
exportAvatarUrls = { ...avatarUrls }
|
||||||
|
for (const member of members) {
|
||||||
|
exportNameMap[member.wxid] =
|
||||||
|
nameMode === 'groupNickname'
|
||||||
|
? member.groupNickname || member.nickname || member.wxid
|
||||||
|
: nameMode === 'remark'
|
||||||
|
? member.remark || member.wechatNickname || member.wxid
|
||||||
|
: member.wechatNickname || member.wxid
|
||||||
|
if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||||
|
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
|
||||||
|
const startOfRange = days
|
||||||
|
? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1)
|
||||||
|
: null
|
||||||
|
const request = {
|
||||||
|
jobId: nextJobId,
|
||||||
|
userMd5: activeContact.md5,
|
||||||
|
name: activeName,
|
||||||
|
format,
|
||||||
|
outputName,
|
||||||
|
startTime: startOfRange
|
||||||
|
? Math.floor(startOfRange.getTime() / 1000)
|
||||||
|
: range === 'custom' && startDate
|
||||||
|
? Math.floor(new Date(startDate).getTime() / 1000)
|
||||||
|
: undefined,
|
||||||
|
endTime: startOfRange
|
||||||
|
? Math.floor(endOfToday.getTime() / 1000)
|
||||||
|
: range === 'custom' && endDate
|
||||||
|
? Math.floor(new Date(endDate).getTime() / 1000)
|
||||||
|
: undefined,
|
||||||
|
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
||||||
|
includeMedia,
|
||||||
|
includeAvatars,
|
||||||
|
avatarUrls: exportAvatarUrls,
|
||||||
|
nameMode,
|
||||||
|
nameMap: exportNameMap,
|
||||||
|
zip
|
||||||
|
}
|
||||||
|
const result = await onStartExport(request)
|
||||||
|
if (result.success) {
|
||||||
|
setProgress((current) => ({
|
||||||
|
...(current || { jobId: nextJobId, processed: result.messageCount || 0 }),
|
||||||
|
phase: 'completed',
|
||||||
|
processed: result.messageCount ?? current?.processed ?? 0,
|
||||||
|
total: result.messageCount ?? current?.total,
|
||||||
|
percent: 100,
|
||||||
|
outputPath: result.outputPath
|
||||||
|
}))
|
||||||
|
setStatus('completed')
|
||||||
|
} else if (result.error !== '已取消') {
|
||||||
|
setStatus('idle')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() =>
|
||||||
|
window.api.onExportProgress((next) => {
|
||||||
|
if (next.jobId !== jobId) return
|
||||||
|
setProgress(next)
|
||||||
|
if (next.phase === 'completed') setStatus('completed')
|
||||||
|
if (next.phase === 'cancelled' || next.phase === 'failed') setStatus('idle')
|
||||||
|
}),
|
||||||
|
[jobId]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentTask) return
|
||||||
|
setJobId(currentTask.jobId)
|
||||||
|
setProgress(currentTask.progress)
|
||||||
|
setStatus(
|
||||||
|
currentTask.status === 'running'
|
||||||
|
? 'running'
|
||||||
|
: currentTask.status === 'completed'
|
||||||
|
? 'completed'
|
||||||
|
: 'idle'
|
||||||
|
)
|
||||||
|
}, [currentTask])
|
||||||
|
|
||||||
|
const targetPath =
|
||||||
|
format === 'html'
|
||||||
|
? zip
|
||||||
|
? `文稿/WechatExplorer/导出/${outputName}.zip`
|
||||||
|
: `文稿/WechatExplorer/导出/${outputName}/`
|
||||||
|
: `文稿/WechatExplorer/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="export-workspace">
|
||||||
|
<ExportContactPanel
|
||||||
|
contacts={contacts}
|
||||||
|
filteredContacts={filteredContacts}
|
||||||
|
activeContact={activeContact}
|
||||||
|
selfInfo={selfInfo}
|
||||||
|
dbReady={dbReady}
|
||||||
|
contactFilter={contactFilter}
|
||||||
|
contactType={contactType}
|
||||||
|
onContactFilterChange={setContactFilter}
|
||||||
|
onContactTypeChange={setContactType}
|
||||||
|
onSelectContact={onSelectContact}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="export-config-panel">
|
||||||
|
<div className="export-config-scroll">
|
||||||
|
<ExportTaskCenter
|
||||||
|
open={taskCenterOpen}
|
||||||
|
taskCount={taskCount}
|
||||||
|
tasks={exportTasks}
|
||||||
|
onToggle={() => setTaskCenterOpen((open) => !open)}
|
||||||
|
onCancel={(taskJobId) => void onCancelExport(taskJobId)}
|
||||||
|
/>
|
||||||
|
<header className="export-config-header">
|
||||||
|
<span className="export-chat-avatar">
|
||||||
|
{activeContact?.avatar ? (
|
||||||
|
<img src={activeContact.avatar} alt="" />
|
||||||
|
) : (
|
||||||
|
activeName.slice(0, 1)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<h1>导出设置</h1>
|
||||||
|
<p>
|
||||||
|
{activeName}
|
||||||
|
{activeContact?.type === 'group' ? ' · 群聊' : ''}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="export-section export-format-top">
|
||||||
|
<h3>导出格式</h3>
|
||||||
|
<div className="export-format-grid">
|
||||||
|
{formatOrder.map((value) => (
|
||||||
|
<button key={value} type="button" className={format === value ? 'active' : ''} onClick={() => setFormat(value)}>
|
||||||
|
<strong>{formatLabels[value].label}</strong>
|
||||||
|
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="export-helper-text">CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。</p>
|
||||||
|
{format === 'html' && (
|
||||||
|
<div className="export-html-options">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="html-package-top" checked={!zip} onChange={() => setZip(false)} /> HTML 资源包
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="html-package-top" checked={zip} onChange={() => setZip(true)} /> HTML 资源包并压缩为 ZIP
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section">
|
||||||
|
<div className="export-section-heading">
|
||||||
|
<h3>时间范围</h3>
|
||||||
|
<span>{status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="export-range-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={range === 'today' ? 'active' : ''}
|
||||||
|
onClick={() => setRange('today')}
|
||||||
|
>
|
||||||
|
今天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={range === 'threeDays' ? 'active' : ''}
|
||||||
|
onClick={() => setRange('threeDays')}
|
||||||
|
>
|
||||||
|
最近 3 天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={range === 'sevenDays' ? 'active' : ''}
|
||||||
|
onClick={() => setRange('sevenDays')}
|
||||||
|
>
|
||||||
|
最近 7 天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={range === 'custom' ? 'active' : ''}
|
||||||
|
onClick={() => setRange('custom')}
|
||||||
|
>
|
||||||
|
自定义时间
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{range === 'custom' && (
|
||||||
|
<div className="export-date-fields">
|
||||||
|
<label>
|
||||||
|
开始时间
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(event) => setStartDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
结束时间
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(event) => setEndDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section">
|
||||||
|
<h3>消息内容</h3>
|
||||||
|
<div className="export-kind-grid">
|
||||||
|
{messageKinds.map(([value, label]) => (
|
||||||
|
<label key={value} className={`export-check-row ${value === 'video' ? 'unsupported' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value !== 'video' && selectedKinds.has(value)}
|
||||||
|
disabled={value === 'video'}
|
||||||
|
onChange={() => toggleKind(value)}
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
{value === 'video' && (
|
||||||
|
<span className="export-unsupported-hint" title="当前版本暂不支持视频导出" aria-label="当前版本暂不支持视频导出">
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section">
|
||||||
|
<h3>消息显示名称</h3>
|
||||||
|
<div className="export-name-mode-grid" role="radiogroup" aria-label="消息显示名称">
|
||||||
|
{nameOptions.map((option) => (
|
||||||
|
<label key={option.value} className="export-name-mode-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="export-name-mode"
|
||||||
|
checked={nameMode === option.value}
|
||||||
|
onChange={() => setNameMode(option.value)}
|
||||||
|
/>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section">
|
||||||
|
<h3>资源处理</h3>
|
||||||
|
<label className="export-media-master">
|
||||||
|
<span>包含图片、视频、语音及动态表情</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeMedia}
|
||||||
|
disabled={format !== 'html'}
|
||||||
|
onChange={(event) => setIncludeMedia(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}>
|
||||||
|
<label className="export-check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={preferOriginal}
|
||||||
|
disabled={!includeMedia || format !== 'html'}
|
||||||
|
onChange={(event) => setPreferOriginal(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>优先导出原图</span>
|
||||||
|
</label>
|
||||||
|
<label className="export-check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={fallbackThumbnail}
|
||||||
|
disabled={!includeMedia || format !== 'html'}
|
||||||
|
onChange={(event) => setFallbackThumbnail(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>原图缺失时使用缩略图</span>
|
||||||
|
</label>
|
||||||
|
<label className="export-check-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={keepMissing}
|
||||||
|
disabled={!includeMedia || format !== 'html'}
|
||||||
|
onChange={(event) => setKeepMissing(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>媒体缺失时保留占位说明</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="export-helper-text">资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。</p>
|
||||||
|
<div className="export-resource-statuses">
|
||||||
|
<span>图片解密:已就绪</span>
|
||||||
|
<span>视频资源:可用</span>
|
||||||
|
<span>语音资源:可用</span>
|
||||||
|
<span>表情资源:按需解析</span>
|
||||||
|
</div>
|
||||||
|
<p className="export-helper-text">媒体资源会延长导出时间,缺失资源不会中断任务。</p>
|
||||||
|
<label className="export-media-master">
|
||||||
|
<span>在聊天气泡旁显示头像</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeAvatars}
|
||||||
|
onChange={(event) => setIncludeAvatars(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section">
|
||||||
|
<h3>导出格式</h3>
|
||||||
|
<div className="export-format-grid">
|
||||||
|
{formatOrder.map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={format === value ? 'active' : ''}
|
||||||
|
onClick={() => setFormat(value)}
|
||||||
|
>
|
||||||
|
<strong>{formatLabels[value].label}</strong>
|
||||||
|
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{format === 'html' && (
|
||||||
|
<div className="export-html-options">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="html-package"
|
||||||
|
checked={!zip}
|
||||||
|
onChange={() => setZip(false)}
|
||||||
|
/>{' '}
|
||||||
|
HTML 资源包(推荐)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="html-package"
|
||||||
|
checked={zip}
|
||||||
|
onChange={() => setZip(true)}
|
||||||
|
/>{' '}
|
||||||
|
HTML 资源包并压缩为 ZIP
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-section export-save-section">
|
||||||
|
<h3>保存设置</h3>
|
||||||
|
<label>
|
||||||
|
文件名称
|
||||||
|
<input
|
||||||
|
value={fileName}
|
||||||
|
onChange={(event) => setFileName(event.target.value)}
|
||||||
|
placeholder={`${activeName}_聊天档案`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="export-target-path">
|
||||||
|
<span>保存位置</span>
|
||||||
|
<strong>{targetPath}</strong>
|
||||||
|
<button type="button">选择位置</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<footer className="export-action-bar">
|
||||||
|
<span className={`export-ready-dot ${status === 'completed' ? 'completed' : ''}`} />
|
||||||
|
<span>
|
||||||
|
{status === 'running'
|
||||||
|
? '正在后台导出'
|
||||||
|
: status === 'completed'
|
||||||
|
? '导出完成'
|
||||||
|
: '准备就绪'}
|
||||||
|
</span>
|
||||||
|
<span className="export-target-summary">路径:{targetPath}</span>
|
||||||
|
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}>
|
||||||
|
恢复默认
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-primary-button"
|
||||||
|
disabled={!activeContact || status === 'running'}
|
||||||
|
onClick={handleStart}
|
||||||
|
>
|
||||||
|
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ExportPreviewPanel
|
||||||
|
status={status}
|
||||||
|
previewItems={previewItems}
|
||||||
|
previewMediaCount={previewMediaCount}
|
||||||
|
previewBytes={previewBytes}
|
||||||
|
selfInfo={selfInfo}
|
||||||
|
progress={progress}
|
||||||
|
jobId={jobId}
|
||||||
|
onCancel={(exportJobId) => {
|
||||||
|
void window.api.cancelExport(exportJobId)
|
||||||
|
setStatus('idle')
|
||||||
|
}}
|
||||||
|
onReveal={(path) => void window.api.revealExport(path)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Contact, Message } from '../../../../shared/types'
|
||||||
|
import type {
|
||||||
|
ExportJobProgress,
|
||||||
|
ExportMessageKind,
|
||||||
|
ExportNameMode,
|
||||||
|
ExportRequest,
|
||||||
|
ExportResult,
|
||||||
|
ExportTaskRecord
|
||||||
|
} from '../../../../shared/export'
|
||||||
|
|
||||||
|
export type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
|
||||||
|
export type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
|
||||||
|
export type ExportStatus = 'idle' | 'running' | 'completed'
|
||||||
|
|
||||||
|
export interface GroupMemberName {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
groupNickname: string
|
||||||
|
wechatNickname: string
|
||||||
|
remark: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfInfo {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
avatar?: string
|
||||||
|
accountRoot: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportWorkspaceProps {
|
||||||
|
contacts: Contact[]
|
||||||
|
selectedContact: Contact | null
|
||||||
|
previewMessages: Message[]
|
||||||
|
selfInfo: SelfInfo | null
|
||||||
|
dbReady: boolean
|
||||||
|
onSelectContact: (contact: Contact) => void
|
||||||
|
onOpenSettings: () => void
|
||||||
|
exportTasks: ExportTaskRecord[]
|
||||||
|
onStartExport: (request: ExportRequest) => Promise<ExportResult>
|
||||||
|
onCancelExport: (jobId: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Contact, ExportJobProgress, ExportMessageKind, Message, ExportNameMode, ExportTaskRecord }
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Contact, Message } from '../../../../shared/types'
|
||||||
|
import type { ExportFormat, GroupMemberName } from './exportTypes'
|
||||||
|
|
||||||
|
export const messageKinds = [
|
||||||
|
['text', '文字'],
|
||||||
|
['image', '图片'],
|
||||||
|
['video', '视频'],
|
||||||
|
['voice', '语音'],
|
||||||
|
['sticker', '表情包'],
|
||||||
|
['share', '链接与分享'],
|
||||||
|
['location', '位置'],
|
||||||
|
['system', '系统消息']
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const formatLabels: Record<ExportFormat, { label: string; hint?: string }> = {
|
||||||
|
html: { label: 'HTML', hint: '推荐' },
|
||||||
|
csv: { label: 'CSV' },
|
||||||
|
json: { label: 'JSON' },
|
||||||
|
markdown: { label: 'Markdown' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
|
||||||
|
|
||||||
|
export function displayName(contact: Contact | null): string {
|
||||||
|
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPreviewTime(message: Message): string {
|
||||||
|
if (!message.createTime) return message.datetime || ''
|
||||||
|
return new Date(message.createTime * 1000).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNameMap(
|
||||||
|
activeContact: Contact | null,
|
||||||
|
groupMembers: GroupMemberName[],
|
||||||
|
nameMode: string,
|
||||||
|
selfInfo: { wxid: string; nickname: string } | null
|
||||||
|
): Record<string, string> {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
if (activeContact?.type === 'group') {
|
||||||
|
for (const member of groupMembers) {
|
||||||
|
const value =
|
||||||
|
nameMode === 'groupNickname'
|
||||||
|
? member.groupNickname || member.nickname || member.wxid
|
||||||
|
: nameMode === 'remark'
|
||||||
|
? member.remark || member.wechatNickname || member.wxid
|
||||||
|
: member.wechatNickname || member.wxid
|
||||||
|
map[member.wxid] = value
|
||||||
|
}
|
||||||
|
} else if (activeContact) {
|
||||||
|
map[activeContact.m_nsUsrName] =
|
||||||
|
nameMode === 'remark'
|
||||||
|
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
|
||||||
|
: activeContact.wechatNickname || activeContact.m_nsUsrName
|
||||||
|
}
|
||||||
|
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAvatarUrls(
|
||||||
|
activeContact: Contact | null,
|
||||||
|
groupMembers: GroupMemberName[],
|
||||||
|
selfInfo: { wxid: string; avatar?: string } | null
|
||||||
|
): Record<string, string> {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
if (activeContact?.m_nsUsrName && activeContact.avatar) {
|
||||||
|
map[activeContact.m_nsUsrName] = activeContact.avatar
|
||||||
|
}
|
||||||
|
for (const member of groupMembers) {
|
||||||
|
if (member.avatar) map[member.wxid] = member.avatar
|
||||||
|
}
|
||||||
|
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
|
||||||
|
return map
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
export type LoadedImage = {
|
||||||
|
data: string
|
||||||
|
isThumbnail: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageLoadOptions = {
|
||||||
|
force?: boolean
|
||||||
|
preferThumbnail?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueueItem = {
|
||||||
|
priority: number
|
||||||
|
run: () => Promise<LoadedImage>
|
||||||
|
resolve: (value: LoadedImage) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CONCURRENT_IMAGE_LOADS = 3
|
||||||
|
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||||
|
const imageCache = new Map<string, LoadedImage>()
|
||||||
|
const imageCacheSizes = new Map<string, number>()
|
||||||
|
const imageRequests = new Map<string, Promise<LoadedImage>>()
|
||||||
|
const imageQueue: QueueItem[] = []
|
||||||
|
let activeImageLoads = 0
|
||||||
|
let imageCacheBytes = 0
|
||||||
|
|
||||||
|
function imageIdentityKeys(imageMd5?: string, imageDatName?: string): string[] {
|
||||||
|
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
|
||||||
|
Boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheMode(options: ImageLoadOptions): string {
|
||||||
|
if (options.force) return 'original'
|
||||||
|
if (options.preferThumbnail) return 'thumbnail'
|
||||||
|
return 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKeys(
|
||||||
|
imageMd5: string | undefined,
|
||||||
|
imageDatName: string | undefined,
|
||||||
|
options: ImageLoadOptions
|
||||||
|
): string[] {
|
||||||
|
return imageIdentityKeys(imageMd5, imageDatName).map(
|
||||||
|
(identity) => `${identity}:${cacheMode(options)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedImage(
|
||||||
|
imageMd5: string | undefined,
|
||||||
|
imageDatName: string | undefined,
|
||||||
|
options: ImageLoadOptions
|
||||||
|
): LoadedImage | undefined {
|
||||||
|
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||||
|
for (const key of keys) {
|
||||||
|
const cached = imageCache.get(key)
|
||||||
|
if (!cached) continue
|
||||||
|
imageCache.delete(key)
|
||||||
|
imageCache.set(key, cached)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.force && options.preferThumbnail) {
|
||||||
|
for (const identity of imageIdentityKeys(imageMd5, imageDatName)) {
|
||||||
|
const fallbackKey = `${identity}:auto`
|
||||||
|
const cached = imageCache.get(fallbackKey)
|
||||||
|
if (cached) return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheImage(
|
||||||
|
imageMd5: string | undefined,
|
||||||
|
imageDatName: string | undefined,
|
||||||
|
options: ImageLoadOptions,
|
||||||
|
image: LoadedImage
|
||||||
|
): void {
|
||||||
|
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||||
|
const size = image.data.length * 2
|
||||||
|
for (const key of keys) {
|
||||||
|
const previousSize = imageCacheSizes.get(key) || 0
|
||||||
|
imageCacheBytes -= previousSize
|
||||||
|
imageCache.delete(key)
|
||||||
|
imageCacheSizes.delete(key)
|
||||||
|
imageCache.set(key, image)
|
||||||
|
imageCacheSizes.set(key, size)
|
||||||
|
imageCacheBytes += size
|
||||||
|
}
|
||||||
|
|
||||||
|
while (imageCacheBytes > MAX_IMAGE_CACHE_BYTES && imageCache.size > 1) {
|
||||||
|
const oldestKey = imageCache.keys().next().value
|
||||||
|
if (!oldestKey) break
|
||||||
|
imageCache.delete(oldestKey)
|
||||||
|
imageCacheBytes -= imageCacheSizes.get(oldestKey) || 0
|
||||||
|
imageCacheSizes.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pumpImageQueue(): void {
|
||||||
|
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
|
||||||
|
imageQueue.sort((left, right) => left.priority - right.priority)
|
||||||
|
const item = imageQueue.shift()
|
||||||
|
if (!item) return
|
||||||
|
activeImageLoads += 1
|
||||||
|
void item
|
||||||
|
.run()
|
||||||
|
.then(item.resolve, item.reject)
|
||||||
|
.finally(() => {
|
||||||
|
activeImageLoads -= 1
|
||||||
|
pumpImageQueue()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedLoadedImage(
|
||||||
|
imageMd5?: string,
|
||||||
|
imageDatName?: string,
|
||||||
|
options: ImageLoadOptions = {}
|
||||||
|
): LoadedImage | undefined {
|
||||||
|
return getCachedImage(imageMd5, imageDatName, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestImage(
|
||||||
|
imageMd5: string | undefined,
|
||||||
|
imageDatName: string | undefined,
|
||||||
|
sessionId: string | undefined,
|
||||||
|
options: ImageLoadOptions = {},
|
||||||
|
priority = 0
|
||||||
|
): Promise<LoadedImage> {
|
||||||
|
const cached = getCachedImage(imageMd5, imageDatName, options)
|
||||||
|
if (cached) return Promise.resolve(cached)
|
||||||
|
|
||||||
|
const identity = imageIdentityKeys(imageMd5, imageDatName)[0]
|
||||||
|
if (!identity) return Promise.reject(new Error('缺少图片标识'))
|
||||||
|
const requestKey = `${identity}:${cacheMode(options)}`
|
||||||
|
const existingRequest = imageRequests.get(requestKey)
|
||||||
|
if (existingRequest) return existingRequest
|
||||||
|
|
||||||
|
const request = new Promise<LoadedImage>((resolve, reject) => {
|
||||||
|
imageQueue.push({
|
||||||
|
priority,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
run: async () => {
|
||||||
|
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options)
|
||||||
|
if (!result.success || !result.data?.startsWith('data:image/')) {
|
||||||
|
throw new Error(result.error || '加载图片失败')
|
||||||
|
}
|
||||||
|
const loadedImage = {
|
||||||
|
data: result.data,
|
||||||
|
isThumbnail: Boolean(result.isThumb)
|
||||||
|
}
|
||||||
|
cacheImage(imageMd5, imageDatName, options, loadedImage)
|
||||||
|
return loadedImage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pumpImageQueue()
|
||||||
|
})
|
||||||
|
imageRequests.set(requestKey, request)
|
||||||
|
void request.then(
|
||||||
|
() => imageRequests.delete(requestKey),
|
||||||
|
() => imageRequests.delete(requestKey)
|
||||||
|
)
|
||||||
|
return request
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ interface AppShellProps {
|
|||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
onPageChange: (page: AppPage) => void
|
onPageChange: (page: AppPage) => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
|
appearanceTheme?: 'system' | 'light' | 'dark'
|
||||||
|
compactMode?: boolean
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,12 +36,14 @@ export function AppShell({
|
|||||||
dbReady,
|
dbReady,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
appearanceTheme = 'system',
|
||||||
|
compactMode = false,
|
||||||
children
|
children
|
||||||
}: AppShellProps): React.ReactElement {
|
}: AppShellProps): React.ReactElement {
|
||||||
const activeItem = PRIMARY_NAV_ITEMS.find((item) => item.id === activePage)
|
const activeItem = PRIMARY_NAV_ITEMS.find((item) => item.id === activePage)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className={`app-shell theme-${appearanceTheme} ${compactMode ? 'is-compact' : ''}`}>
|
||||||
<aside className="app-primary-rail">
|
<aside className="app-primary-rail">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
<PrimaryNavigation activePage={activePage} onPageChange={onPageChange} />
|
<PrimaryNavigation activePage={activePage} onPageChange={onPageChange} />
|
||||||
|
|||||||
@@ -0,0 +1,891 @@
|
|||||||
|
import React, { useMemo, useRef, useState } from 'react'
|
||||||
|
import type { Contact, Message } from '../../../../shared/types'
|
||||||
|
import { SearchIcon } from '../chat/icons'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AISearchCacheRecord,
|
||||||
|
AISearchWorkspaceProps,
|
||||||
|
EvidenceItem,
|
||||||
|
GroupMemberName,
|
||||||
|
SearchPassSummary,
|
||||||
|
SearchRange,
|
||||||
|
SearchScope,
|
||||||
|
SearchStage,
|
||||||
|
SenderDirectory
|
||||||
|
} from './searchTypes'
|
||||||
|
import {
|
||||||
|
RANGE_LABELS,
|
||||||
|
SEARCH_CACHE_KEY,
|
||||||
|
SEARCH_HISTORY_KEY,
|
||||||
|
buildLocalSearchPlan,
|
||||||
|
buildSearchCacheKey,
|
||||||
|
compactCacheItem,
|
||||||
|
currentTimestamp,
|
||||||
|
evidenceIdentity,
|
||||||
|
formatMemberName,
|
||||||
|
formatMessageDate,
|
||||||
|
formatMessageTime,
|
||||||
|
getRangeStart,
|
||||||
|
includesSearchAlias,
|
||||||
|
messageDateKey,
|
||||||
|
messageIdentity,
|
||||||
|
messageText,
|
||||||
|
mergeSearchPlans,
|
||||||
|
normalizeSearchText,
|
||||||
|
parseSearchCacheKey,
|
||||||
|
parseSearchPlanResponse,
|
||||||
|
readSearchCache,
|
||||||
|
readSearchCacheByQuery,
|
||||||
|
selectEvenly,
|
||||||
|
selectEvidenceByDate,
|
||||||
|
senderName,
|
||||||
|
writeSearchCache
|
||||||
|
} from './searchUtils'
|
||||||
|
import { renderMarkdown } from './searchMarkdown'
|
||||||
|
|
||||||
|
export function AISearchWorkspace({
|
||||||
|
contacts,
|
||||||
|
selectedContact,
|
||||||
|
dbReady,
|
||||||
|
aiModelConfig,
|
||||||
|
onSelectContact,
|
||||||
|
onOpenEvidence,
|
||||||
|
onOpenAISettings,
|
||||||
|
onNotice
|
||||||
|
}: AISearchWorkspaceProps): React.ReactElement {
|
||||||
|
const allContacts = useMemo(() => contacts.filter((contact) => contact.md5), [contacts])
|
||||||
|
const availableContacts = allContacts.slice(0, 80)
|
||||||
|
const [scope, setScope] = useState<SearchScope>('global')
|
||||||
|
const [scopeContactMd5, setScopeContactMd5] = useState(selectedContact?.md5 || '')
|
||||||
|
const [range, setRange] = useState<SearchRange>('7d')
|
||||||
|
const [contactFilter, setContactFilter] = useState('')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [stage, setStage] = useState<SearchStage>('idle')
|
||||||
|
const [answer, setAnswer] = useState('')
|
||||||
|
const [evidence, setEvidence] = useState<EvidenceItem[]>([])
|
||||||
|
const [selectedEvidence, setSelectedEvidence] = useState(0)
|
||||||
|
const [analysisError, setAnalysisError] = useState('')
|
||||||
|
const [messageCount, setMessageCount] = useState(0)
|
||||||
|
const [history, setHistory] = useState<string[]>(() => {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '[]')
|
||||||
|
return Array.isArray(stored)
|
||||||
|
? stored.filter((item): item is string => typeof item === 'string')
|
||||||
|
: []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const [senderNames, setSenderNames] = useState<Record<string, string>>({})
|
||||||
|
const [cachedAt, setCachedAt] = useState(0)
|
||||||
|
const [debugEnabled, setDebugEnabled] = useState(false)
|
||||||
|
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
|
||||||
|
const [debugEntries, setDebugEntries] = useState<string[]>([])
|
||||||
|
const [appLogPath, setAppLogPath] = useState('')
|
||||||
|
const bypassCacheRef = useRef(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void Promise.all([window.api.getSettings(), window.api.getAppLogPath()]).then(
|
||||||
|
([settingsResult, logPath]) => {
|
||||||
|
setDebugEnabled(settingsResult.settings.debugEnabled)
|
||||||
|
setAppLogPath(logPath)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addDebugEntry = (message: string, details: Record<string, unknown> = {}): void => {
|
||||||
|
const entry = `${new Date().toLocaleTimeString('zh-CN')} ${message} ${JSON.stringify(details)}`
|
||||||
|
setDebugEntries((current) => [entry, ...current].slice(0, 80))
|
||||||
|
if (debugEnabled) {
|
||||||
|
void window.api
|
||||||
|
.writeAppLog({ level: 'info', scope: 'ai-search', message, details })
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeContact =
|
||||||
|
availableContacts.find(
|
||||||
|
(contact) => contact.md5 === (scopeContactMd5 || selectedContact?.md5)
|
||||||
|
) || selectedContact
|
||||||
|
const visibleContacts = availableContacts.filter((contact) => {
|
||||||
|
const keyword = contactFilter.trim().toLowerCase()
|
||||||
|
if (!keyword) return true
|
||||||
|
return (
|
||||||
|
contact.m_nsNickName.toLowerCase().includes(keyword) ||
|
||||||
|
contact.m_nsUsrName.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const sourceLabel =
|
||||||
|
scope === 'conversation' ? activeContact?.m_nsNickName || '未选择会话' : '全局搜索'
|
||||||
|
const modelLabel = aiModelConfig.configured
|
||||||
|
? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}`
|
||||||
|
: '尚未配置 AI 模型'
|
||||||
|
|
||||||
|
const buildSourceContacts = (): Contact[] => {
|
||||||
|
if (scope === 'conversation') return activeContact ? [activeContact] : []
|
||||||
|
return allContacts
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSourcePages = async (
|
||||||
|
sourceContacts: Contact[],
|
||||||
|
startTime: number | undefined
|
||||||
|
): Promise<{ contact: Contact; messages: Message[] }[]> => {
|
||||||
|
const pages = new Array<{ contact: Contact; messages: Message[] }>(sourceContacts.length)
|
||||||
|
let nextIndex = 0
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
while (nextIndex < sourceContacts.length) {
|
||||||
|
const index = nextIndex
|
||||||
|
nextIndex += 1
|
||||||
|
const contact = sourceContacts[index]
|
||||||
|
pages[index] = {
|
||||||
|
contact,
|
||||||
|
messages: await window.api.getMessages(contact.md5, startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const workerCount = Math.min(6, sourceContacts.length)
|
||||||
|
await Promise.all(Array.from({ length: workerCount }, () => worker()))
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
const rememberQuery = (value: string): void => {
|
||||||
|
setHistory((current) => {
|
||||||
|
const next = [value, ...current.filter((item) => item !== value)].slice(0, 10)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next))
|
||||||
|
} catch {
|
||||||
|
// History persistence is optional and must not interrupt analysis.
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeHistoryQuery = (historyQuery: string): void => {
|
||||||
|
setHistory((current) => {
|
||||||
|
const next = current.filter((item) => item !== historyQuery)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next))
|
||||||
|
} catch {
|
||||||
|
// History persistence is optional and must not interrupt analysis.
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const records = JSON.parse(
|
||||||
|
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
|
||||||
|
) as AISearchCacheRecord[]
|
||||||
|
const queryKey = historyQuery.trim().toLowerCase()
|
||||||
|
const nextRecords = records.filter((item) => {
|
||||||
|
try {
|
||||||
|
const keyParts = JSON.parse(item.key) as unknown
|
||||||
|
return !(
|
||||||
|
Array.isArray(keyParts) &&
|
||||||
|
typeof keyParts[3] === 'string' &&
|
||||||
|
keyParts[3] === queryKey
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify(nextRecords))
|
||||||
|
} catch {
|
||||||
|
// Cache cleanup is optional and must not interrupt the current workspace.
|
||||||
|
}
|
||||||
|
onNotice('已删除这条最近提问')
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyCachedResult = (cached: AISearchCacheRecord, queryValue = query.trim()): void => {
|
||||||
|
setAnswer(cached.answer)
|
||||||
|
setEvidence(cached.evidence)
|
||||||
|
setSenderNames(cached.senderNames)
|
||||||
|
setMessageCount(cached.messageCount)
|
||||||
|
setCachedAt(cached.createdAt)
|
||||||
|
rememberQuery(queryValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreHistoryQuery = (historyQuery: string): void => {
|
||||||
|
setQuery(historyQuery)
|
||||||
|
setSelectedEvidence(0)
|
||||||
|
const cacheKey = buildSearchCacheKey(
|
||||||
|
scope,
|
||||||
|
scope === 'conversation' ? activeContact?.md5 || '' : '',
|
||||||
|
range,
|
||||||
|
historyQuery
|
||||||
|
)
|
||||||
|
const cached = readSearchCache(cacheKey) || readSearchCacheByQuery(historyQuery)?.record || null
|
||||||
|
if (!cached) {
|
||||||
|
setAnswer('')
|
||||||
|
setEvidence([])
|
||||||
|
setCachedAt(0)
|
||||||
|
setStage('idle')
|
||||||
|
onNotice('这条提问没有可恢复的缓存,请点击开始分析重新读取消息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cachedLocation = parseSearchCacheKey(cached.key)
|
||||||
|
if (cachedLocation) {
|
||||||
|
setScope(cachedLocation.scope)
|
||||||
|
setRange(cachedLocation.range)
|
||||||
|
setScopeContactMd5(cachedLocation.contactMd5)
|
||||||
|
}
|
||||||
|
setAnalysisError('')
|
||||||
|
applyCachedResult(cached, historyQuery)
|
||||||
|
setStage('result')
|
||||||
|
onNotice('已恢复这条提问的检索结果')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSenderDirectory = async (sourceContacts: Contact[]): Promise<SenderDirectory> => {
|
||||||
|
const groupContacts = sourceContacts.filter((contact) => contact.type === 'group')
|
||||||
|
const snapshots = await Promise.all(
|
||||||
|
groupContacts.map(async (contact) => {
|
||||||
|
const snapshot = (await window.api.getGroupSnapshot(contact.md5)) as {
|
||||||
|
members?: GroupMemberName[]
|
||||||
|
} | null
|
||||||
|
return snapshot?.members || []
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const displayNames: Record<string, string> = {}
|
||||||
|
const aliases: Record<string, string[]> = {}
|
||||||
|
snapshots.flat().forEach((member) => {
|
||||||
|
if (!member.wxid) return
|
||||||
|
displayNames[member.wxid] = formatMemberName(member)
|
||||||
|
aliases[member.wxid] = [
|
||||||
|
member.groupNickname,
|
||||||
|
member.nickname,
|
||||||
|
member.remark,
|
||||||
|
member.wechatNickname,
|
||||||
|
member.wxid
|
||||||
|
].filter((name): name is string => Boolean(name?.trim()))
|
||||||
|
})
|
||||||
|
return { displayNames, aliases }
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAnalysis = async (event?: React.FormEvent): Promise<void> => {
|
||||||
|
event?.preventDefault()
|
||||||
|
const normalizedQuery = query.trim()
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
setAnalysisError('先输入一个想了解的问题')
|
||||||
|
setStage('insufficient')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!dbReady) {
|
||||||
|
setAnalysisError('数据库尚未连接,暂时无法读取聊天记录')
|
||||||
|
setStage('insufficient')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!aiModelConfig.configured) {
|
||||||
|
setAnalysisError('尚未配置 AI 模型,请先在设置中添加可用的模型供应商')
|
||||||
|
setStage('insufficient')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceContacts = buildSourceContacts()
|
||||||
|
if (!sourceContacts.length) {
|
||||||
|
setAnalysisError('没有可检索的聊天范围')
|
||||||
|
setStage('insufficient')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStage('loading')
|
||||||
|
setAnalysisError('')
|
||||||
|
setAnswer('')
|
||||||
|
setEvidence([])
|
||||||
|
setSelectedEvidence(0)
|
||||||
|
setCachedAt(0)
|
||||||
|
const cacheKey = buildSearchCacheKey(
|
||||||
|
scope,
|
||||||
|
scope === 'conversation' ? activeContact?.md5 || '' : '',
|
||||||
|
range,
|
||||||
|
normalizedQuery
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const cached = bypassCacheRef.current ? null : readSearchCache(cacheKey)
|
||||||
|
bypassCacheRef.current = false
|
||||||
|
if (cached) {
|
||||||
|
addDebugEntry('检索命中缓存', { scope, range, messageCount: cached.messageCount })
|
||||||
|
applyCachedResult(cached, normalizedQuery)
|
||||||
|
setStage('result')
|
||||||
|
onNotice('已使用最近的检索缓存,可点击刷新数据读取最新消息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const localSearchPlan = buildLocalSearchPlan(normalizedQuery)
|
||||||
|
let searchPlan = localSearchPlan
|
||||||
|
try {
|
||||||
|
const planResult = await window.api.aiChat([
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'你是本地聊天检索规划器,不回答用户问题。请从用户问题中提取用于本地数据库检索的主题词和同义短语,只输出 JSON:{"intent":"general|topic|participants|mixed","keywords":["..."],"variants":["..."]}。删除“全局搜索、我和谁聊过、这个话题”等意图词,不要编造人名或聊天内容。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `用户问题:${normalizedQuery}`
|
||||||
|
}
|
||||||
|
])
|
||||||
|
const aiPlan =
|
||||||
|
planResult.success && planResult.data ? parseSearchPlanResponse(planResult.data) : null
|
||||||
|
searchPlan = mergeSearchPlans(localSearchPlan, aiPlan)
|
||||||
|
addDebugEntry('AI 检索规划完成', {
|
||||||
|
source: searchPlan.source,
|
||||||
|
intent: searchPlan.intent,
|
||||||
|
keywords: searchPlan.keywords,
|
||||||
|
variants: searchPlan.variants,
|
||||||
|
aiPlanSuccess: Boolean(aiPlan)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
addDebugEntry('AI 检索规划失败,使用本地规划', {
|
||||||
|
error: error instanceof Error ? error.message : '未知错误',
|
||||||
|
keywords: localSearchPlan.keywords
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const startTime = getRangeStart(range)
|
||||||
|
const pages = await loadSourcePages(sourceContacts, startTime)
|
||||||
|
const sourceMessages = pages.flatMap((page) =>
|
||||||
|
page.messages.map((message) => ({ contact: page.contact, message }))
|
||||||
|
)
|
||||||
|
const uniqueMessages = Array.from(
|
||||||
|
new Map(
|
||||||
|
sourceMessages.map((item) => [
|
||||||
|
`${item.contact.md5}:${messageIdentity(item.message)}`,
|
||||||
|
item
|
||||||
|
])
|
||||||
|
).values()
|
||||||
|
).sort((left, right) => (left.message.createTime || 0) - (right.message.createTime || 0))
|
||||||
|
if (!uniqueMessages.length) {
|
||||||
|
addDebugEntry('检索没有消息', { contactCount: sourceContacts.length, scope, range })
|
||||||
|
setAnalysisError('当前范围内没有找到可分析的消息,请扩大时间范围或更换会话')
|
||||||
|
setStage('insufficient')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryKeywords = searchPlan.keywords
|
||||||
|
const fuzzySearchKeywords = searchPlan.variants
|
||||||
|
const contactNamesInQuery = sourceContacts.filter((contact) =>
|
||||||
|
[contact.m_nsNickName, contact.m_nsUsrName, contact.remark, contact.wechatNickname]
|
||||||
|
.filter((name): name is string => Boolean(name?.trim()))
|
||||||
|
.some((name) => includesSearchAlias(normalizedQuery, name))
|
||||||
|
)
|
||||||
|
const senderDirectoryContacts =
|
||||||
|
scope === 'conversation' ? sourceContacts : contactNamesInQuery
|
||||||
|
const querySenderDirectory = await loadSenderDirectory(senderDirectoryContacts)
|
||||||
|
const matchedSenderIds = new Set(
|
||||||
|
Object.entries(querySenderDirectory.aliases)
|
||||||
|
.filter(([, aliases]) =>
|
||||||
|
aliases.some((name) => includesSearchAlias(normalizedQuery, name))
|
||||||
|
)
|
||||||
|
.map(([senderId]) => senderId)
|
||||||
|
)
|
||||||
|
const matchedContactIds = new Set(contactNamesInQuery.map((contact) => contact.md5))
|
||||||
|
const senderMatchedMessages = uniqueMessages.filter(({ message }) => {
|
||||||
|
const senderFields = [message.name, message.senderId, message.from].filter(
|
||||||
|
(value): value is string => Boolean(value?.trim())
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
senderFields.some((value) => matchedSenderIds.has(value)) ||
|
||||||
|
senderFields.some((value) => includesSearchAlias(normalizedQuery, value))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const senderMessageIds = new Set(senderMatchedMessages.map(evidenceIdentity))
|
||||||
|
const searchPasses = [
|
||||||
|
{ label: '主题精确匹配', keywords: queryKeywords },
|
||||||
|
{ label: 'AI 变体匹配', keywords: fuzzySearchKeywords }
|
||||||
|
]
|
||||||
|
const keywordMatchedMap = new Map<string, EvidenceItem>()
|
||||||
|
const passSummaries: SearchPassSummary[] = []
|
||||||
|
for (const pass of searchPasses) {
|
||||||
|
const passMatches = uniqueMessages.filter(({ message }) => {
|
||||||
|
const text = normalizeSearchText(messageText(message))
|
||||||
|
return pass.keywords.some((keyword) => text.includes(normalizeSearchText(keyword)))
|
||||||
|
})
|
||||||
|
passMatches.forEach((item) => keywordMatchedMap.set(evidenceIdentity(item), item))
|
||||||
|
passSummaries.push({
|
||||||
|
label: pass.label,
|
||||||
|
keywords: pass.keywords,
|
||||||
|
messageCount: passMatches.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const keywordMatchedMessages = Array.from(keywordMatchedMap.values())
|
||||||
|
const keywordMessageIds = new Set(keywordMatchedMessages.map(evidenceIdentity))
|
||||||
|
const relevantMessages = uniqueMessages.filter(({ contact, message }) => {
|
||||||
|
if (matchedContactIds.has(contact.md5)) return true
|
||||||
|
const itemKey = evidenceIdentity({ contact, message })
|
||||||
|
return senderMessageIds.has(itemKey) || keywordMessageIds.has(itemKey)
|
||||||
|
})
|
||||||
|
const hasSearchConstraint =
|
||||||
|
queryKeywords.length > 0 || fuzzySearchKeywords.length > 0 || matchedContactIds.size > 0
|
||||||
|
if (!relevantMessages.length && hasSearchConstraint) {
|
||||||
|
const attemptedTerms = Array.from(new Set([...queryKeywords, ...fuzzySearchKeywords])).join(
|
||||||
|
'、'
|
||||||
|
)
|
||||||
|
const noResultMessage = `${RANGE_LABELS[range]}内没有找到包含“${attemptedTerms || '主题关键词'}”的聊天消息。已完成精确匹配和智能变体匹配,未回退到全量消息;可以扩大时间范围或换一个更具体的词。`
|
||||||
|
addDebugEntry('检索未找到相关消息', {
|
||||||
|
contactCount: sourceContacts.length,
|
||||||
|
uniqueMessageCount: uniqueMessages.length,
|
||||||
|
passSummaries,
|
||||||
|
fallbackToAllMessages: false
|
||||||
|
})
|
||||||
|
setAnalysisError(noResultMessage)
|
||||||
|
setStage('insufficient')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const analysisMessages = relevantMessages.length ? relevantMessages : uniqueMessages
|
||||||
|
addDebugEntry('检索消息匹配完成', {
|
||||||
|
contactCount: sourceContacts.length,
|
||||||
|
uniqueMessageCount: uniqueMessages.length,
|
||||||
|
contactNameMatchCount: contactNamesInQuery.length,
|
||||||
|
searchIntent: searchPlan.intent,
|
||||||
|
searchPlanSource: searchPlan.source,
|
||||||
|
queryKeywords,
|
||||||
|
fuzzyKeywordCount: fuzzySearchKeywords.length,
|
||||||
|
passSummaries,
|
||||||
|
queryAliasCount: Object.keys(querySenderDirectory.aliases).length,
|
||||||
|
senderMatchCount: matchedSenderIds.size,
|
||||||
|
senderMessageCount: senderMatchedMessages.length,
|
||||||
|
keywordMessageCount: keywordMatchedMessages.length,
|
||||||
|
relevantMessageCount: relevantMessages.length,
|
||||||
|
fallbackToAllMessages: relevantMessages.length === 0
|
||||||
|
})
|
||||||
|
const analysisContactIds = new Set(analysisMessages.map(({ contact }) => contact.md5))
|
||||||
|
const analysisContacts = sourceContacts.filter((contact) =>
|
||||||
|
analysisContactIds.has(contact.md5)
|
||||||
|
)
|
||||||
|
const resolvedSenderNames = {
|
||||||
|
...querySenderDirectory.displayNames,
|
||||||
|
...(
|
||||||
|
await loadSenderDirectory(
|
||||||
|
analysisContacts.filter((contact) => !matchedContactIds.has(contact.md5))
|
||||||
|
)
|
||||||
|
).displayNames
|
||||||
|
}
|
||||||
|
const primaryMessages = senderMatchedMessages.length
|
||||||
|
? senderMatchedMessages
|
||||||
|
: keywordMatchedMessages
|
||||||
|
const primaryMessageIds = new Set(primaryMessages.map(evidenceIdentity))
|
||||||
|
const selectedEvidenceItems = primaryMessages.length
|
||||||
|
? selectEvenly(primaryMessages, 8)
|
||||||
|
: selectEvidenceByDate(analysisMessages, 8)
|
||||||
|
const contextItems = primaryMessages.length
|
||||||
|
? [
|
||||||
|
...selectEvenly(primaryMessages, Math.min(160, primaryMessages.length)),
|
||||||
|
...selectEvidenceByDate(
|
||||||
|
analysisMessages.filter((item) => !primaryMessageIds.has(evidenceIdentity(item))),
|
||||||
|
80
|
||||||
|
)
|
||||||
|
]
|
||||||
|
: selectEvidenceByDate(analysisMessages, 240)
|
||||||
|
const dateCounts = new Map<string, number>()
|
||||||
|
const senderCounts = new Map<string, number>()
|
||||||
|
analysisMessages.forEach(({ contact, message }) => {
|
||||||
|
const date = messageDateKey(message)
|
||||||
|
dateCounts.set(date, (dateCounts.get(date) || 0) + 1)
|
||||||
|
const name = senderName(message, contact, resolvedSenderNames)
|
||||||
|
senderCounts.set(name, (senderCounts.get(name) || 0) + 1)
|
||||||
|
})
|
||||||
|
const dateSummary = Array.from(dateCounts.entries())
|
||||||
|
.map(([date, count]) => `${formatMessageDate(date)} ${count} 条`)
|
||||||
|
.join('、')
|
||||||
|
const senderSummary = Array.from(senderCounts.entries())
|
||||||
|
.sort(([, left], [, right]) => right - left)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map(([name, count]) => `${name} ${count} 条`)
|
||||||
|
.join('、')
|
||||||
|
const asksConversationParticipants =
|
||||||
|
searchPlan.intent === 'participants' || searchPlan.intent === 'mixed'
|
||||||
|
const context = contextItems
|
||||||
|
.map(
|
||||||
|
({ contact, message }) =>
|
||||||
|
`[${formatMessageTime(message)}] ${contact.m_nsNickName} / ${senderName(message, contact, resolvedSenderNames)}: ${messageText(message)}`
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
const aiResult = await window.api.aiChat([
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'你是 WechatExplorer 的本地聊天记录分析助手。只能基于提供的消息回答,不得编造事实。请用中文回答,先给出简短摘要,再列出关键主题、结论和不确定性。对人物问题只能描述群聊中的发言主题和可能角色,不做人格或敏感属性判断。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `检索范围:${sourceLabel},时间:${RANGE_LABELS[range]}\n用户问题:${normalizedQuery}\n检索意图:${searchPlan.intent}\n检索关键词:${queryKeywords.join('、') || '未提取到主题关键词'}\n检索变体:${fuzzySearchKeywords.join('、') || '无'}\n相关消息数:${analysisMessages.length}\n目标成员消息数:${senderMatchedMessages.length}\n检索范围消息总数:${uniqueMessages.length}\n覆盖日期:${new Set(analysisMessages.map((item) => messageDateKey(item.message))).size} 天\n按日期统计:${dateSummary}\n主要发言者统计:${senderSummary}\n${asksConversationParticipants ? '\n这是一个“我和谁聊过”的问题,请按聊天会话和联系人归纳,优先列出实际出现主题关键词的会话,不要根据全量消息猜测。' : ''}\n以下是按检索轮次命中的原始消息,优先级最高的消息排在前面,不代表全部消息:\n${context}`
|
||||||
|
}
|
||||||
|
])
|
||||||
|
if (!aiResult.success || !aiResult.data) {
|
||||||
|
setAnalysisError(aiResult.error || 'AI 分析失败,请稍后重试')
|
||||||
|
setStage('insufficient')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAnswer(aiResult.data)
|
||||||
|
setEvidence(selectedEvidenceItems)
|
||||||
|
setSenderNames(resolvedSenderNames)
|
||||||
|
setMessageCount(uniqueMessages.length)
|
||||||
|
rememberQuery(normalizedQuery)
|
||||||
|
writeSearchCache({
|
||||||
|
version: 1,
|
||||||
|
key: cacheKey,
|
||||||
|
createdAt: currentTimestamp(),
|
||||||
|
answer: aiResult.data,
|
||||||
|
evidence: selectedEvidenceItems.map(compactCacheItem),
|
||||||
|
senderNames: resolvedSenderNames,
|
||||||
|
messageCount: uniqueMessages.length
|
||||||
|
})
|
||||||
|
setStage('result')
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '读取聊天记录失败'
|
||||||
|
addDebugEntry('检索失败', { error: errorMessage })
|
||||||
|
setAnalysisError(errorMessage)
|
||||||
|
setStage('insufficient')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyAnswer = async (): Promise<void> => {
|
||||||
|
if (!answer) return
|
||||||
|
const result = await window.api.copyText(answer)
|
||||||
|
onNotice(result.success ? 'AI 摘要已复制' : result.error || '复制失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderIdle = (): React.ReactElement => (
|
||||||
|
<div className="ai-search-empty">
|
||||||
|
<div className="ai-search-empty-mark" aria-hidden>
|
||||||
|
<span>✦</span>
|
||||||
|
</div>
|
||||||
|
<span className="ai-search-kicker">LOCAL AI WORKSPACE</span>
|
||||||
|
<h2>把聊天记录变成可追问的答案</h2>
|
||||||
|
<p>选择范围,用自然语言提问。AI 只读取本地聊天数据,并为每个结论保留证据。</p>
|
||||||
|
<div className="ai-search-prompts">
|
||||||
|
{[
|
||||||
|
'交友群"张三"最近聊了什么?',
|
||||||
|
'工作群"李四"今天发布了什么任务?',
|
||||||
|
'我和"老李"最近聊了什么话题?',
|
||||||
|
'全局搜一下 我和谁聊过 去健身?'
|
||||||
|
].map((prompt) => (
|
||||||
|
<button key={prompt} type="button" onClick={() => setQuery(prompt)}>
|
||||||
|
{prompt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderLoading = (): React.ReactElement => (
|
||||||
|
<div className="ai-search-loading">
|
||||||
|
<div className="ai-search-spinner" aria-hidden />
|
||||||
|
<span className="ai-search-kicker">AI 正在分析</span>
|
||||||
|
<h2>正在读取本地消息并提取证据</h2>
|
||||||
|
<p>
|
||||||
|
范围:{sourceLabel} · {RANGE_LABELS[range]}
|
||||||
|
</p>
|
||||||
|
<div className="ai-search-loading-steps">
|
||||||
|
<span className="done">● 建立本地数据范围</span>
|
||||||
|
<span className="active">● 提取关键消息</span>
|
||||||
|
<span>○ 生成带证据的摘要</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderResult = (): React.ReactElement => (
|
||||||
|
<div className="ai-search-result">
|
||||||
|
<div className="ai-search-result-header">
|
||||||
|
<div>
|
||||||
|
<span className="ai-search-kicker">AI 深度检索结果</span>
|
||||||
|
<h2>{query}</h2>
|
||||||
|
<p>
|
||||||
|
{sourceLabel} · {RANGE_LABELS[range]} · 基于 {messageCount} 条消息 · 证据采样{' '}
|
||||||
|
{evidence.length} 条{cachedAt ? ' · 已使用缓存' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-result-actions">
|
||||||
|
<button type="button" onClick={() => void copyAnswer()} title="复制 AI 摘要">
|
||||||
|
复制摘要
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
bypassCacheRef.current = true
|
||||||
|
void runAnalysis()
|
||||||
|
}}
|
||||||
|
title="跳过缓存并重新读取聊天记录"
|
||||||
|
>
|
||||||
|
刷新数据
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="ai-search-summary-block">
|
||||||
|
<div className="ai-search-section-heading">
|
||||||
|
<span />
|
||||||
|
摘要
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-answer">{renderMarkdown(answer)}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderInsufficient = (): React.ReactElement => (
|
||||||
|
<div className="ai-search-insufficient">
|
||||||
|
<div className="ai-search-insufficient-icon">!</div>
|
||||||
|
<span className="ai-search-kicker">检索反馈</span>
|
||||||
|
<h2>{analysisError || '当前范围没有足够证据'}</h2>
|
||||||
|
<p>可以扩大时间范围、切换群聊,或换一个更具体的问题。</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setRange('30d')
|
||||||
|
setStage('idle')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
扩大到近 30 天
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ai-search-workspace">
|
||||||
|
<header className="ai-search-header">
|
||||||
|
<div>
|
||||||
|
<span className="ai-search-kicker">WechatExplorer · LOCAL INTELLIGENCE</span>
|
||||||
|
<h1>AI 智能检索</h1>
|
||||||
|
<p>在本地聊天记录中提炼主题、结论和可追溯证据</p>
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-header-actions">
|
||||||
|
<div className="ai-search-model-status">
|
||||||
|
<span className={aiModelConfig.configured ? 'ready' : 'warning'} />
|
||||||
|
<span>{modelLabel}</span>
|
||||||
|
{!aiModelConfig.configured && (
|
||||||
|
<button type="button" onClick={onOpenAISettings}>
|
||||||
|
配置模型
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{debugEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ai-search-debug-button ${debugPanelOpen ? 'active' : ''}`}
|
||||||
|
onClick={() => setDebugPanelOpen((open) => !open)}
|
||||||
|
title="查看本次检索诊断信息"
|
||||||
|
>
|
||||||
|
诊断日志
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{debugEnabled && debugPanelOpen && (
|
||||||
|
<section className="ai-search-debug-panel">
|
||||||
|
<div className="ai-search-debug-header">
|
||||||
|
<div>
|
||||||
|
<strong>检索诊断</strong>
|
||||||
|
<span>
|
||||||
|
{debugEnabled ? '已写入应用日志' : '仅显示本次会话,设置中可开启持久化日志'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-debug-actions">
|
||||||
|
<button type="button" onClick={() => setDebugEntries([])}>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void window.api.revealAppLog()}>
|
||||||
|
打开日志文件夹
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{appLogPath && <small className="ai-search-debug-path">{appLogPath}</small>}
|
||||||
|
<pre>{debugEntries.length ? debugEntries.join('\n') : '等待下一次检索操作...'}</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<div className="ai-search-grid">
|
||||||
|
<aside className="ai-search-scope-panel">
|
||||||
|
<div className="ai-search-panel-heading">
|
||||||
|
<div>
|
||||||
|
<span>范围与过滤</span>
|
||||||
|
<strong>检索范围</strong>
|
||||||
|
</div>
|
||||||
|
<span className="ai-search-local-badge">本地</span>
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-scope-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={scope === 'global' ? 'active' : ''}
|
||||||
|
onClick={() => setScope('global')}
|
||||||
|
>
|
||||||
|
全局搜索
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={scope === 'conversation' ? 'active' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
if (activeContact) {
|
||||||
|
setScope('conversation')
|
||||||
|
setScopeContactMd5(activeContact.md5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onNotice('请先从下方选择一个会话')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
指定会话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="ai-search-scope-help">
|
||||||
|
{scope === 'global'
|
||||||
|
? '全局搜索会读取全部群聊和私聊;左侧列表用于快速指定会话。'
|
||||||
|
: '指定会话只读取当前高亮的一个群聊或私聊。'}
|
||||||
|
</p>
|
||||||
|
<label className="ai-search-field-label" htmlFor="ai-search-contact-filter">
|
||||||
|
会话
|
||||||
|
</label>
|
||||||
|
<div className="ai-search-filter-input">
|
||||||
|
<SearchIcon />
|
||||||
|
<input
|
||||||
|
id="ai-search-contact-filter"
|
||||||
|
value={contactFilter}
|
||||||
|
onChange={(event) => setContactFilter(event.target.value)}
|
||||||
|
placeholder="搜索群聊或联系人"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-contact-list">
|
||||||
|
{visibleContacts.map((contact) => (
|
||||||
|
<button
|
||||||
|
key={contact.md5}
|
||||||
|
type="button"
|
||||||
|
className={`ai-search-contact ${scope === 'conversation' && scopeContactMd5 === contact.md5 ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setScope('conversation')
|
||||||
|
setScopeContactMd5(contact.md5)
|
||||||
|
onSelectContact(contact)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="ai-search-contact-avatar">
|
||||||
|
{contact.avatar ? (
|
||||||
|
<img src={contact.avatar} alt="" />
|
||||||
|
) : (
|
||||||
|
contact.m_nsNickName.charAt(0)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{contact.m_nsNickName || contact.m_nsUsrName}</strong>
|
||||||
|
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!visibleContacts.length && <span className="ai-search-muted">没有匹配的会话</span>}
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-field-heading">
|
||||||
|
<label className="ai-search-field-label">时间范围</label>
|
||||||
|
<span>当前:{RANGE_LABELS[range]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-range-grid">
|
||||||
|
{(Object.keys(RANGE_LABELS) as SearchRange[]).map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
className={range === item ? 'active' : ''}
|
||||||
|
aria-pressed={range === item}
|
||||||
|
onClick={() => setRange(item)}
|
||||||
|
>
|
||||||
|
{RANGE_LABELS[item]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-filter-note">
|
||||||
|
<span>●</span> 当前分析只读取本机数据库,不会操作微信。
|
||||||
|
</div>
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="ai-search-history">
|
||||||
|
<label className="ai-search-field-label">最近提问</label>
|
||||||
|
{history.map((item) => (
|
||||||
|
<div className="ai-search-history-item" key={item}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => restoreHistoryQuery(item)}
|
||||||
|
title="恢复这条提问"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ai-search-history-delete"
|
||||||
|
aria-label={`删除最近提问:${item}`}
|
||||||
|
title="删除这条提问"
|
||||||
|
onClick={() => removeHistoryQuery(item)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
<main className="ai-search-main">
|
||||||
|
<div className="ai-search-main-scroll">
|
||||||
|
{stage === 'idle' && renderIdle()}
|
||||||
|
{stage === 'loading' && renderLoading()}
|
||||||
|
{stage === 'result' && renderResult()}
|
||||||
|
{stage === 'insufficient' && renderInsufficient()}
|
||||||
|
</div>
|
||||||
|
<form className="ai-search-composer" onSubmit={(event) => void runAnalysis(event)}>
|
||||||
|
<div className="ai-search-composer-meta">
|
||||||
|
<span>正在询问</span>
|
||||||
|
<strong>{sourceLabel}</strong>
|
||||||
|
<em>{RANGE_LABELS[range]}</em>
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-composer-row">
|
||||||
|
<textarea
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="例如:技术交流群最近讨论了哪些 Windows 性能问题?"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="primary" disabled={stage === 'loading'}>
|
||||||
|
{stage === 'loading' ? '分析中' : '开始分析'}
|
||||||
|
<span>→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-composer-foot">
|
||||||
|
<span>Enter 发送 · Shift + Enter 换行</span>
|
||||||
|
<span>AI 只使用当前搜索范围内的消息</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<aside className="ai-search-evidence-panel">
|
||||||
|
<div className="ai-search-panel-heading">
|
||||||
|
<div>
|
||||||
|
<span>可追溯数据</span>
|
||||||
|
<strong>证据与来源</strong>
|
||||||
|
</div>
|
||||||
|
{evidence.length > 0 && (
|
||||||
|
<span className="ai-search-count-badge">{evidence.length} 条样本</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ai-search-evidence-meta">
|
||||||
|
<span>范围</span>
|
||||||
|
<strong>{sourceLabel}</strong>
|
||||||
|
<span>时间</span>
|
||||||
|
<strong>{RANGE_LABELS[range]}</strong>
|
||||||
|
</div>
|
||||||
|
{evidence.length ? (
|
||||||
|
evidence.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={`${messageIdentity(item.message)}-${index}`}
|
||||||
|
type="button"
|
||||||
|
className={`ai-search-evidence-card ${selectedEvidence === index ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEvidence(index)
|
||||||
|
onOpenEvidence(item.contact, item.message.createTime)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="ai-search-evidence-card-top">
|
||||||
|
<strong>{senderName(item.message, item.contact, senderNames)}</strong>
|
||||||
|
<time>{formatMessageTime(item.message)}</time>
|
||||||
|
</span>
|
||||||
|
<span className="ai-search-evidence-conversation">{item.contact.m_nsNickName}</span>
|
||||||
|
<span className="ai-search-evidence-text">{messageText(item.message)}</span>
|
||||||
|
<span className="ai-search-evidence-link">跳转到档案 ↗</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="ai-search-evidence-empty">
|
||||||
|
<div>⌕</div>
|
||||||
|
<strong>等待检索结果</strong>
|
||||||
|
<span>分析完成后,这里会显示支持结论的原始消息。</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const inlineMarkdown = (value: string, keyPrefix: string): React.ReactNode[] =>
|
||||||
|
value.split(/(\*\*.*?\*\*|`.*?`|\*.*?\*)/g).map((part, index) => {
|
||||||
|
const key = `${keyPrefix}-${index}`
|
||||||
|
if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
|
return <strong key={key}>{part.slice(2, -2)}</strong>
|
||||||
|
}
|
||||||
|
if (part.startsWith('`') && part.endsWith('`')) {
|
||||||
|
return <code key={key}>{part.slice(1, -1)}</code>
|
||||||
|
}
|
||||||
|
if (part.startsWith('*') && part.endsWith('*')) {
|
||||||
|
return <em key={key}>{part.slice(1, -1)}</em>
|
||||||
|
}
|
||||||
|
return <React.Fragment key={key}>{part}</React.Fragment>
|
||||||
|
})
|
||||||
|
|
||||||
|
export const renderMarkdown = (value: string): React.ReactNode =>
|
||||||
|
value.split(/\r?\n/).map((line, index) => {
|
||||||
|
const key = `markdown-${index}`
|
||||||
|
if (!line.trim()) return <div key={key} className="ai-search-markdown-spacer" />
|
||||||
|
const heading = /^(#{1,3})\s+(.+)$/.exec(line)
|
||||||
|
if (heading) {
|
||||||
|
const Heading = `h${heading[1].length}` as 'h1' | 'h2' | 'h3'
|
||||||
|
return <Heading key={key}>{inlineMarkdown(heading[2], key)}</Heading>
|
||||||
|
}
|
||||||
|
const bullet = /^\s*[-*]\s+(.+)$/.exec(line)
|
||||||
|
if (bullet) {
|
||||||
|
return (
|
||||||
|
<div key={key} className="ai-search-markdown-list-item">
|
||||||
|
<span aria-hidden>•</span>
|
||||||
|
<span>{inlineMarkdown(bullet[1], key)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const numbered = /^\s*\d+[.)]\s+(.+)$/.exec(line)
|
||||||
|
if (numbered) {
|
||||||
|
return (
|
||||||
|
<div key={key} className="ai-search-markdown-list-item">
|
||||||
|
<span aria-hidden>{line.trim().match(/^\d+/)?.[0]}.</span>
|
||||||
|
<span>{inlineMarkdown(numbered[1], key)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <p key={key}>{inlineMarkdown(line, key)}</p>
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||||
|
import type { Contact, Message } from '../../../../shared/types'
|
||||||
|
|
||||||
|
export type SearchStage = 'idle' | 'loading' | 'result' | 'insufficient'
|
||||||
|
export type SearchScope = 'global' | 'conversation'
|
||||||
|
export type SearchRange = 'today' | '7d' | '30d' | 'all'
|
||||||
|
export type SearchIntent = 'general' | 'topic' | 'participants' | 'mixed'
|
||||||
|
|
||||||
|
export interface EvidenceItem {
|
||||||
|
contact: Contact
|
||||||
|
message: Message
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AISearchCacheRecord {
|
||||||
|
version: 1
|
||||||
|
key: string
|
||||||
|
createdAt: number
|
||||||
|
answer: string
|
||||||
|
evidence: EvidenceItem[]
|
||||||
|
senderNames: Record<string, string>
|
||||||
|
messageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberName {
|
||||||
|
wxid: string
|
||||||
|
nickname?: string
|
||||||
|
groupNickname?: string
|
||||||
|
remark?: string
|
||||||
|
wechatNickname?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SenderDirectory {
|
||||||
|
displayNames: Record<string, string>
|
||||||
|
aliases: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchQueryPlan {
|
||||||
|
intent: SearchIntent
|
||||||
|
keywords: string[]
|
||||||
|
variants: string[]
|
||||||
|
source: 'local' | 'ai' | 'hybrid'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchPassSummary {
|
||||||
|
label: string
|
||||||
|
keywords: string[]
|
||||||
|
messageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AISearchWorkspaceProps {
|
||||||
|
contacts: Contact[]
|
||||||
|
selectedContact: Contact | null
|
||||||
|
dbReady: boolean
|
||||||
|
aiModelConfig: AIRuntimeModelConfig
|
||||||
|
onSelectContact: (contact: Contact) => void
|
||||||
|
onOpenEvidence: (contact: Contact, createTime?: number) => void
|
||||||
|
onOpenAISettings: () => void
|
||||||
|
onNotice: (message: string) => void
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import type { Contact, Message } from '../../../../shared/types'
|
||||||
|
import type { AISearchCacheRecord, EvidenceItem, GroupMemberName, SearchIntent, SearchQueryPlan, SearchRange, SearchScope } from './searchTypes'
|
||||||
|
|
||||||
|
export const RANGE_LABELS: Record<SearchRange, string> = {
|
||||||
|
today: '今天',
|
||||||
|
'7d': '近 7 天',
|
||||||
|
'30d': '近 30 天',
|
||||||
|
all: '全部历史'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SEARCH_CACHE_KEY = 'wxe_ai_search_cache_v8'
|
||||||
|
export const SEARCH_HISTORY_KEY = 'wxe_ai_search_history_v1'
|
||||||
|
export const SEARCH_CACHE_LIMIT = 20
|
||||||
|
export const currentTimestamp = (): number => Date.now()
|
||||||
|
|
||||||
|
const SEARCH_INTENT_PHRASES = [
|
||||||
|
'全局搜一下',
|
||||||
|
'全局搜索',
|
||||||
|
'搜索一下',
|
||||||
|
'搜一下',
|
||||||
|
'查询一下',
|
||||||
|
'查一下',
|
||||||
|
'找一下',
|
||||||
|
'我和谁聊过',
|
||||||
|
'谁和我聊过',
|
||||||
|
'哪些人和我聊过',
|
||||||
|
'最近讨论了什么',
|
||||||
|
'最近聊了什么',
|
||||||
|
'最近说了什么',
|
||||||
|
'讨论了什么',
|
||||||
|
'讨论什么',
|
||||||
|
'聊了什么',
|
||||||
|
'聊些什么',
|
||||||
|
'说了什么',
|
||||||
|
'说些什么',
|
||||||
|
'最近讨论',
|
||||||
|
'最近聊天',
|
||||||
|
'这个话题',
|
||||||
|
'相关话题',
|
||||||
|
'的聊天',
|
||||||
|
'的内容',
|
||||||
|
'的记录',
|
||||||
|
'关于',
|
||||||
|
'聊天',
|
||||||
|
'记录',
|
||||||
|
'聊天记录',
|
||||||
|
'帮我',
|
||||||
|
'请问'
|
||||||
|
].sort((left, right) => right.length - left.length)
|
||||||
|
|
||||||
|
const SEARCH_STOP_WORDS = new Set([
|
||||||
|
'我',
|
||||||
|
'谁',
|
||||||
|
'什么',
|
||||||
|
'哪些',
|
||||||
|
'哪个',
|
||||||
|
'人',
|
||||||
|
'和',
|
||||||
|
'聊过',
|
||||||
|
'说过',
|
||||||
|
'提到',
|
||||||
|
'讨论',
|
||||||
|
'聊天',
|
||||||
|
'记录',
|
||||||
|
'说',
|
||||||
|
'聊',
|
||||||
|
'话题',
|
||||||
|
'内容',
|
||||||
|
'相关',
|
||||||
|
'最近',
|
||||||
|
'一下'
|
||||||
|
])
|
||||||
|
|
||||||
|
export const messageText = (message: Message): string =>
|
||||||
|
String(message.content || '').trim() || `[${message.type || '消息'}]`
|
||||||
|
|
||||||
|
export const normalizeSearchText = (value: string): string => value.toLowerCase().replace(/\s+/g, '')
|
||||||
|
|
||||||
|
export const includesSearchAlias = (query: string, alias: string): boolean => {
|
||||||
|
const normalizedAlias = normalizeSearchText(alias.trim())
|
||||||
|
return Boolean(normalizedAlias) && normalizeSearchText(query).includes(normalizedAlias)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractSearchKeywords = (query: string): string[] => {
|
||||||
|
const cleanedQuery = SEARCH_INTENT_PHRASES.reduce(
|
||||||
|
(value, phrase) => value.split(phrase).join(' '),
|
||||||
|
query.toLowerCase()
|
||||||
|
)
|
||||||
|
const tokens = cleanedQuery
|
||||||
|
.split(/[\s,,。!?!?、::;;"“”‘’()()[\]【】]+/)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => token.length >= 2 && !SEARCH_STOP_WORDS.has(token))
|
||||||
|
return Array.from(new Set(tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFuzzySearchKeywords = (keywords: string[]): string[] =>
|
||||||
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
keywords.flatMap((keyword) => {
|
||||||
|
const variants = [keyword]
|
||||||
|
if (/^[\u4e00-\u9fff]+$/.test(keyword) && keyword.length > 2) {
|
||||||
|
variants.push(keyword.slice(-2))
|
||||||
|
}
|
||||||
|
return variants
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const normalizeSearchTerms = (terms: unknown): string[] => {
|
||||||
|
if (!Array.isArray(terms)) return []
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
terms
|
||||||
|
.filter((term): term is string => typeof term === 'string')
|
||||||
|
.map((term) => term.trim())
|
||||||
|
.filter((term) => term.length >= 2 && term.length <= 32)
|
||||||
|
)
|
||||||
|
).slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildLocalSearchPlan = (query: string): SearchQueryPlan => {
|
||||||
|
const keywords = extractSearchKeywords(query)
|
||||||
|
const asksParticipants = /我和谁|谁和我|哪些人|哪个人|哪些联系人/.test(query)
|
||||||
|
const intent: SearchIntent = asksParticipants
|
||||||
|
? keywords.length
|
||||||
|
? 'mixed'
|
||||||
|
: 'participants'
|
||||||
|
: keywords.length
|
||||||
|
? 'topic'
|
||||||
|
: 'general'
|
||||||
|
return {
|
||||||
|
intent,
|
||||||
|
keywords,
|
||||||
|
variants: getFuzzySearchKeywords(keywords),
|
||||||
|
source: 'local'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseSearchPlanResponse = (value: string): Partial<SearchQueryPlan> | null => {
|
||||||
|
const jsonMatch = value.match(/\{[\s\S]*\}/)
|
||||||
|
if (!jsonMatch) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>
|
||||||
|
const intent = ['general', 'topic', 'participants', 'mixed'].includes(String(parsed.intent))
|
||||||
|
? (parsed.intent as SearchIntent)
|
||||||
|
: undefined
|
||||||
|
return {
|
||||||
|
intent,
|
||||||
|
keywords: normalizeSearchTerms(parsed.keywords),
|
||||||
|
variants: normalizeSearchTerms(parsed.variants)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mergeSearchPlans = (
|
||||||
|
localPlan: SearchQueryPlan,
|
||||||
|
aiPlan: Partial<SearchQueryPlan> | null
|
||||||
|
): SearchQueryPlan => {
|
||||||
|
if (!aiPlan) return localPlan
|
||||||
|
const keywords = normalizeSearchTerms([...(localPlan.keywords || []), ...(aiPlan.keywords || [])])
|
||||||
|
const variants = normalizeSearchTerms([
|
||||||
|
...getFuzzySearchKeywords(keywords),
|
||||||
|
...(localPlan.variants || []),
|
||||||
|
...(aiPlan.variants || [])
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
intent: aiPlan.intent || localPlan.intent,
|
||||||
|
keywords,
|
||||||
|
variants,
|
||||||
|
source: 'hybrid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messageIdentity = (message: Message): string =>
|
||||||
|
message.localId
|
||||||
|
? `local:${message.localId}`
|
||||||
|
: message.id || `${message.createTime}:${message.content}`
|
||||||
|
|
||||||
|
export const evidenceIdentity = ({ contact, message }: EvidenceItem): string =>
|
||||||
|
`${contact.md5}:${messageIdentity(message)}`
|
||||||
|
|
||||||
|
export const formatMessageTime = (message: Message): string => {
|
||||||
|
if (message.datetime) return message.datetime
|
||||||
|
if (message.createTime) return new Date(message.createTime * 1000).toLocaleString('zh-CN')
|
||||||
|
return '未知时间'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRangeStart = (range: SearchRange): number | undefined => {
|
||||||
|
if (range === 'all') return undefined
|
||||||
|
if (range === 'today') {
|
||||||
|
const now = new Date()
|
||||||
|
return Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000)
|
||||||
|
}
|
||||||
|
const days = range === '7d' ? 7 : 30
|
||||||
|
return Math.floor(Date.now() / 1000) - days * 86400
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messageDateKey = (message: Message): string => {
|
||||||
|
if (!message.createTime) return 'unknown'
|
||||||
|
const date = new Date(message.createTime * 1000)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatMessageDate = (dateKey: string): string => {
|
||||||
|
if (dateKey === 'unknown') return '--/--'
|
||||||
|
const [, month, day] = dateKey.split('-')
|
||||||
|
return `${month}/${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectEvenly = <T,>(items: T[], count: number): T[] => {
|
||||||
|
if (count >= items.length) return items
|
||||||
|
if (count <= 0) return []
|
||||||
|
if (count === 1) return [items[items.length - 1]]
|
||||||
|
return Array.from({ length: count }, (_item, index) => {
|
||||||
|
const itemIndex = Math.round((index * (items.length - 1)) / (count - 1))
|
||||||
|
return items[itemIndex]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectEvidenceByDate = (items: EvidenceItem[], maxItems: number): EvidenceItem[] => {
|
||||||
|
const buckets = new Map<string, EvidenceItem[]>()
|
||||||
|
items.forEach((item) => {
|
||||||
|
const key = messageDateKey(item.message)
|
||||||
|
const bucket = buckets.get(key) || []
|
||||||
|
bucket.push(item)
|
||||||
|
buckets.set(key, bucket)
|
||||||
|
})
|
||||||
|
const entries = Array.from(buckets.entries()).sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
const targetCount = Math.min(maxItems, items.length)
|
||||||
|
const counts = entries.map(() => 0)
|
||||||
|
let remaining = targetCount
|
||||||
|
while (remaining > 0) {
|
||||||
|
let bestIndex = -1
|
||||||
|
let bestRemaining = 0
|
||||||
|
entries.forEach(([, bucket], index) => {
|
||||||
|
const available = bucket.length - counts[index]
|
||||||
|
if (available > bestRemaining) {
|
||||||
|
bestIndex = index
|
||||||
|
bestRemaining = available
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (bestIndex < 0) break
|
||||||
|
counts[bestIndex] += 1
|
||||||
|
remaining -= 1
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.flatMap((entry, index) => selectEvenly(entry[1], counts[index]))
|
||||||
|
.sort((left, right) => (left.message.createTime || 0) - (right.message.createTime || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const looksLikeUserId = (value: string): boolean =>
|
||||||
|
value.startsWith('wxid_') || value.includes('@chatroom') || /^\d{6,}$/.test(value)
|
||||||
|
|
||||||
|
export const formatMemberName = (member: GroupMemberName): string =>
|
||||||
|
member.groupNickname || member.wechatNickname || member.nickname || member.remark || member.wxid
|
||||||
|
|
||||||
|
export const senderName = (message: Message, contact: Contact, names: Record<string, string>): string => {
|
||||||
|
const identifiers = [message.senderId, message.from, message.name].filter(
|
||||||
|
(value): value is string => Boolean(value?.trim())
|
||||||
|
)
|
||||||
|
const mappedName = identifiers.map((value) => names[value]).find(Boolean)
|
||||||
|
if (mappedName) return mappedName
|
||||||
|
if (message.name && !looksLikeUserId(message.name)) return message.name
|
||||||
|
if (message.isSender) return '我'
|
||||||
|
return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const compactCacheItem = ({ contact, message }: EvidenceItem): EvidenceItem => ({
|
||||||
|
contact: {
|
||||||
|
md5: contact.md5,
|
||||||
|
m_nsUsrName: contact.m_nsUsrName,
|
||||||
|
m_nsNickName: contact.m_nsNickName,
|
||||||
|
type: contact.type,
|
||||||
|
avatar: contact.avatar
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
id: message.id,
|
||||||
|
from: message.from,
|
||||||
|
type: message.type,
|
||||||
|
datetime: message.datetime,
|
||||||
|
content: message.content,
|
||||||
|
isSender: message.isSender,
|
||||||
|
name: message.name,
|
||||||
|
senderId: message.senderId,
|
||||||
|
localId: message.localId,
|
||||||
|
serverId: message.serverId,
|
||||||
|
createTime: message.createTime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const buildSearchCacheKey = (
|
||||||
|
scope: SearchScope,
|
||||||
|
contactMd5: string,
|
||||||
|
range: SearchRange,
|
||||||
|
query: string
|
||||||
|
): string => JSON.stringify([scope, contactMd5, range, query.trim().toLowerCase()])
|
||||||
|
|
||||||
|
export const parseSearchCacheKey = (
|
||||||
|
key: string
|
||||||
|
): { scope: SearchScope; contactMd5: string; range: SearchRange; query: string } | null => {
|
||||||
|
try {
|
||||||
|
const parts = JSON.parse(key) as unknown
|
||||||
|
if (
|
||||||
|
!Array.isArray(parts) ||
|
||||||
|
!['global', 'conversation'].includes(String(parts[0])) ||
|
||||||
|
!['today', '7d', '30d', 'all'].includes(String(parts[2])) ||
|
||||||
|
typeof parts[3] !== 'string'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
scope: parts[0] as SearchScope,
|
||||||
|
contactMd5: typeof parts[1] === 'string' ? parts[1] : '',
|
||||||
|
range: parts[2] as SearchRange,
|
||||||
|
query: parts[3]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readSearchCache = (key: string): AISearchCacheRecord | null => {
|
||||||
|
try {
|
||||||
|
const records = JSON.parse(
|
||||||
|
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
|
||||||
|
) as AISearchCacheRecord[]
|
||||||
|
const record = records.find((item) => item.version === 1 && item.key === key)
|
||||||
|
return record || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readSearchCacheByQuery = (
|
||||||
|
query: string
|
||||||
|
): { record: AISearchCacheRecord; location: ReturnType<typeof parseSearchCacheKey> } | null => {
|
||||||
|
try {
|
||||||
|
const records = JSON.parse(
|
||||||
|
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
|
||||||
|
) as AISearchCacheRecord[]
|
||||||
|
const normalizedQuery = query.trim().toLowerCase()
|
||||||
|
for (const record of records) {
|
||||||
|
const location = parseSearchCacheKey(record.key)
|
||||||
|
if (record.version === 1 && location?.query === normalizedQuery) {
|
||||||
|
return { record, location }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const writeSearchCache = (record: AISearchCacheRecord): void => {
|
||||||
|
try {
|
||||||
|
const records = JSON.parse(
|
||||||
|
localStorage.getItem(SEARCH_CACHE_KEY) || '[]'
|
||||||
|
) as AISearchCacheRecord[]
|
||||||
|
const nextRecords = [record, ...records.filter((item) => item.key !== record.key)].slice(
|
||||||
|
0,
|
||||||
|
SEARCH_CACHE_LIMIT
|
||||||
|
)
|
||||||
|
localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify(nextRecords))
|
||||||
|
} catch {
|
||||||
|
// A large message result must not break the search itself.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ import { AccountDatabasePage } from './pages/AccountDatabasePage'
|
|||||||
import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
|
import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
|
||||||
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
||||||
import { AIModelPage } from './pages/AIModelPage'
|
import { AIModelPage } from './pages/AIModelPage'
|
||||||
|
import { RecallProtectionPage } from './pages/RecallProtectionPage'
|
||||||
|
import { AdvancedPage } from './pages/AdvancedPage'
|
||||||
|
import { CacheCleanupPage } from './pages/CacheCleanupPage'
|
||||||
|
import { AppearancePage } from './pages/AppearancePage'
|
||||||
|
import { AboutPage } from './pages/AboutPage'
|
||||||
import type { Contact } from '../../../../shared/types'
|
import type { Contact } from '../../../../shared/types'
|
||||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||||
|
|
||||||
@@ -23,7 +28,8 @@ export function SettingsWorkspace({
|
|||||||
onReturnToLogin,
|
onReturnToLogin,
|
||||||
onAIRuntimeChange,
|
onAIRuntimeChange,
|
||||||
onNotice,
|
onNotice,
|
||||||
onOpenSettings
|
onOpenSettings,
|
||||||
|
onAppearanceChange
|
||||||
}: {
|
}: {
|
||||||
selectedCategory: SettingsCategoryId
|
selectedCategory: SettingsCategoryId
|
||||||
onCategoryChange: (id: SettingsCategoryId) => void
|
onCategoryChange: (id: SettingsCategoryId) => void
|
||||||
@@ -39,6 +45,7 @@ export function SettingsWorkspace({
|
|||||||
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
|
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
|
||||||
onNotice: (message: string) => void
|
onNotice: (message: string) => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
|
onAppearanceChange: (settings: { theme: 'system' | 'light' | 'dark'; compactMode: boolean }) => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="settings-workspace">
|
<div className="settings-workspace">
|
||||||
@@ -49,14 +56,17 @@ export function SettingsWorkspace({
|
|||||||
dbReady={dbReady}
|
dbReady={dbReady}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
/>
|
/>
|
||||||
{selectedCategory === 'account-database' ? (
|
<div
|
||||||
|
className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
<AccountDatabasePage
|
<AccountDatabasePage
|
||||||
dbKey={dbKey}
|
dbKey={dbKey}
|
||||||
dbReady={dbReady}
|
dbReady={dbReady}
|
||||||
selfInfo={selfInfo}
|
selfInfo={selfInfo}
|
||||||
onNotice={onNotice}
|
onNotice={onNotice}
|
||||||
/>
|
/>
|
||||||
) : selectedCategory === 'database-key' ? (
|
</div>
|
||||||
|
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
|
||||||
<DatabaseKeyPage
|
<DatabaseKeyPage
|
||||||
dbKey={dbKey}
|
dbKey={dbKey}
|
||||||
dbReady={dbReady}
|
dbReady={dbReady}
|
||||||
@@ -69,12 +79,44 @@ export function SettingsWorkspace({
|
|||||||
onReturnToLogin={onReturnToLogin}
|
onReturnToLogin={onReturnToLogin}
|
||||||
onNotice={onNotice}
|
onNotice={onNotice}
|
||||||
/>
|
/>
|
||||||
) : selectedCategory === 'image-key' ? (
|
</div>
|
||||||
|
<div className={`settings-page-panel ${selectedCategory === 'image-key' ? 'active' : ''}`}>
|
||||||
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
||||||
) : selectedCategory === 'ai-model' ? (
|
</div>
|
||||||
|
<div className={`settings-page-panel ${selectedCategory === 'ai-model' ? 'active' : ''}`}>
|
||||||
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||||
) : (
|
</div>
|
||||||
|
<div
|
||||||
|
className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<RecallProtectionPage onNotice={onNotice} />
|
||||||
|
</div>
|
||||||
|
<div className={`settings-page-panel ${selectedCategory === 'advanced' ? 'active' : ''}`}>
|
||||||
|
<AdvancedPage onNotice={onNotice} />
|
||||||
|
</div>
|
||||||
|
<div className={`settings-page-panel ${selectedCategory === 'cache-cleanup' ? 'active' : ''}`}>
|
||||||
|
<CacheCleanupPage onNotice={onNotice} />
|
||||||
|
</div>
|
||||||
|
<div className={`settings-page-panel ${selectedCategory === 'appearance' ? 'active' : ''}`}>
|
||||||
|
<AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
|
||||||
|
</div>
|
||||||
|
<div className={`settings-page-panel ${selectedCategory === 'about' ? 'active' : ''}`}>
|
||||||
|
<AboutPage onNotice={onNotice} />
|
||||||
|
</div>
|
||||||
|
{![
|
||||||
|
'account-database',
|
||||||
|
'database-key',
|
||||||
|
'image-key',
|
||||||
|
'ai-model',
|
||||||
|
'recall-protection',
|
||||||
|
'advanced',
|
||||||
|
'cache-cleanup',
|
||||||
|
'appearance',
|
||||||
|
'about'
|
||||||
|
].includes(selectedCategory) && (
|
||||||
|
<div className="settings-page-panel active">
|
||||||
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export function AutoDetectImageKeySection({
|
|||||||
{state.status.platform === 'darwin'
|
{state.status.platform === 'darwin'
|
||||||
? '扫描本机微信缓存并通过图片模板验证候选密钥。'
|
? '扫描本机微信缓存并通过图片模板验证候选密钥。'
|
||||||
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
|
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
|
||||||
|
<br />
|
||||||
|
<small className="image-auto-scope-hint">
|
||||||
|
仅支持 WeChat 4.0,V3 及以下无法解析
|
||||||
|
</small>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -7,19 +7,10 @@ export function ImageKeyConfiguration({
|
|||||||
}: {
|
}: {
|
||||||
state: ImageDecryptionState
|
state: ImageDecryptionState
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
onEdit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void
|
onEdit: (field: 'xorKey' | 'aesKey', value: string) => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<section className="settings-card image-key-editor">
|
<section className="settings-card image-key-editor">
|
||||||
<label>
|
|
||||||
<span>图片资源目录</span>
|
|
||||||
<input
|
|
||||||
value={state.resourceRoot}
|
|
||||||
disabled={disabled}
|
|
||||||
title={state.resourceRoot}
|
|
||||||
onChange={(event) => onEdit('resourceRoot', event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="image-key-grid">
|
<div className="image-key-grid">
|
||||||
<label>
|
<label>
|
||||||
<span>XOR Key</span>
|
<span>XOR Key</span>
|
||||||
|
|||||||
@@ -1,5 +1,58 @@
|
|||||||
import type { ImageDecryptionState } from './types'
|
import type { ImageDecryptionState } from './types'
|
||||||
|
|
||||||
|
type StepState = 'pending' | 'ok' | 'fail' | 'skipped'
|
||||||
|
|
||||||
|
function stepClass(step: StepState): string {
|
||||||
|
switch (step) {
|
||||||
|
case 'ok':
|
||||||
|
return 'image-step image-step-ok'
|
||||||
|
case 'fail':
|
||||||
|
return 'image-step image-step-fail'
|
||||||
|
case 'skipped':
|
||||||
|
return 'image-step image-step-skipped'
|
||||||
|
default:
|
||||||
|
return 'image-step image-step-pending'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepIcon(step: StepState): string {
|
||||||
|
switch (step) {
|
||||||
|
case 'ok':
|
||||||
|
return '✓'
|
||||||
|
case 'fail':
|
||||||
|
return '×'
|
||||||
|
case 'skipped':
|
||||||
|
return '·'
|
||||||
|
default:
|
||||||
|
return '○'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSteps(result: {
|
||||||
|
fileFound: boolean
|
||||||
|
decrypted: boolean
|
||||||
|
readable: boolean
|
||||||
|
success: boolean
|
||||||
|
}): { find: StepState; decrypt: StepState; read: StepState } {
|
||||||
|
// 三步严格联动:找到失败 → 解密/读取 skipped;解密失败 → 读取 skipped;
|
||||||
|
// 解密成功但不可读 → 读取 fail。
|
||||||
|
if (!result.fileFound) {
|
||||||
|
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
|
||||||
|
}
|
||||||
|
if (!result.success && !result.decrypted && !result.readable) {
|
||||||
|
// 后端把 fileFound=false 的情况也用 success:false 表达;
|
||||||
|
// 此时第一步直接 fail,后两步 skip。
|
||||||
|
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
|
||||||
|
}
|
||||||
|
if (!result.decrypted) {
|
||||||
|
return { find: 'ok', decrypt: 'fail', read: 'skipped' }
|
||||||
|
}
|
||||||
|
if (!result.readable) {
|
||||||
|
return { find: 'ok', decrypt: 'ok', read: 'fail' }
|
||||||
|
}
|
||||||
|
return { find: 'ok', decrypt: 'ok', read: 'ok' }
|
||||||
|
}
|
||||||
|
|
||||||
export function ImageTestSection({
|
export function ImageTestSection({
|
||||||
state,
|
state,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -16,6 +69,14 @@ export function ImageTestSection({
|
|||||||
onSave: () => void
|
onSave: () => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const result = state.testResult
|
const result = state.testResult
|
||||||
|
const steps = result
|
||||||
|
? pickSteps({
|
||||||
|
fileFound: result.fileFound,
|
||||||
|
decrypted: result.decrypted,
|
||||||
|
readable: result.readable,
|
||||||
|
success: result.success
|
||||||
|
})
|
||||||
|
: null
|
||||||
return (
|
return (
|
||||||
<section className="settings-card image-test-section">
|
<section className="settings-card image-test-section">
|
||||||
<div>
|
<div>
|
||||||
@@ -48,16 +109,27 @@ export function ImageTestSection({
|
|||||||
确认保存
|
确认保存
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{result ? (
|
{steps ? (
|
||||||
<div className={`image-test-result ${result.success ? 'success' : 'error'}`}>
|
<ol className="image-step-list">
|
||||||
<span>{result.fileFound ? '✓' : '×'} 找到图片文件</span>
|
<li className={stepClass(steps.find)}>
|
||||||
<span>{result.decrypted ? '✓' : '×'} 解密成功</span>
|
<span className="image-step-icon">{stepIcon(steps.find)}</span>
|
||||||
<span>{result.readable ? '✓' : '×'} 图片可以读取</span>
|
<span>找到图片文件</span>
|
||||||
{!result.success ? <p>{result.error}</p> : null}
|
</li>
|
||||||
</div>
|
<li className={stepClass(steps.decrypt)}>
|
||||||
|
<span className="image-step-icon">{stepIcon(steps.decrypt)}</span>
|
||||||
|
<span>解密成功</span>
|
||||||
|
</li>
|
||||||
|
<li className={stepClass(steps.read)}>
|
||||||
|
<span className="image-step-icon">{stepIcon(steps.read)}</span>
|
||||||
|
<span>图片可以读取</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
) : state.error ? (
|
) : state.error ? (
|
||||||
<p className="image-inline-error">{state.error}</p>
|
<p className="image-inline-error">{state.error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{result && !result.success && result.error ? (
|
||||||
|
<p className="image-inline-error">{result.error}</p>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ export function imageDecryptionReducer(
|
|||||||
config: action.config,
|
config: action.config,
|
||||||
status: action.status,
|
status: action.status,
|
||||||
contacts: action.contacts,
|
contacts: action.contacts,
|
||||||
resourceRoot: action.config.resourceRoot,
|
// 下方"图片资源目录"跟随状态面板的默认目录同步;
|
||||||
|
// 一旦用户手动编辑过(dirty),就不再被刷新覆盖。
|
||||||
|
resourceRoot: state.dirty && state.resourceRoot
|
||||||
|
? state.resourceRoot
|
||||||
|
: action.status.resourceRoot || action.config.resourceRoot,
|
||||||
xorKey: action.config.xorKey || '0x40',
|
xorKey: action.config.xorKey || '0x40',
|
||||||
aesKey: action.config.aesKey || '',
|
aesKey: action.config.aesKey || '',
|
||||||
error: action.config.success ? undefined : action.config.error,
|
error: action.config.success ? undefined : action.config.error,
|
||||||
@@ -126,7 +130,7 @@ export function imageDecryptionReducer(
|
|||||||
config: action.config,
|
config: action.config,
|
||||||
status: action.status,
|
status: action.status,
|
||||||
contacts: state.contacts,
|
contacts: state.contacts,
|
||||||
resourceRoot: action.config.resourceRoot
|
resourceRoot: action.status.resourceRoot || action.config.resourceRoot
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return state
|
return state
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export type ImageDecryptionAction =
|
|||||||
contacts: Contact[]
|
contacts: Contact[]
|
||||||
}
|
}
|
||||||
| { type: 'LOAD_ERROR'; error: string }
|
| { type: 'LOAD_ERROR'; error: string }
|
||||||
| { type: 'EDIT'; field: 'resourceRoot' | 'xorKey' | 'aesKey'; value: string }
|
| { type: 'EDIT'; field: 'xorKey' | 'aesKey'; value: string }
|
||||||
| { type: 'SELECT_CHAT'; userMd5: string }
|
| { type: 'SELECT_CHAT'; userMd5: string }
|
||||||
| { type: 'TEST_START' }
|
| { type: 'TEST_START' }
|
||||||
| { type: 'TEST_DONE'; result: ImageDecryptionTestResult }
|
| { type: 'TEST_DONE'; result: ImageDecryptionTestResult }
|
||||||
@@ -80,7 +80,7 @@ export interface ImageDecryptionController {
|
|||||||
pageStatus: 'configured' | 'unconfigured' | 'partial'
|
pageStatus: 'configured' | 'unconfigured' | 'partial'
|
||||||
busy: boolean
|
busy: boolean
|
||||||
canSave: boolean
|
canSave: boolean
|
||||||
edit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void
|
edit: (field: 'xorKey' | 'aesKey', value: string) => void
|
||||||
selectChat: (userMd5: string) => void
|
selectChat: (userMd5: string) => void
|
||||||
test: () => Promise<void>
|
test: () => Promise<void>
|
||||||
save: () => Promise<void>
|
save: () => Promise<void>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function useImageDecryptionController({
|
|||||||
})
|
})
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
const edit = useCallback((field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string): void => {
|
const edit = useCallback((field: 'xorKey' | 'aesKey', value: string): void => {
|
||||||
dispatch({ type: 'EDIT', field, value })
|
dispatch({ type: 'EDIT', field, value })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -84,12 +84,17 @@ export function useImageDecryptionController({
|
|||||||
dispatch({ type: 'AUTO_START' })
|
dispatch({ type: 'AUTO_START' })
|
||||||
const result = await window.api.autoGetImageKey({ save: false })
|
const result = await window.api.autoGetImageKey({ save: false })
|
||||||
if (!result.success || !result.aesKey || !result.verified) {
|
if (!result.success || !result.aesKey || !result.verified) {
|
||||||
dispatch({
|
// 自动获取链路:原文透传后端错误信息,不要走 sanitizeImageError。
|
||||||
type: 'AUTO_ERROR',
|
// sanitizeImageError 是给"测试图片解析"设计的字典,会把
|
||||||
error: result.success
|
// "未找到 V2 模板文件 / 微信进程未运行 / 60 秒未扫描到密钥" 等
|
||||||
? '获取到候选密钥,但未通过图片验证'
|
// 完全合法的扫描阶段错误强制翻成"无法解析媒体文件"。
|
||||||
: sanitizeImageError(result.error)
|
const rawError = (result.error || '').toString().trim()
|
||||||
})
|
const errorMessage = !result.success
|
||||||
|
? rawError || '自动获取图片密钥失败'
|
||||||
|
: !result.aesKey
|
||||||
|
? '自动获取未返回 AES 密钥'
|
||||||
|
: '获取到候选密钥,但未通过图片验证'
|
||||||
|
dispatch({ type: 'AUTO_ERROR', error: errorMessage })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ export function formatImageConfigTime(value?: number): string {
|
|||||||
|
|
||||||
export function sanitizeImageError(error?: string): string {
|
export function sanitizeImageError(error?: string): string {
|
||||||
const value = String(error || '').toLowerCase()
|
const value = String(error || '').toLowerCase()
|
||||||
|
if (value.includes('no_image_message') || value.includes('300')) {
|
||||||
|
return '当前会话最近 300 条消息内没有图片,请换一个含图片的聊天再测试'
|
||||||
|
}
|
||||||
|
if (value.includes('unsupported') || value.includes('dat version')) {
|
||||||
|
return '仅支持 WeChat 4.0 图片协议,V3 及以下无法解析'
|
||||||
|
}
|
||||||
if (value.includes('key') || value.includes('密钥')) return '图片密钥未配置或与当前账号不匹配'
|
if (value.includes('key') || value.includes('密钥')) return '图片密钥未配置或与当前账号不匹配'
|
||||||
if (value.includes('不存在') || value.includes('not found')) return '图片文件不存在'
|
if (value.includes('不存在') || value.includes('not found')) return '图片文件不存在'
|
||||||
if (value.includes('账号')) return '当前账号不匹配'
|
if (value.includes('账号')) return '当前账号不匹配'
|
||||||
if (value.includes('目录')) return '图片资源目录不可用'
|
if (value.includes('目录')) return '图片资源目录不可用'
|
||||||
return error ? '无法解析媒体文件' : '图片解析测试未通过'
|
return error ? '无法解析媒体文件(仅支持 WeChat 4.0)' : '图片解析测试未通过'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAutoXorKey(value?: number, formatted?: string): string {
|
export function normalizeAutoXorKey(value?: number, formatted?: string): string {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
|||||||
label: '智能能力',
|
label: '智能能力',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'ai-model', label: 'AI 模型' },
|
{ id: 'ai-model', label: 'AI 模型' },
|
||||||
{ id: 'local-api', label: '本地 API' }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,6 +30,7 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
|||||||
{
|
{
|
||||||
label: '应用',
|
label: '应用',
|
||||||
items: [
|
items: [
|
||||||
|
{ id: 'recall-protection', label: '防撤回' },
|
||||||
{ id: 'appearance', label: '外观与行为' },
|
{ id: 'appearance', label: '外观与行为' },
|
||||||
{ id: 'advanced', label: '高级' },
|
{ id: 'advanced', label: '高级' },
|
||||||
{ id: 'about', label: '关于' }
|
{ id: 'about', label: '关于' }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type SettingsCategoryId =
|
|||||||
| 'database-key'
|
| 'database-key'
|
||||||
| 'image-key'
|
| 'image-key'
|
||||||
| 'ai-model'
|
| 'ai-model'
|
||||||
|
| 'recall-protection'
|
||||||
| 'local-api'
|
| 'local-api'
|
||||||
| 'storage-export'
|
| 'storage-export'
|
||||||
| 'cache-cleanup'
|
| 'cache-cleanup'
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { AppUpdateState } from '../../../../../shared/app-update'
|
||||||
|
|
||||||
|
const REPOSITORY_URL = 'https://github.com/Wxw-Gu/WechatExplorer'
|
||||||
|
const RELEASES_URL = `${REPOSITORY_URL}/releases`
|
||||||
|
|
||||||
|
function formatBytes(value?: number): string {
|
||||||
|
if (!value) return ''
|
||||||
|
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB/s`
|
||||||
|
return `${(value / 1024 / 1024).toFixed(1)} MB/s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AboutPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
|
||||||
|
const [update, setUpdate] = useState<AppUpdateState>({ status: 'idle', currentVersion: '读取中...' })
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.api.getAppUpdateState().then((state) => active && setUpdate(state))
|
||||||
|
const unsubscribe = window.api.onAppUpdateState((state) => {
|
||||||
|
if (active) setUpdate(state)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const action = useMemo(() => {
|
||||||
|
if (update.status === 'downloaded') return '重启并安装'
|
||||||
|
if (update.status === 'available') return '下载更新'
|
||||||
|
if (update.status === 'checking' || update.status === 'downloading') return '处理中...'
|
||||||
|
return '检查更新'
|
||||||
|
}, [update.status])
|
||||||
|
|
||||||
|
const runUpdate = async (): Promise<void> => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
if (update.status === 'downloaded') {
|
||||||
|
const result = await window.api.installAppUpdate()
|
||||||
|
if (!result.success) onNotice(result.error || '更新安装失败')
|
||||||
|
} else if (update.status === 'available') {
|
||||||
|
await window.api.downloadAppUpdate()
|
||||||
|
} else {
|
||||||
|
await window.api.checkAppUpdate()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<header className="settings-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>关于</h1>
|
||||||
|
<p>WechatExplorer 本地微信聊天记录工作台。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="settings-page-scroll">
|
||||||
|
<div className="settings-page-content">
|
||||||
|
<section className="settings-card about-identity-card">
|
||||||
|
<div><span className="settings-card-kicker">当前版本</span><strong>WechatExplorer</strong><small>v{update.currentVersion}</small></div>
|
||||||
|
<a href={REPOSITORY_URL} target="_blank" rel="noreferrer">GitHub 仓库</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 className="settings-section-heading">软件更新</h2>
|
||||||
|
<section className={`settings-card update-card status-${update.status}`}>
|
||||||
|
<div className="update-card-copy">
|
||||||
|
<strong>{update.status === 'available' || update.status === 'downloaded' ? `发现 v${update.version}` : update.message || '检查 GitHub Releases 获取最新版本'}</strong>
|
||||||
|
<span>
|
||||||
|
{update.status === 'downloading'
|
||||||
|
? `正在下载 ${Math.round(update.percent || 0)}% · ${formatBytes(update.bytesPerSecond)}`
|
||||||
|
: '会根据当前系统和 CPU 自动选择对应安装包,安装前会等待你的确认。'}
|
||||||
|
</span>
|
||||||
|
{update.status === 'downloading' && <div className="update-progress"><i style={{ width: `${update.percent || 0}%` }} /></div>}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="settings-primary-button" disabled={busy || update.status === 'checking' || update.status === 'downloading'} onClick={() => void runUpdate()}>{action}</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 className="settings-section-heading">支持</h2>
|
||||||
|
<section className="settings-card about-links-card">
|
||||||
|
<a href={RELEASES_URL} target="_blank" rel="noreferrer">查看历史版本与更新说明</a>
|
||||||
|
<button type="button" onClick={() => void window.api.revealAppLog()}>打开诊断日志目录</button>
|
||||||
|
</section>
|
||||||
|
<p className="settings-footnote">聊天数据、密钥和 AI 配置均保留在本机,更新不会上传这些内容。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,7 +31,8 @@ export function AccountDatabasePage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
void window.api.getSettings().then((result) => {
|
void window.api.getSettings().then((result) => {
|
||||||
if (active) setAutoLogin(result.settings.autoLogin)
|
if (!active) return
|
||||||
|
setAutoLogin(result.settings.autoLogin)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
@@ -46,6 +47,7 @@ export function AccountDatabasePage({
|
|||||||
setAutoLogin(result.settings.autoLogin)
|
setAutoLogin(result.settings.autoLogin)
|
||||||
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
|
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-page">
|
<div className="settings-page">
|
||||||
<header className="settings-page-header">
|
<header className="settings-page-header">
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function AdvancedPage({
|
||||||
|
onNotice
|
||||||
|
}: {
|
||||||
|
onNotice: (message: string) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
const [debugEnabled, setDebugEnabled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.api.getSettings().then((result) => {
|
||||||
|
if (active) setDebugEnabled(result.settings.debugEnabled)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const changeDebugEnabled = async (checked: boolean): Promise<void> => {
|
||||||
|
const result = await window.api.setSettings({ debugEnabled: checked })
|
||||||
|
setDebugEnabled(result.settings.debugEnabled)
|
||||||
|
onNotice(checked ? '已开启调试日志' : '已关闭调试日志')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<header className="settings-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>高级</h1>
|
||||||
|
<p>用于开发人员排查本地数据库和检索问题</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="settings-page-scroll">
|
||||||
|
<div className="settings-page-content">
|
||||||
|
<h2 className="settings-section-heading">诊断</h2>
|
||||||
|
<section className="settings-card settings-debug-card">
|
||||||
|
<label>
|
||||||
|
<span>
|
||||||
|
<b>显示诊断日志</b>
|
||||||
|
<small>开启后,检索页显示诊断日志入口并记录匹配统计,不记录聊天正文。</small>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={debugEnabled}
|
||||||
|
onChange={(event) => void changeDebugEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="settings-debug-actions">
|
||||||
|
<button type="button" onClick={() => void window.api.revealAppLog()}>
|
||||||
|
打开诊断日志
|
||||||
|
</button>
|
||||||
|
<small>关闭调试日志后,仍会保留错误和崩溃日志。</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export type AppearanceTheme = 'system' | 'light' | 'dark'
|
||||||
|
|
||||||
|
export function AppearancePage({
|
||||||
|
onNotice,
|
||||||
|
onAppearanceChange
|
||||||
|
}: {
|
||||||
|
onNotice: (message: string) => void
|
||||||
|
onAppearanceChange: (settings: { theme: AppearanceTheme; compactMode: boolean }) => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
const [theme, setTheme] = useState<AppearanceTheme>('system')
|
||||||
|
const [compactMode, setCompactMode] = useState(false)
|
||||||
|
const [showStartupProgress, setShowStartupProgress] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.api.getSettings().then((result) => {
|
||||||
|
if (!active) return
|
||||||
|
setTheme(result.settings.appearanceTheme)
|
||||||
|
setCompactMode(result.settings.compactMode)
|
||||||
|
setShowStartupProgress(result.settings.showStartupProgress)
|
||||||
|
onAppearanceChange({ theme: result.settings.appearanceTheme, compactMode: result.settings.compactMode })
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [onAppearanceChange])
|
||||||
|
|
||||||
|
const save = async (patch: {
|
||||||
|
appearanceTheme?: AppearanceTheme
|
||||||
|
compactMode?: boolean
|
||||||
|
showStartupProgress?: boolean
|
||||||
|
}): Promise<void> => {
|
||||||
|
const result = await window.api.setSettings(patch)
|
||||||
|
setTheme(result.settings.appearanceTheme)
|
||||||
|
setCompactMode(result.settings.compactMode)
|
||||||
|
setShowStartupProgress(result.settings.showStartupProgress)
|
||||||
|
onAppearanceChange({ theme: result.settings.appearanceTheme, compactMode: result.settings.compactMode })
|
||||||
|
onNotice('外观设置已保存')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<header className="settings-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>外观与行为</h1>
|
||||||
|
<p>调整工作区的显示方式和启动体验。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="settings-page-scroll">
|
||||||
|
<div className="settings-page-content">
|
||||||
|
<h2 className="settings-section-heading">显示主题</h2>
|
||||||
|
<section className="settings-card settings-option-card">
|
||||||
|
<div className="settings-choice-grid">
|
||||||
|
{([
|
||||||
|
['system', '跟随系统', '根据 macOS 或 Windows 外观自动切换'],
|
||||||
|
['light', '浅色', '保持当前清爽的浅色工作区'],
|
||||||
|
['dark', '深色', '降低夜间浏览时的亮度']
|
||||||
|
] as const).map(([value, label, hint]) => (
|
||||||
|
<label className={`settings-choice ${theme === value ? 'active' : ''}`} key={value}>
|
||||||
|
<input type="radio" name="appearance-theme" checked={theme === value} onChange={() => void save({ appearanceTheme: value })} />
|
||||||
|
<span><b>{label}</b><small>{hint}</small></span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 className="settings-section-heading">工作区行为</h2>
|
||||||
|
<section className="settings-card settings-toggle-list">
|
||||||
|
<label className="settings-toggle-row">
|
||||||
|
<span><b>紧凑布局</b><small>减少导航栏和列表的留白,适合较小窗口。</small></span>
|
||||||
|
<input type="checkbox" checked={compactMode} onChange={(event) => void save({ compactMode: event.target.checked })} />
|
||||||
|
</label>
|
||||||
|
<label className="settings-toggle-row">
|
||||||
|
<span><b>显示启动进度</b><small>启动或自动连接数据库时显示详细进度。</small></span>
|
||||||
|
<input type="checkbox" checked={showStartupProgress} onChange={(event) => void save({ showStartupProgress: event.target.checked })} />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import type { CacheSummary } from '../../../../../shared/cache'
|
||||||
|
|
||||||
|
const SEARCH_CACHE_KEYS = ['wxe_ai_search_cache_v8', 'wxe_ai_search_history_v1', 'wxe_export_tasks']
|
||||||
|
|
||||||
|
function formatBytes(value: number): string {
|
||||||
|
if (value < 1024) return `${value} B`
|
||||||
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
|
||||||
|
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
|
||||||
|
const [summary, setSummary] = useState<CacheSummary | null>(null)
|
||||||
|
const [busyScope, setBusyScope] = useState<'bootstrap' | 'electron' | 'all' | 'local' | null>(null)
|
||||||
|
|
||||||
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
|
setSummary(await window.api.getCacheSummary())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const clearLocal = (): void => {
|
||||||
|
setBusyScope('local')
|
||||||
|
for (const key of SEARCH_CACHE_KEYS) localStorage.removeItem(key)
|
||||||
|
setBusyScope(null)
|
||||||
|
onNotice('已清理检索和导出本地缓存')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = async (scope: 'bootstrap' | 'electron' | 'all'): Promise<void> => {
|
||||||
|
setBusyScope(scope)
|
||||||
|
try {
|
||||||
|
if (scope === 'all') {
|
||||||
|
for (const key of SEARCH_CACHE_KEYS) localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
setSummary(await window.api.clearCache(scope))
|
||||||
|
onNotice(scope === 'all' ? '已清理全部可恢复缓存和检索记录' : '缓存已清理')
|
||||||
|
} finally {
|
||||||
|
setBusyScope(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<header className="settings-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>缓存与清理</h1>
|
||||||
|
<p>管理本地加速数据,不会删除微信原始聊天记录或数据库密钥。</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="settings-header-action" onClick={() => void refresh()}>
|
||||||
|
刷新占用
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="settings-page-scroll">
|
||||||
|
<div className="settings-page-content">
|
||||||
|
<section className="settings-card cache-overview-card">
|
||||||
|
<div>
|
||||||
|
<span className="settings-card-kicker">可恢复缓存</span>
|
||||||
|
<strong>{formatBytes(summary?.totalBytes || 0)}</strong>
|
||||||
|
<small>清理后首次打开档案可能需要重新读取。</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="settings-danger-button"
|
||||||
|
disabled={busyScope !== null}
|
||||||
|
onClick={() => void clear('all')}
|
||||||
|
>
|
||||||
|
{busyScope === 'all' ? '清理中...' : '清理全部'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 className="settings-section-heading">缓存分类</h2>
|
||||||
|
<div className="settings-cache-list">
|
||||||
|
{summary?.items.map((item) => (
|
||||||
|
<section className="settings-card settings-cache-item" key={item.id}>
|
||||||
|
<div>
|
||||||
|
<h3>{item.label}</h3>
|
||||||
|
<p>{item.description}</p>
|
||||||
|
<small>{formatBytes(item.sizeBytes)} · {item.fileCount} 个文件</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busyScope !== null}
|
||||||
|
onClick={() => void clear(item.id)}
|
||||||
|
>
|
||||||
|
{busyScope === item.id ? '清理中...' : '清理'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
<section className="settings-card settings-cache-item">
|
||||||
|
<div>
|
||||||
|
<h3>检索与导出记录</h3>
|
||||||
|
<p>清理最近提问、检索结果和导出任务列表,不影响聊天数据库。</p>
|
||||||
|
<small>浏览器本地缓存</small>
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled={busyScope !== null} onClick={clearLocal}>
|
||||||
|
{busyScope === 'local' ? '清理中...' : '清理'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-inline-note">
|
||||||
|
<strong>说明</strong>
|
||||||
|
<span>缓存没有过期时间,只有在这里手动清理,或应用检测到格式需要迁移时才会被替换。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function RecallProtectionPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.api.getSettings().then((result) => {
|
||||||
|
if (active) setEnabled(result.settings.recallProtectionEnabled)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const changeEnabled = async (checked: boolean): Promise<void> => {
|
||||||
|
const result = await window.api.setSettings({ recallProtectionEnabled: checked })
|
||||||
|
setEnabled(result.settings.recallProtectionEnabled)
|
||||||
|
onNotice(checked ? '已开启防撤回' : '已关闭防撤回')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<header className="settings-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>防撤回</h1>
|
||||||
|
<p>尽量保留已撤回的聊天内容,并在聊天记录中明确标记。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="settings-page-scroll">
|
||||||
|
<div className="settings-page-content">
|
||||||
|
<h2 className="settings-section-heading">消息处理</h2>
|
||||||
|
<section className="settings-card settings-recall-card">
|
||||||
|
<div className="settings-recall-grid">
|
||||||
|
<label className="settings-recall-option">
|
||||||
|
<span>
|
||||||
|
<b>开启防撤回</b>
|
||||||
|
<small>尽量保留已撤回的聊天内容,方便后续查看。</small>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(event) => void changeEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<aside className="settings-recall-notice">
|
||||||
|
<strong>性能提示</strong>
|
||||||
|
<span>开启后会为消息表增加监听,数据量较大或磁盘较慢时可能让加载变慢。</span>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './styles/tokens.css'
|
import './styles/index.scss'
|
||||||
import './assets/main.css'
|
|
||||||
|
|
||||||
window.addEventListener('error', (event) => {
|
window.addEventListener('error', (event) => {
|
||||||
void window.api
|
void window.api
|
||||||
|
|||||||
@@ -0,0 +1,769 @@
|
|||||||
|
@media (max-width: 700px) {
|
||||||
|
.settings-account-overview {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.settings-account-actions {
|
||||||
|
grid-row: auto;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.settings-account-root {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
.database-key-status-card dl,
|
||||||
|
.database-key-diagnostics dl {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.database-key-auto-heading {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.database-key-phases {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.database-key-phases li {
|
||||||
|
padding: 0 0 0 28px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.database-key-phases li::before {
|
||||||
|
top: -2px;
|
||||||
|
left: 0;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.image-decrypt-status dl,
|
||||||
|
.image-key-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.image-decrypt-status .image-decrypt-wide {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
.image-auto-heading {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.image-auto-phases {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.image-auto-phases li {
|
||||||
|
padding: 0 0 0 28px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.image-auto-phases li::before {
|
||||||
|
top: -2px;
|
||||||
|
left: 0;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.image-auto-success {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.image-auto-success button {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: auto;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
.image-resource-checks > div {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.image-resource-checks small {
|
||||||
|
white-space: normal;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.ai-provider-card dl,
|
||||||
|
.ai-provider-form-grid,
|
||||||
|
.ai-provider-advanced > div {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.ai-provider-form-grid .wide,
|
||||||
|
.ai-provider-advanced .wide {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
.ai-model-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.api-center-layout > * {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.api-section-heading h2,
|
||||||
|
.api-introduction h2,
|
||||||
|
.api-integrations h2,
|
||||||
|
.api-runtime-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 700 17px/24px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.ready-text {
|
||||||
|
color: var(--wxex-success);
|
||||||
|
}
|
||||||
|
.api-main {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
}
|
||||||
|
.api-main-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 22px 28px 36px;
|
||||||
|
}
|
||||||
|
.api-workspace-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
.api-title-line {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.api-title-line h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 700 22px/30px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-workspace-heading p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 13px/19px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-skill-status,
|
||||||
|
.api-version {
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font: 700 11px/17px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-skill-status.ready {
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
}
|
||||||
|
.api-skill-status.error {
|
||||||
|
color: var(--wxex-danger);
|
||||||
|
background: #fff1f1;
|
||||||
|
}
|
||||||
|
.api-version {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
background: var(--wxex-bg-app);
|
||||||
|
}
|
||||||
|
.api-header-actions,
|
||||||
|
.api-tester-actions,
|
||||||
|
.api-runtime-actions,
|
||||||
|
.api-skill-file-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.api-header-actions button,
|
||||||
|
.api-tester-actions button,
|
||||||
|
.api-runtime-actions button,
|
||||||
|
.api-endpoint-row button,
|
||||||
|
.api-response-summary + pre + p + button,
|
||||||
|
.api-skill-file-actions button {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-header-actions button:hover,
|
||||||
|
.api-tester-actions button:hover,
|
||||||
|
.api-runtime-actions button:hover,
|
||||||
|
.api-endpoint-row button:hover,
|
||||||
|
.api-skill-file-actions button:hover {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
.api-primary-button {
|
||||||
|
border-color: var(--wxex-brand) !important;
|
||||||
|
background: var(--wxex-brand) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.api-primary-button:hover:not(:disabled),
|
||||||
|
.api-primary-button:focus-visible,
|
||||||
|
.api-primary-button:active {
|
||||||
|
background: var(--wxex-brand-hover) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.api-primary-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.api-trust-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: #f5f8f6;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-trust-bar i {
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--wxex-text-muted);
|
||||||
|
}
|
||||||
|
.api-introduction {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-lg);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
}
|
||||||
|
.api-introduction p {
|
||||||
|
margin: 10px 0 16px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 13px/20px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-flow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-flow span,
|
||||||
|
.api-flow strong {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.api-flow strong {
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
}
|
||||||
|
.api-flow b {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.api-endpoint-catalog,
|
||||||
|
.api-request-tester {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
.skill-install-flow {
|
||||||
|
margin-top: 26px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-lg);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
}
|
||||||
|
.skill-flow-steps {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.skill-flow-steps > section {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 26px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
}
|
||||||
|
.skill-flow-steps > section:not(:last-child)::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 28px;
|
||||||
|
left: 12px;
|
||||||
|
bottom: -16px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--wxex-border);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
.skill-flow-steps > section > b {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
font: 700 12px/1 var(--wxex-font);
|
||||||
|
}
|
||||||
|
.skill-flow-steps > section.active > b,
|
||||||
|
.skill-flow-steps > section.done > b {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.skill-flow-steps h3 {
|
||||||
|
margin: 1px 0 5px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 700 14px/20px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.skill-flow-steps p {
|
||||||
|
margin: 3px 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.skill-flow-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.skill-flow-actions button,
|
||||||
|
.api-skill-details button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.skill-flow-actions button:disabled,
|
||||||
|
.api-skill-details button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.skill-target-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.skill-target-selector button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.skill-target-selector button.active {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
.api-skill-details {
|
||||||
|
margin-top: 16px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
}
|
||||||
|
.api-skill-details summary {
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 700 13px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-skill-details > dl,
|
||||||
|
.api-skill-details > div {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 14px 14px;
|
||||||
|
}
|
||||||
|
.api-skill-details dl div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-skill-details dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
.api-skill-more {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.api-skill-more summary {
|
||||||
|
display: grid;
|
||||||
|
min-height: 32px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px/18px var(--wxex-font);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.api-skill-more[open] > button {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 36px;
|
||||||
|
right: 0;
|
||||||
|
width: 170px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
box-shadow: var(--wxex-shadow-popover);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.api-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.api-section-heading > span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-endpoint-table {
|
||||||
|
margin-top: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
}
|
||||||
|
.api-endpoint-head,
|
||||||
|
.api-endpoint-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(128px, 1.1fr) minmax(150px, 1.2fr) 90px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
}
|
||||||
|
.api-endpoint-head {
|
||||||
|
background: #f1f4f2;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 700 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-endpoint-row {
|
||||||
|
min-height: 54px;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-endpoint-row.active {
|
||||||
|
background: #f7faf8;
|
||||||
|
}
|
||||||
|
.api-endpoint-row code {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font:
|
||||||
|
12px/18px ui-monospace,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
.api-endpoint-row small {
|
||||||
|
display: block;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
}
|
||||||
|
.api-endpoint-row > span:last-child {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.api-endpoint-row button {
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.api-method {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font:
|
||||||
|
700 11px/18px ui-monospace,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
.api-method.post {
|
||||||
|
color: var(--wxex-ai);
|
||||||
|
}
|
||||||
|
.api-request-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: #f5f8f6;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.api-request-meta code {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font:
|
||||||
|
12px/18px ui-monospace,
|
||||||
|
monospace;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.api-param-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.api-param-grid label,
|
||||||
|
.api-json-input {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-param-grid label span b {
|
||||||
|
margin-left: 5px;
|
||||||
|
color: var(--wxex-danger);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.api-param-grid input,
|
||||||
|
.api-json-input textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
outline: none;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-param-grid input {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 9px;
|
||||||
|
}
|
||||||
|
.api-json-input {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.api-json-input textarea {
|
||||||
|
min-height: 180px;
|
||||||
|
padding: 10px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.api-tester-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.api-inline-error {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(200, 90, 90, 0.35);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: #fff4f4;
|
||||||
|
color: var(--wxex-danger);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-runtime-panel {
|
||||||
|
border-left: 1px solid var(--wxex-border);
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.api-runtime-scroll {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.api-runtime-scroll section {
|
||||||
|
padding: 18px 16px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
.api-runtime-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.api-runtime-title > span {
|
||||||
|
font: 700 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-runtime-title .ready {
|
||||||
|
color: var(--wxex-success);
|
||||||
|
}
|
||||||
|
.api-runtime-title .stopped {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
}
|
||||||
|
.api-runtime-scroll h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 700 14px/20px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-runtime-scroll dl {
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
.api-runtime-scroll dl div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 5px 0;
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-runtime-scroll dt {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
}
|
||||||
|
.api-runtime-scroll dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.warning-text {
|
||||||
|
color: var(--wxex-warning);
|
||||||
|
}
|
||||||
|
.api-runtime-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.api-runtime-actions button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
.api-security-warning {
|
||||||
|
margin: 12px 16px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-left: 3px solid var(--wxex-warning);
|
||||||
|
background: #fff8ec;
|
||||||
|
color: #915d1e;
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-response-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font:
|
||||||
|
12px/18px ui-monospace,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
.api-runtime-scroll pre {
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 10px 0 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: #1f2824;
|
||||||
|
color: #cde7dc;
|
||||||
|
font:
|
||||||
|
11px/16px ui-monospace,
|
||||||
|
monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.api-response-meta,
|
||||||
|
.api-empty-text {
|
||||||
|
margin: 0 0 9px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: 11px/17px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-history {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.api-history li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 2px 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
.api-history span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font:
|
||||||
|
11px/17px ui-monospace,
|
||||||
|
monospace;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.api-history b {
|
||||||
|
font: 700 11px/17px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-history small {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: 11px/16px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-privacy {
|
||||||
|
background: #f5f8f6;
|
||||||
|
}
|
||||||
|
.api-privacy p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-markdown-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(32, 39, 36, 0.24);
|
||||||
|
}
|
||||||
|
.api-markdown-overlay > div {
|
||||||
|
display: flex;
|
||||||
|
width: min(820px, 100%);
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-lg);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
.api-markdown-overlay header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.api-markdown-overlay header > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.api-markdown-overlay header span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-markdown-overlay button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.api-markdown-overlay pre,
|
||||||
|
.skill-markdown-preview {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 12px/19px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.skill-markdown-preview h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.skill-markdown-preview h2 {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.skill-markdown-preview p {
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.skill-markdown-preview li {
|
||||||
|
margin-left: 18px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.archive-jump-target-group {
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.archive-jump-message {
|
||||||
|
z-index: 1;
|
||||||
|
animation: archive-jump-flash 0.72s ease-in-out 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes archive-jump-flash {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
22%,
|
||||||
|
58% {
|
||||||
|
filter: drop-shadow(0 0 0.45rem rgba(38, 128, 103, 0.68));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.archive-jump-target-group {
|
||||||
|
.archive-jump-message {
|
||||||
|
outline: 3px solid rgba(38, 128, 103, 0.58);
|
||||||
|
outline-offset: 5px;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
.conversation-sidebar {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border-right: 1px solid var(--wxex-border);
|
||||||
|
background: var(--wxex-bg-sidebar);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sidebar-header {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 16px 14px 12px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sidebar-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-search {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: rgba(36, 122, 99, 0.58);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 13px/18px var(--wxex-font);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-search-icon {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-search-icon svg {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-search-icon circle,
|
||||||
|
.conversation-search-icon path {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-date-range {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-date-range-button {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 30px;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 12px/16px var(--wxex-font);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(36, 122, 99, 0.28);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-virtual-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-virtual-row {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-section + .conversation-section {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-section-header {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px/16px var(--wxex-font);
|
||||||
|
padding: 0 8px;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.48);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-section-chevron {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-section-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-section-list {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-config-scroll > .export-section:not(.export-format-top):has(.export-format-grid) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-section-virtual-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-section-virtual-row {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-section-empty {
|
||||||
|
padding: 10px 12px 12px 28px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 58px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--wxex-font);
|
||||||
|
padding: 8px 10px 8px 13px;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
|
||||||
|
.conversation-item-active-mark {
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item-active-mark {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 0;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 0 999px 999px 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item-avatar {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item-body {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item-name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item-meta {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sidebar-account {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sidebar-account .account-summary {
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-sidebar-account .account-summary-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background-color: var(--sidebar-bg);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* Prevent shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 10px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-status {
|
||||||
|
color: #07c160;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-btn {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #07c160;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
margin-right: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-empty {
|
||||||
|
padding: 10px 14px 12px 27px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--sidebar-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--sidebar-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #ccc;
|
||||||
|
margin-right: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-name {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizer {
|
||||||
|
width: 4px;
|
||||||
|
cursor: col-resize;
|
||||||
|
background-color: transparent;
|
||||||
|
border-right: 1px solid transparent;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizer:hover,
|
||||||
|
.resizer:active {
|
||||||
|
border-color: rgba(36, 122, 99, 0.22);
|
||||||
|
background-color: rgba(36, 122, 99, 0.06);
|
||||||
|
}
|
||||||
@@ -0,0 +1,951 @@
|
|||||||
|
/* Export workspace */
|
||||||
|
.export-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 292px minmax(520px, 1fr) 360px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-contact-panel,
|
||||||
|
.export-config-panel,
|
||||||
|
.export-preview-panel {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-contact-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--wxex-border);
|
||||||
|
background: var(--wxex-bg-sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-panel-header {
|
||||||
|
padding: 20px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-panel-title-row,
|
||||||
|
.export-section-heading,
|
||||||
|
.export-preview-heading,
|
||||||
|
.export-action-bar,
|
||||||
|
.export-target-path,
|
||||||
|
.export-media-master,
|
||||||
|
.export-account-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-panel-title-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-count-badge {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-search-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 13px/20px var(--wxex-font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-filter-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #e3e9e5;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px/30px var(--wxex-font);
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
box-shadow: 0 1px 2px rgba(32, 39, 36, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-contact-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-contact-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
border: 0;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-left-color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-contact-avatar,
|
||||||
|
.export-account-avatar,
|
||||||
|
.export-chat-avatar,
|
||||||
|
.export-preview-avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-contact-avatar {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-contact-copy,
|
||||||
|
.export-account-summary > span:last-child {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-account-summary {
|
||||||
|
small.ready {
|
||||||
|
color: var(--wxex-success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-account-summary {
|
||||||
|
gap: 9px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-account-avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-config-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-config-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px 28px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-config-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-chat-avatar {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 22px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-section-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-range-toggle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px/30px var(--wxex-font);
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
line-height: 36px;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f0f3f0;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 13px/20px var(--wxex-font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-kind-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 4px 20px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f0f3f0;
|
||||||
|
}
|
||||||
|
.export-check-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unsupported {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
input {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-unsupported-hint {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #c57923;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #a85e13;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.export-name-mode-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f0f3f0;
|
||||||
|
}
|
||||||
|
.export-name-mode-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input {
|
||||||
|
accent-color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-media-master {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
accent-color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-media-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 9px 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f0f3f0;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.52;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-resource-statuses {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 9px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-success);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-helper-text {
|
||||||
|
margin: 9px 0 0;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-format-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 78px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border: 2px solid var(--wxex-brand);
|
||||||
|
background: #f2f8f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--wxex-success);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-html-options {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f0f3f0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
accent-color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-save-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: 0;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 13px/20px var(--wxex-font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-target-path {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 11px var(--wxex-font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-action-bar {
|
||||||
|
flex: 0 0 58px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 22px;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.export-ready-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--wxex-success);
|
||||||
|
|
||||||
|
&.completed {
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-target-summary {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.export-reset-button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.export-primary-button {
|
||||||
|
min-width: 132px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: 700 12px var(--wxex-font);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-preview-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border-left: 1px solid var(--wxex-border);
|
||||||
|
background: #fbfcfb;
|
||||||
|
}
|
||||||
|
.export-preview-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 0 0 52px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-message-preview {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px 14px;
|
||||||
|
}
|
||||||
|
.export-preview-date {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #e9eeeb;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.export-preview-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(100%, 620px);
|
||||||
|
margin: 0 auto 15px;
|
||||||
|
|
||||||
|
&.mine {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
|
.export-preview-bubble {
|
||||||
|
background: #95ec69;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.system {
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
.export-preview-avatar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-preview-bubble {
|
||||||
|
max-width: 92%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #e9eeeb;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-preview-avatar {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-preview-bubble {
|
||||||
|
max-width: 78%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
box-shadow: 0 1px 2px rgba(32, 39, 36, 0.05);
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-preview-stats {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-center-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px 11px;
|
||||||
|
font: 600 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-center {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong,
|
||||||
|
small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-progress {
|
||||||
|
position: relative;
|
||||||
|
width: 110px;
|
||||||
|
height: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wxex-border);
|
||||||
|
|
||||||
|
i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
b {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 0;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-preview-panel .export-preview-stats:not(.export-preview-real-stats) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.export-preview-stats span,
|
||||||
|
.export-complete-summary span {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.export-preview-stats strong,
|
||||||
|
.export-complete-summary strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-job-state {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 28px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
content: '';
|
||||||
|
vertical-align: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.done {
|
||||||
|
color: var(--wxex-success);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
border-color: var(--wxex-success);
|
||||||
|
background: var(--wxex-success);
|
||||||
|
content: '✓';
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
box-shadow: inset 0 0 0 4px #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> strong {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-progress-bar {
|
||||||
|
height: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e2e9e4;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.export-job-state > strong {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.export-cancel-button,
|
||||||
|
.export-open-folder-button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.export-success-icon {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
margin: 0 auto 4px;
|
||||||
|
border: 5px solid var(--wxex-brand-soft);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 48px;
|
||||||
|
}
|
||||||
|
.export-complete-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eef3ef;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.export-workspace {
|
||||||
|
grid-template-columns: 248px minmax(460px, 1fr);
|
||||||
|
}
|
||||||
|
.export-preview-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
:root {
|
||||||
|
--bg-color: #f5f5f5;
|
||||||
|
--sidebar-bg: #e7e7e7;
|
||||||
|
--sidebar-hover: #d6d6d6;
|
||||||
|
--sidebar-active: #c6c6c6;
|
||||||
|
--chat-bg: #f5f5f5;
|
||||||
|
--message-bg-user: #95ec69;
|
||||||
|
--message-bg-other: #ffffff;
|
||||||
|
--text-color: #000000;
|
||||||
|
--border-color: #dcdcdc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--wxex-font);
|
||||||
|
background-color: var(--wxex-bg-app);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--wxex-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-primary-rail {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: var(--wxex-nav-width);
|
||||||
|
flex: 0 0 var(--wxex-nav-width);
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
border-right: 1px solid var(--wxex-border);
|
||||||
|
background: var(--wxex-bg-sidebar);
|
||||||
|
padding: 12px 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-navigation {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-nav-item {
|
||||||
|
width: 100%;
|
||||||
|
height: 58px;
|
||||||
|
flex: 0 0 58px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--wxex-font);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.46);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-nav-mark {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
path,
|
||||||
|
circle {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-nav-label {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-rail-account {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-family: var(--wxex-font);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary.compact {
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary-avatar {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary-status {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border: 2px solid var(--wxex-bg-elevated);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wxex-text-muted);
|
||||||
|
|
||||||
|
&.ready {
|
||||||
|
background: var(--wxex-success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary:not(.compact) .account-summary-avatar .account-summary-status {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary-name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary-meta {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary-state {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary-state-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wxex-text-muted);
|
||||||
|
|
||||||
|
&.ready {
|
||||||
|
background: var(--wxex-success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-summary-settings {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-main {
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-top: var(--wxex-shell-content-top);
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.app-page-placeholder-eyebrow {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
@use './_tokens';
|
||||||
|
@use './_base';
|
||||||
|
|
||||||
|
@use './foundation';
|
||||||
|
@use './conversation';
|
||||||
|
@use './chat';
|
||||||
|
@use './rich-message';
|
||||||
|
@use './shell-overlays';
|
||||||
|
@use './reports-core';
|
||||||
|
@use './settings';
|
||||||
|
@use './api';
|
||||||
|
@use './reports-templates';
|
||||||
|
@use './export';
|
||||||
|
@use './search';
|
||||||
|
@use './archive';
|
||||||
|
@use './settings-advanced';
|
||||||
|
@use './settings-preferences';
|
||||||
|
@use './theme';
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
@media (max-width: 1100px) {
|
||||||
|
.api-center-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 280px;
|
||||||
|
}
|
||||||
|
.api-main-scroll {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.api-header-actions {
|
||||||
|
max-width: 260px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.api-endpoint-head,
|
||||||
|
.api-endpoint-row {
|
||||||
|
grid-template-columns: 56px minmax(110px, 1fr) minmax(100px, 1fr) 78px;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.api-flow {
|
||||||
|
overflow-x: auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-list-title {
|
||||||
|
padding: 4px 4px 8px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 700 13px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-group {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-group h3 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 4px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: 700 12px/16px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 54px 10px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-item.active {
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-item.active::before {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-avatar {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font: 700 14px/1 var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-text {
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-text b,
|
||||||
|
.report-history-text small,
|
||||||
|
.report-history-text em {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-text b {
|
||||||
|
color: inherit;
|
||||||
|
font: 600 13px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-text small,
|
||||||
|
.report-history-text em {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: normal 12px/16px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-delete {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 50%;
|
||||||
|
display: grid;
|
||||||
|
width: 38px;
|
||||||
|
height: 26px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(198, 72, 72, 0.22);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: rgba(198, 72, 72, 0.06);
|
||||||
|
color: #a64242;
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px/16px var(--wxex-font);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-item:hover .report-history-delete,
|
||||||
|
.report-history-delete:focus {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-empty {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px 8px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: 13px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-empty button,
|
||||||
|
.report-center-empty button {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--wxex-brand);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 13px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-history-account {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-delete-confirm {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(32, 39, 36, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-delete-confirm-card {
|
||||||
|
display: grid;
|
||||||
|
width: min(360px, calc(100vw - 48px));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-lg);
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
box-shadow: var(--wxex-shadow-popover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-delete-confirm-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 700 18px/24px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-delete-confirm-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 13px/20px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-delete-confirm-card > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-delete-confirm-card button {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 13px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-delete-confirm-card button.danger {
|
||||||
|
border-color: #a64242;
|
||||||
|
background: #a64242;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-delete-error {
|
||||||
|
color: #a64242 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-viewer {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f2f1ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-viewer-header {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 22px 12px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-viewer-header h1 {
|
||||||
|
font: 700 20px/27px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-viewer-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-viewer-toolbar button,
|
||||||
|
.report-zoom-bar button,
|
||||||
|
.report-more-popover button,
|
||||||
|
.report-settings-section button {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 13px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-viewer-toolbar button.primary {
|
||||||
|
border-color: var(--wxex-ai);
|
||||||
|
background: var(--wxex-ai);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-viewer-toolbar button:disabled {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-more-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-more-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 38px;
|
||||||
|
right: 0;
|
||||||
|
z-index: 8;
|
||||||
|
min-width: 128px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
box-shadow: var(--wxex-shadow-popover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-more-popover button {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-viewer-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 8px 22px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
background: var(--wxex-ai-soft);
|
||||||
|
color: var(--wxex-ai);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-viewer-stage {
|
||||||
|
display: block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-canvas {
|
||||||
|
width: max-content;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(32, 39, 36, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-canvas img {
|
||||||
|
display: block;
|
||||||
|
max-width: none;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-zoom-bar {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 48px;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-zoom-bar span {
|
||||||
|
min-width: 48px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-center-empty {
|
||||||
|
display: grid;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
max-width: 360px;
|
||||||
|
gap: 12px;
|
||||||
|
margin: auto;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-center-empty-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
place-items: center;
|
||||||
|
justify-self: center;
|
||||||
|
border: 1px solid rgba(104, 110, 220, 0.18);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--wxex-ai-soft);
|
||||||
|
color: var(--wxex-ai);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-center-empty-icon svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-center-empty-icon path {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-center-empty h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 700 18px/24px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-center-empty p {
|
||||||
|
margin: 0;
|
||||||
|
font: 13px/20px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-center-empty button {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-settings-panel {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-left: 1px solid var(--wxex-border);
|
||||||
|
background: #f7f9f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-settings-panel header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 20px 18px 14px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-settings-panel header h2 {
|
||||||
|
font: 700 16px/22px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-settings-section {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-settings-section h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 700 14px/20px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-settings-section p,
|
||||||
|
.report-settings-section code {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-settings-section code {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-settings-section.muted {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-export-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-export-list div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-export-list b {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-generation-log {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-generation-log li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-generation-log li > span {
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-generation-log b,
|
||||||
|
.report-generation-log time,
|
||||||
|
.report-generation-log small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-generation-log b {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-generation-log time {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-generation-log small {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 600 11px/16px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.report-center-page {
|
||||||
|
grid-template-columns: 268px minmax(360px, 1fr) 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-viewer-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================ */
|
||||||
|
/* 日报模板选择器 + 预览器 */
|
||||||
|
/* ============================================================ */
|
||||||
|
.report-section-desc {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1.5px solid var(--wxex-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-item.active {
|
||||||
|
border-color: var(--wxex-primary, #07c160);
|
||||||
|
background: #f0fbf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-item.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-item > label {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-item input[type='radio'] {
|
||||||
|
accent-color: var(--wxex-primary, #07c160);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--wxex-text-primary, #1f2933);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-tagline {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wxex-text-secondary, #485465);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-preview-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-primary, #1f2933);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
color 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-preview-btn:hover {
|
||||||
|
background: var(--wxex-primary, #07c160);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--wxex-primary, #07c160);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预览遮罩 */
|
||||||
|
.report-template-preview-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-preview-card {
|
||||||
|
background: #f3f5f7;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
width: min(380px, 100%);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 16px 60px rgba(15, 23, 42, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-preview-card h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-preview-card p.muted {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wxex-text-secondary, #485465);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-preview-frame {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fake-card {
|
||||||
|
background: #f7faf9;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fake-hero {
|
||||||
|
background: linear-gradient(135deg, #edf9f1 0%, #f7fbf8 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fake-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #076c39;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fake-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #485465;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fake-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fake-bar {
|
||||||
|
width: 4px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--wxex-primary, #07c160);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fake-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-template-preview-close {
|
||||||
|
margin-top: 14px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: var(--wxex-primary, #07c160);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-auto-login-card {
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.settings-auto-login-card label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.settings-auto-login-card label > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.settings-auto-login-card b {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 700 13px/19px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.settings-auto-login-card small {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.settings-auto-login-card input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
.settings-recall-card {
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.settings-recall-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.8fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.settings-recall-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.settings-recall-option span {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.settings-recall-option b {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font: 700 13px/19px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.settings-recall-option small {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.settings-recall-option input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
accent-color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
.settings-recall-notice {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding-left: 14px;
|
||||||
|
border-left: 2px solid #e5b45f;
|
||||||
|
color: #765326;
|
||||||
|
font: 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.settings-recall-notice strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.settings-recall-notice span {
|
||||||
|
color: #8a6a3e;
|
||||||
|
}
|
||||||
|
.api-upload-test-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.api-upload-test-row button {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: 600 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
.api-upload-test-row span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font: 11px/17px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
/* Voice Player */
|
||||||
|
.voice-message {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-error {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-loading-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-error-text {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-duration {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-icon.playing {
|
||||||
|
background: rgba(36, 122, 99, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rich Message Bubbles */
|
||||||
|
.location-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 280px;
|
||||||
|
|
||||||
|
.location-icon {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 34px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-info {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-name {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-coords {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-message-row.mine {
|
||||||
|
.location-message {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-nickname {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-username {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #07c160;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-appname {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-title {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-url {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-message {
|
||||||
|
width: min(280px, 48vw);
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-description {
|
||||||
|
margin: -4px 0 8px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-preview {
|
||||||
|
.image-bubble,
|
||||||
|
.image-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-content {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-content.image-fallback {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 28px auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-inline-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-icon {
|
||||||
|
display: block;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-program-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 7px;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-message {
|
||||||
|
width: min(280px, 48vw);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fa9d3b;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 36px;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid rgba(255, 239, 170, 0.92);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff1a8;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-copy strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 21px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-copy small {
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-footer {
|
||||||
|
padding: 5px 14px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voip-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voip-icon {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voip-status {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||