Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08e1294e5d | ||
|
|
224308f0e0 | ||
|
|
3e57a8432d | ||
|
|
b4f909a597 | ||
|
|
ee7dc11e92 | ||
|
|
90bf1aed90 | ||
|
|
a3955d691d | ||
|
|
8a6d443acc | ||
|
|
f0601cdc85 | ||
|
|
77adc744e0 | ||
|
|
0adb064681 | ||
|
|
8e40487e08 |
@@ -22,3 +22,7 @@ VITE_FILTER_MSG_TYPES=
|
||||
# AES Key: 16-character string, derived from wxid and code
|
||||
VITE_IMAGE_XOR_KEY=
|
||||
VITE_IMAGE_AES_KEY=
|
||||
|
||||
# Electron E2E test window close delay in milliseconds.
|
||||
# Local default: 2000 (2 seconds). Set to 0 for immediate close.
|
||||
WXE_E2E_CLOSE_DELAY_MS=2000
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
desktop-tests:
|
||||
name: ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 7.33.7
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: services/wechat-connector/go.mod
|
||||
cache-dependency-path: services/wechat-connector/go.sum
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Component tests
|
||||
run: pnpm test:component
|
||||
|
||||
- name: IPC integration tests
|
||||
run: pnpm test:integration
|
||||
|
||||
- name: Skill installation instruction tests
|
||||
run: pnpm test:skill-install
|
||||
|
||||
- name: WeChat connector tests
|
||||
run: pnpm test:wechat-connector
|
||||
|
||||
- name: Build Electron test application
|
||||
run: pnpm test:e2e:build
|
||||
|
||||
- name: Electron E2E tests
|
||||
run: pnpm exec playwright test --grep-invert @visual
|
||||
env:
|
||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||
|
||||
- name: Platform visual regression
|
||||
run: pnpm exec playwright test tests/e2e/visual.spec.ts
|
||||
env:
|
||||
WXE_E2E_CLOSE_DELAY_MS: 0
|
||||
|
||||
- name: Upload Playwright diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-${{ matrix.os }}
|
||||
path: |
|
||||
test-results/
|
||||
playwright-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -6,6 +6,9 @@ out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
resources/connectors/wechat/
|
||||
.omc
|
||||
.codex/
|
||||
|
||||
@@ -1,140 +1,318 @@
|
||||
# WechatExplorer
|
||||
|
||||
macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
||||
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
|
||||
<p align="center">
|
||||
<img src="./build/icon.png" width="120" alt="WechatExplorer Logo" />
|
||||
</p>
|
||||
|
||||
## 项目说明
|
||||
<h2 align="center">让 AI 读懂你的微信</h2>
|
||||
|
||||
本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。
|
||||
<p align="center">
|
||||
本地优先的 AI 微信助手<br />
|
||||
聊天记录查看 · AI 问问微信 · 群聊日报 · Agent · 本地 API
|
||||
</p>
|
||||
|
||||
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/stars/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="GitHub stars" />
|
||||
<img src="https://img.shields.io/github/downloads/Wxw-Gu/WechatExplorer/total?style=for-the-badge" alt="GitHub downloads" />
|
||||
<img src="https://img.shields.io/github/v/release/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="Latest release" />
|
||||
</p>
|
||||
|
||||
> macOS 支持相对稳定;Windows 已初步支持 但因聊天记录大/机械硬盘等问题 会有所卡顿,仍在持续兼容不同微信版本与本地目录结构。
|
||||
<p align="center">
|
||||
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>📦 下载最新版</b></a>
|
||||
·
|
||||
<a href="./docs/user-guide/getting-started.md"><b>🚀 第一次使用</b></a>
|
||||
·
|
||||
<a href="./docs/user-guide/getting-started.md#遇到问题"><b>📖 使用说明</b></a>
|
||||
</p>
|
||||
|
||||
## ✨ 功能特性
|
||||
> ⭐ 如果这个项目帮助到了你,欢迎点一个 Star,支持项目持续更新。
|
||||
|
||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
||||
- **全局搜索**: 快速搜索聊天内容。
|
||||
- **消息防撤回**: 高亮查看对方已撤回消息
|
||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||
- **一句话速览**: 首屏突出今日核心结论与待跟进事项。
|
||||
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
||||
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
||||
- **有趣对话或金句**: 收录群内的精彩对话。
|
||||
- **问题与解答**: 整理群内的问答内容。
|
||||
- **尚未解决 / 今日剧情线**: 更适合工作群和项目群的回顾与跟进。
|
||||
- **今日群相册 / 语音时长榜 / 临时群友称号**: 让图片、语音和氛围型内容也能参与日报。
|
||||
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||
- **词云/关键词**: 可视化展示群聊关键词。
|
||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
|
||||
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
||||
<p align="center">
|
||||
<img src="./public/software-1.png" alt="WechatExplorer AI 微信助手界面" />
|
||||
</p>
|
||||
|
||||
## 📸 预览
|
||||
> 像问 ChatGPT 一样,直接询问你的微信聊天记录。
|
||||
|
||||
### 日报模板
|
||||
WechatExplorer 是一个基于 Electron + React + TypeScript 开发的本地优先 AI 微信助手。它不只是查看聊天记录,而是把聊天内容变成可以搜索、总结、分析和交给 Agent 使用的信息。
|
||||
|
||||
**支持:**
|
||||
|
||||
微信聊天记录查看、AI 微信助手、AI 群聊日报、MCP、Agent、本地 API
|
||||
|
||||
## ✨ 为什么选择 WechatExplorer?
|
||||
|
||||
- ✅ **像 ChatGPT 一样搜索整个微信**:用自然语言提问,快速找到聊天上下文。
|
||||
- ✅ **AI 自动生成群聊日报**:自动整理热点、资源、问答和待跟进事项。
|
||||
- ✅ **Agent 可直接读取微信聊天**:支持 Codex、Claude Code、MCP 等 AI 工作流。
|
||||
- ✅ **本地数据库优先**:聊天数据默认保存在本机,不会自动上传。
|
||||
- ✅ **支持微信 3.x / 4.x**:不同微信版本提供对应版本支持。
|
||||
- ✅ **多格式导出**:支持 HTML、Markdown、CSV 和 JSON。
|
||||
|
||||
## 🚀 第一次使用
|
||||
|
||||
软件已经内置完整的新手引导,通常按下面三步即可开始:
|
||||
|
||||
```text
|
||||
下载软件
|
||||
↓
|
||||
连接微信
|
||||
↓
|
||||
开始问你的微信
|
||||
```
|
||||
|
||||
首次启动会自动进入「第一次使用」页面。连接成功后,软件会显示「开始探索你的微信」;进入主界面后,还可以随时点击左下角「新手引导」重新查看。
|
||||
|
||||
## 📸 功能预览
|
||||
|
||||
### AI 群聊日报
|
||||
|
||||
<details>
|
||||
<summary>点击查看完整日报模板</summary>
|
||||
<br />
|
||||
<img src="./public/report-template-1.png" alt="完整日报模板" />
|
||||
<img src="./public/report-template-1.png" alt="完整群聊日报模板" />
|
||||
</details>
|
||||
|
||||
### AI 群聊日报界面
|
||||
### AI 问问微信
|
||||
|
||||
<img src="./public/software-1.png" alt="AI 群聊日报页面" />
|
||||
<img src="./public/ai-search.png" alt="AI 问问微信页面" />
|
||||
|
||||
### 本地 API 与 Reader Skill
|
||||
### 本地 API 与 Agent
|
||||
|
||||
<img src="./public/software-2.png" alt="本地 API 与 Reader Skill 页面" />
|
||||
<img src="./public/software-2.png" alt="本地 API 与 Agent 页面" />
|
||||
|
||||
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||
## 🎯 它能帮你做什么
|
||||
|
||||
## 📖 使用方法
|
||||
### 🤖 AI 问问微信
|
||||
|
||||
安装、获取数据库密钥、连接微信数据及常见问题,请查看:
|
||||
直接向自己的微信提问:
|
||||
|
||||
### [👉 WechatExplorer 完整使用教程](./docs/user-guide/getting-started.md)
|
||||
> “去年我和老板聊过哪些关于涨薪的事情?”
|
||||
>
|
||||
> “技术群这周讨论了哪些问题?”
|
||||
>
|
||||
> “帮我找到张三发过的项目地址。”
|
||||
|
||||
教程包含 macOS 与 Windows 的分步截图,以及数据目录、SIP、图片解密密钥和自动获取失败的排查方法。
|
||||
### 📰 AI 群聊日报
|
||||
|
||||
> 微信 4.0+ 在 macOS / Windows 上已支持部分能力,目前仍在持续适配。如需其他成熟方案,也可参考 [WeFlow](https://github.com/hicccc77/WeFlow) 和 [Chatlog](https://github.com/sjzar/chatlog)。
|
||||
选择一个群聊和时间范围,自动生成:
|
||||
|
||||
## 🛠️ 开发配置(可选)
|
||||
- ✅ 今日热点
|
||||
- ✅ 一句话总结
|
||||
- ✅ 资源汇总
|
||||
- ✅ 问答整理
|
||||
- ✅ 活跃榜
|
||||
- ✅ 词云与关键词
|
||||
|
||||
本地开发需要 Node.js(推荐 v16+)和 pnpm 7。
|
||||
<details>
|
||||
<summary>展开查看日报的完整模块</summary>
|
||||
|
||||
### 环境变量
|
||||
- **今日讨论热点**:梳理群内主要话题,支持热度标签。
|
||||
- **一句话速览**:首屏突出今日核心结论与待跟进事项。
|
||||
- **实用信息与资源**:提取分享的链接、资源等信息。
|
||||
- **重要消息汇总**:标记并展示重要消息,带发送者头像。
|
||||
- **有趣对话或金句**:收录群内的精彩对话。
|
||||
- **问题与解答**:整理群内的问答内容。
|
||||
- **尚未解决 / 今日剧情线**:适合工作群和项目群的回顾与跟进。
|
||||
- **今日群相册 / 语音时长榜 / 临时群友称号**:让图片、语音和氛围型内容也能参与日报。
|
||||
- **群内数据可视化**:消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||
- **词云 / 关键词**:可视化展示群聊关键词。
|
||||
</details>
|
||||
|
||||
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
|
||||
支持导出 HTML 与 PNG,也支持图片理解和图片生成。
|
||||
|
||||
| 变量名 | 说明 | 示例 |
|
||||
| ----------------------- | --------------------------- | --------------------------- |
|
||||
| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` |
|
||||
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` |
|
||||
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` |
|
||||
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
|
||||
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
|
||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||
### 📂 查看聊天
|
||||
|
||||
## 🤖 AI 集成(本地 HTTP API)
|
||||
浏览微信好友和群聊的聊天记录,支持查看:
|
||||
|
||||
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
|
||||
- 文本
|
||||
- 图片
|
||||
- 视频
|
||||
- 语音
|
||||
- 文件
|
||||
|
||||
同时支持头像显示、全局搜索、指定会话搜索、消息防撤回和上下文定位。
|
||||
|
||||
### 📤 导出聊天
|
||||
|
||||
支持按会话和时间范围导出聊天记录为 HTML、CSV、JSON 或 Markdown,并可以打开文件所在文件夹。
|
||||
|
||||
### 🤖 Agent
|
||||
|
||||
通过本地 HTTP API 和内置 Reader Skill,让 Codex、Claude Code 等 Agent 在本机服务运行并获得授权后读取、总结聊天数据。
|
||||
|
||||
## 🚀 规划与未来(Roadmap)
|
||||
|
||||
WechatExplorer 仍在持续演进,未来会围绕 **AI 大模型 + 微信 + Agent** 持续完善能力。
|
||||
|
||||
下面是正在设计或计划中的部分功能(不代表发布时间)。
|
||||
|
||||
<details>
|
||||
<summary>点击展开未来规划</summary>
|
||||
|
||||
### 🚧 人物镜像(Persona)
|
||||
|
||||
根据长期聊天记录生成每个人的沟通画像:
|
||||
|
||||
- 兴趣标签
|
||||
- 常聊话题
|
||||
- 表达风格
|
||||
- 个性化沟通参考
|
||||
|
||||
### 🚧 AI 长期记忆
|
||||
|
||||
让 AI 持续理解你的聊天历史,在不同时间跨度内建立上下文,支持长期事项追踪和连续对话。
|
||||
|
||||
### 🚧 微信卡片分享
|
||||
|
||||
将 AI 日报生成可点击的微信卡片消息,而不仅仅是图片,方便在群聊中传播与查看。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/微信卡片分享.png" alt="微信卡片分享示例" width="520" />
|
||||
</p>
|
||||
|
||||
### 🚧 退群自动监控
|
||||
|
||||
自动记录群聊成员变动:
|
||||
|
||||
- 谁加入群聊
|
||||
- 谁退出群聊
|
||||
- 变动发生的时间
|
||||
- 群成员变动记录
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/退群监控.png" alt="退群自动监控示例" width="720" />
|
||||
</p>
|
||||
|
||||
### 💡 更多 AI 能力
|
||||
|
||||
包括会议纪要、聊天知识库、长期事项追踪、个人成长分析等更多探索。
|
||||
|
||||
WechatExplorer 希望不仅仅是一个聊天记录查看工具,更希望成为一个能够理解、整理和协助管理微信信息的 AI 工作平台。
|
||||
|
||||
如果你有好的想法,欢迎提交 Issue 或 Pull Request,一起把它做得更好。
|
||||
|
||||
</details>
|
||||
|
||||
## ⚙️ 快速开始
|
||||
|
||||
### 下载并安装
|
||||
|
||||
当前 WechatExplorer / 迹忆版本:`v2.1.6`。
|
||||
|
||||
应用安装包:[WechatExplorer GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`,macOS 按处理器架构选择对应 `.dmg`。
|
||||
|
||||
| 系统 | 已测试的微信客户端 |
|
||||
| ------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) |
|
||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) |
|
||||
|
||||
微信客户端来自上表对应的第三方版本存档,请自行核对来源与文件完整性。
|
||||
|
||||
正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录;但应用缓存和本地设置可能随版本升级变化。升级前仍建议使用微信官方迁移或备份功能备份重要记录,不要将唯一副本保存在单一设备。
|
||||
|
||||
### 连接微信
|
||||
|
||||
按照软件内置的「第一次使用」引导完成连接:
|
||||
|
||||
1. 确认微信数据目录。
|
||||
2. 让微信停在登录页面。
|
||||
3. 点击“开始获取”,按提示完成连接。
|
||||
|
||||
Windows 已完整支持,不需要关闭 SIP。macOS 首次自动获取数据库密钥前,需要关闭 SIP 并完成系统授权。
|
||||
|
||||
### 配置 AI
|
||||
|
||||
进入「设置 → AI 模型」,添加模型服务商并填写 API Key,保存并测试成功后即可使用「问问微信」和「日报」。支持:
|
||||
|
||||
- OpenAI
|
||||
- DeepSeek
|
||||
- Claude
|
||||
- Moonshot
|
||||
- OpenAI 兼容接口
|
||||
|
||||
### 下一步
|
||||
|
||||
| 你想做什么 | 从哪里开始 |
|
||||
| ----------------- | ---------------------------------------------------------------- |
|
||||
| 重新查看连接步骤 | 点击左下角「新手引导」 |
|
||||
| 直接向微信提问 | 打开「问问微信」 |
|
||||
| 生成群聊日报 | 打开「日报」 |
|
||||
| 浏览聊天记录 | 打开「档案」 |
|
||||
| 导出聊天记录 | 打开「导出」 |
|
||||
| 让 Agent 读取微信 | [Reader Skill 文档](./docs/skill/wechatexplorer-reader/SKILL.md) |
|
||||
|
||||
## 🖥️ 支持平台与微信版本
|
||||
|
||||
- **Windows**:已完整支持 Windows x64,不需要关闭 SIP。
|
||||
- **macOS**:支持 Intel 和 Apple Silicon;首次自动获取数据库密钥前,需要关闭 SIP 并完成系统授权。
|
||||
- **微信 3.0**:请使用 [v1.1.0 版本](https://github.com/Wxw-Gu/WechatExplorer/releases/tag/v1.1.0)。
|
||||
- **微信 4.0**:使用当前 Releases 中的最新版。
|
||||
|
||||
不同微信版本、账号和数据目录可能存在差异,遇到连接问题时请优先参考 [使用说明](./docs/user-guide/getting-started.md)。
|
||||
|
||||
## 🔒 隐私与权限
|
||||
|
||||
- WechatExplorer 只读取你有权访问的本机微信数据。
|
||||
- 不使用 AI 时,应用不会因为读取聊天记录而自动上传聊天内容。
|
||||
- 使用 AI 问问微信、日报或图片理解时,相关内容会发送到你配置的模型服务。
|
||||
- 本地 API 默认监听 `127.0.0.1`,无鉴权;请按可信网络范围配置。
|
||||
- 消息防撤回、图片解密和数据库密钥等能力都应只用于你有权访问的数据。
|
||||
|
||||
## 🔌 高级能力:本地 HTTP API 与 Agent
|
||||
|
||||
<details>
|
||||
<summary>展开本地 HTTP API、Reader Skill 和 Agent 说明</summary>
|
||||
|
||||
WechatExplorer 内置一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`,纯本地、无鉴权。完成数据库连接后,API 会自动启用。
|
||||
|
||||
### 启用本地 API
|
||||
|
||||
API 服务在 WechatExplorer 启动时自动启用,**不需要任何配置**。只需要:
|
||||
|
||||
1. 安装并启动 WechatExplorer
|
||||
2. 完成首次密钥配置(主窗口第一步),解锁 WCDB 数据库
|
||||
3. API 即在 `http://127.0.0.1:6131` 可用
|
||||
1. 安装并启动 WechatExplorer。
|
||||
2. 完成首次密钥配置,解锁 WCDB 数据库。
|
||||
3. 在 `http://127.0.0.1:6131` 使用本地 API。
|
||||
|
||||
### 7×24 提供 API(菜单栏常驻模式)
|
||||
|
||||
默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,启用菜单栏模式:
|
||||
默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,可以启用菜单栏模式:
|
||||
|
||||
```bash
|
||||
# 任选一种方式
|
||||
WXE_TRAY=1 open /Applications/WechatExplorer.app
|
||||
/Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray
|
||||
```
|
||||
|
||||
启用后:
|
||||
|
||||
- macOS dock 图标自动隐藏
|
||||
- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态)
|
||||
- 主窗口关闭后 API 服务继续运行
|
||||
- macOS Dock 图标自动隐藏。
|
||||
- 菜单栏出现 WechatExplorer 图标,可重新打开主窗口、查看 API 状态。
|
||||
- 主窗口关闭后 API 服务继续运行。
|
||||
|
||||
### API 端点一览
|
||||
|
||||
| 端点 | 说明 |
|
||||
| ------------------------------------------------ | --------------------------------------- |
|
||||
| `GET /api/v1/health` | 健康检查 |
|
||||
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) |
|
||||
| `GET /api/v1/current_time` | 获取当前本地时间,用于“今天 / 昨天”换算 |
|
||||
| `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 |
|
||||
| `GET /api/v1/chatroom?keyword=xxx` | 搜索群聊 |
|
||||
| `GET /api/v1/chatlog?talker=xxx&time=2026-07-03` | 聊天记录 |
|
||||
| `GET /api/v1/group_snapshot?md5=xxx` | 群成员快照 |
|
||||
| `GET /api/v1/resolve?q=群昵称` | 把昵称/wxid/md5 解析成 md5 |
|
||||
| `GET /api/v1/resolve?q=群昵称` | 把昵称、wxid 或 md5 解析成 md5 |
|
||||
|
||||
详细参数、返回结构、时间格式见 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
详细参数、返回结构和时间格式见 [Reader Skill 文档](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
|
||||
### 安装 Reader Skill,让 Agent 读取和总结群聊
|
||||
|
||||
WechatExplorer 已内置 **Reader Skill**,无需手动复制仓库中的 `SKILL.md`:
|
||||
WechatExplorer 已内置 Reader Skill,无需手动复制仓库中的 `SKILL.md`:
|
||||
|
||||
1. 启动 WechatExplorer,并确认数据库已连接、本地 API 已运行。
|
||||
2. 打开应用内的 **API** 页面。
|
||||
3. 在“快速接入”中选择 **Codex** 或 **Claude Code**。
|
||||
4. 点击复制安装指令,将指令粘贴给对应的 Agent 执行。
|
||||
2. 打开应用内的「API」页面。
|
||||
3. 在“快速接入”中选择 Codex 或 Claude Code。
|
||||
4. 点击复制安装指令,将指令粘贴给对应 Agent 执行。
|
||||
5. 安装完成后,可以直接向 Agent 提问:
|
||||
|
||||
> “今天技术交流群聊了什么?”
|
||||
|
||||
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。详细接口说明仍可查看 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||
Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊天记录,并结合上下文生成总结。
|
||||
|
||||
### curl 调试示例(可选)
|
||||
|
||||
@@ -144,7 +322,7 @@ Reader Skill 会自动获取本机时间、定位目标群聊、读取所需聊
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:6131/api/v1/health
|
||||
|
||||
# 今天 摸鱼交流群 的聊天记录
|
||||
# 今天“摸鱼交流群”的聊天记录
|
||||
curl -G "http://127.0.0.1:6131/api/v1/chatlog" \
|
||||
--data-urlencode "talker=摸鱼交流群" \
|
||||
--data-urlencode "time=$(date +%Y-%m-%d)"
|
||||
@@ -154,11 +332,73 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
||||
--data-urlencode "q=摸鱼交流群"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🛠️ 开发配置(可选)
|
||||
|
||||
<details>
|
||||
<summary>展开开发配置、环境变量和构建命令</summary>
|
||||
|
||||
本地开发需要 Node.js(建议当前 LTS)和 pnpm 7+:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户不需要配置 `.env`,也可以直接在软件“设置”里填写 AI 和图片解密配置。
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量名 | 说明 | 示例 |
|
||||
| ----------------------- | ----------------------------- | --------------------------- |
|
||||
| `VITE_DB_KEY` | 微信数据库密钥(32 字节 hex) | `YOUR_DB_KEY_HERE` |
|
||||
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥(hex 格式) | `0x40` |
|
||||
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥(16 字符) | `YOUR_AES_KEY_HERE` |
|
||||
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
|
||||
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
|
||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
pnpm typecheck # 类型检查
|
||||
pnpm lint # ESLint 检查
|
||||
pnpm build # 构建
|
||||
pnpm build:win # 构建 Windows x64 安装包
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
<details>
|
||||
<summary>展开常见问题</summary>
|
||||
|
||||
### 我已经连接成功,怎么重新查看教程?
|
||||
|
||||
点击左下角「新手引导」。首次连接流程、AI 配置入口、群聊日报、问问微信和完整教程都会再次展示。
|
||||
|
||||
### 微信 3.0 应该下载哪个版本?
|
||||
|
||||
请使用 [v1.1.0 版本](https://github.com/Wxw-Gu/WechatExplorer/releases/tag/v1.1.0)。微信 4.0 用户使用当前 Releases 中的最新版。
|
||||
|
||||
### AI 问问微信或群聊日报不可用怎么办?
|
||||
|
||||
进入「设置 → AI 模型」,添加模型服务商并填写 API Key,确认 Base URL 和模型名称正确,然后保存并测试连接。
|
||||
|
||||
### 连接失败怎么办?
|
||||
|
||||
请先查看 [使用说明](./docs/user-guide/getting-started.md) 的“遇到问题”部分,重点确认微信数据目录、微信登录状态、微信版本和 macOS SIP 设置。
|
||||
|
||||
</details>
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议,并仅处理你有权访问的数据。
|
||||
|
||||
## Star History
|
||||
## ⭐ Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Wxw-Gu%2FWechatExplorer&type=date&legend=top-left">
|
||||
<picture>
|
||||
@@ -168,14 +408,41 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 🔗 参考致谢
|
||||
## 致谢
|
||||
|
||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
||||
- [chatlog](https://github.com/sjzar/chatlog)
|
||||
<details>
|
||||
<summary>展开致谢与参考项目</summary>
|
||||
|
||||
## 📱 交流与反馈
|
||||
WechatExplorer 在开发过程中参考了多个优秀的开源项目,感谢这些项目作者的工作与分享。
|
||||
|
||||
特别感谢:
|
||||
|
||||
- **[WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)**
|
||||
- 提供了微信数据库解析相关思路。
|
||||
- **[WeFlow](https://github.com/hicccc77/WeFlow)**
|
||||
- 参考了数据库密钥获取、图片解密等实现思路。
|
||||
- **[chatlog](https://github.com/sjzar/chatlog)**
|
||||
- 提供了聊天记录导出与数据处理方面的参考。
|
||||
|
||||
在此基础上,WechatExplorer 进行了重新设计与实现,包括:
|
||||
|
||||
- AI 问问微信
|
||||
- AI 群聊日报
|
||||
- 本地 HTTP API
|
||||
- Reader Skill
|
||||
- Agent Hub
|
||||
- 新手引导
|
||||
- Electron + React 全新界面
|
||||
- 本地优先 AI 工作流
|
||||
|
||||
感谢所有开源作者。
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 交流与反馈
|
||||
|
||||
请先完成 [第一次使用与问题排查](./docs/user-guide/getting-started.md),再查看问题排查和 FAQ。只有自助排查仍无法解决时,再扫码进入交流群。
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/二维码.jpg" alt="WechatExplorer 交流二维码" width="280" />
|
||||
<img src="./public/二维码.jpg" alt="WechatExplorer 交流与售后群二维码" width="280" />
|
||||
</p>
|
||||
|
||||
@@ -1,121 +1,250 @@
|
||||
# WechatExplorer 使用教程
|
||||
# WechatExplorer:第一次使用与问题排查
|
||||
|
||||
本文介绍如何安装 WechatExplorer、自动获取微信数据库密钥,并完成首次连接。
|
||||
这份说明解决三件事:第一次连接微信、连接成功后如何开始使用,以及遇到问题时如何自助排查。
|
||||
|
||||
## 1. 使用前准备
|
||||
如果你已经进入软件,忘记了连接步骤,可以直接点击左下角「新手引导」,重新查看首次连接流程、AI 配置入口和群聊日报入口。
|
||||
|
||||
### 支持的版本
|
||||
## 你现在要做什么
|
||||
|
||||
| 系统 | 已测试的微信版本 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| macOS | `4.1.8.100` | 支持相对稳定;自动获取密钥前需要关闭 SIP |
|
||||
| Windows | `4.1.9.57` | 已初步支持;不同安装路径和数据目录可能仍需手动调整 |
|
||||
- [我第一次使用,想连接微信](#第一次连接微信)
|
||||
- [我已经连接成功,下一步做什么](#连接成功后做什么)
|
||||
- [我想重新查看引导](#重新查看新手引导)
|
||||
- [我想配置 AI](#配置-ai)
|
||||
- [我遇到问题](#遇到问题)
|
||||
- [我想让 Agent 读取微信](#接入-api-reader-skill-或-agent)
|
||||
|
||||
- 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)
|
||||
> 正常覆盖安装只会替换应用程序文件,WechatExplorer / 迹忆不会主动删除或修改微信原始聊天记录。应用缓存和本地设置可能随版本升级发生变化。系统故障、磁盘异常、误操作和微信自身迁移不受本应用控制,因此升级前仍建议使用微信官方迁移或备份功能备份重要聊天记录,不要将唯一副本保存在单一设备。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> WechatExplorer 必须取得本机微信数据库密钥才能读取聊天记录。请仅处理你有权访问的数据。
|
||||
## 开始前确认
|
||||
|
||||
### macOS:关闭 SIP
|
||||
| 系统 | 已测试的微信客户端 | 需要注意 |
|
||||
| ------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| macOS | [微信 macOS `4.1.8.100`](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100) | 自动获取数据库密钥前需要关闭 SIP 并完成授权 |
|
||||
| Windows | [微信 Windows `4.1.9.57`](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57) | 已完整支持;首次使用时请确认微信数据目录 |
|
||||
|
||||
macOS 自动获取密钥前需要关闭 SIP,具体操作见 [macOS 关闭 SIP 教程](../mac-disable-sip.md)。
|
||||
- WechatExplorer 当前面向微信 4.0 数据结构。
|
||||
- Windows 不需要关闭 SIP。
|
||||
- macOS 首次自动获取数据库密钥需要按页面提示完成系统授权。
|
||||
- WechatExplorer 必须取得当前微信账号对应的数据库密钥才能读取聊天记录。
|
||||
- 请只处理你有权访问的微信数据。
|
||||
|
||||
关闭 SIP 会降低系统安全性。建议了解风险后再操作,并在不再需要自动获取密钥时重新开启。
|
||||
WechatExplorer / 迹忆应用安装包:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)。Windows 选择 `-setup.exe`,macOS 按处理器架构选择对应 `.dmg`。微信客户端请使用上方“已测试的微信客户端”链接。
|
||||
|
||||
## 2. 安装 WechatExplorer
|
||||
## 第一次连接微信
|
||||
|
||||
### macOS
|
||||
### 1. 安装 WechatExplorer
|
||||
|
||||
#### Windows
|
||||
|
||||
1. 从 Releases 下载 Windows `-setup.exe` 安装包。
|
||||
2. 双击安装包,按向导完成安装。
|
||||
3. 启动 WechatExplorer。
|
||||
|
||||
#### macOS
|
||||
|
||||
1. 从 Releases 下载 `.dmg` 文件。
|
||||
2. 打开 DMG,将 WechatExplorer 拖入“应用程序”文件夹。
|
||||
3. 如果系统提示“无法打开,因为开发者无法验证”,请前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
4. 如果系统提示应用已损坏,在终端执行:
|
||||
3. 如果系统提示“无法打开,因为开发者无法验证”,前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
4. 如果系统提示应用已损坏,可在终端执行:
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/WechatExplorer.app"
|
||||
```
|
||||
|
||||
### Windows
|
||||
5. 如果这是第一次在 macOS 上自动获取数据库密钥,先完成 [关闭 SIP 教程](../mac-disable-sip.md)。关闭 SIP 会降低系统安全性,完成密钥配置后建议重新开启。
|
||||
|
||||
1. 从 Releases 下载 `-setup.exe` 安装包。
|
||||
2. 双击安装,并按安装向导完成操作。
|
||||
### 2. 按软件内引导连接微信
|
||||
|
||||
## 3. 自动获取密钥
|
||||
首次启动会自动进入「第一次使用」页面。页面会根据当前系统显示连接方式和注意事项:
|
||||
|
||||
### 第一步:确认微信数据目录
|
||||
<p align="center">
|
||||
<img src="../../public/setup-page.png" alt="第一次使用连接页面" width="820" />
|
||||
</p>
|
||||
|
||||
启动 WechatExplorer 后,先检查页面中的“存储路径”是否正确。
|
||||
通常按下面三步操作即可:
|
||||
|
||||

|
||||
1. **确认微信数据目录**:自动识别不准确时,在页面中修改存储路径。
|
||||
2. **让微信停在登录页面**:如果微信已经登录,先退出微信登录,不只是关闭窗口。
|
||||
3. **点击开始获取**:软件会尝试获取数据库密钥。按提示可以登录后,再回到微信完成登录。
|
||||
|
||||
Windows 当前不会扫描二级目录。如果没有正确识别微信数据,请进入“设置”,手动选择微信数据所在目录。
|
||||
Windows 已完整支持,不需要关闭 SIP。macOS 首次获取密钥前,需要按页面提示完成授权并关闭 SIP。
|
||||
|
||||

|
||||
### 3. 连接成功
|
||||
|
||||
### 第二步:让微信停留在登录页面
|
||||
连接成功后,软件会进入聊天档案,并显示「开始探索你的微信」引导:
|
||||
|
||||
如果微信已经登录,请先退出登录;然后重新打开微信,让它停留在未登录页面,暂时不要点击登录。
|
||||
<p align="center">
|
||||
<img src="../../public/first-use-welcome.png" alt="连接成功后的新手引导" width="760" />
|
||||
</p>
|
||||
|
||||

|
||||
这里推荐先体验「AI 群聊日报」,也可以直接查看聊天、问问微信或配置 AI 模型。
|
||||
|
||||
### 第三步:开始获取密钥
|
||||
## 连接成功后做什么
|
||||
|
||||
返回 WechatExplorer,点击“自动获取密钥”。
|
||||
### AI 问问微信
|
||||
|
||||
- **Windows**:看到“Hook 注入成功”后,返回微信完成登录。
|
||||
- **macOS**:系统会弹出授权提示,请输入当前 macOS 用户密码并完成授权,然后返回微信完成登录。
|
||||
打开「问问微信」,用自然语言向自己的微信提问,例如:
|
||||
|
||||

|
||||
- “技术群这周讨论了哪些问题?”
|
||||
- “帮我找到张三发过的项目地址。”
|
||||
- “去年我和老板聊过哪些关于涨薪的事情?”
|
||||
|
||||
> 点击“自动获取密钥”前,微信必须停留在登录页面。WechatExplorer 提示可以登录后,再回到微信完成登录。
|
||||
如果还没有配置 AI,点击「设置 → AI 模型」添加模型服务商并测试连接。
|
||||
|
||||
### 第四步:完成连接
|
||||
### AI 群聊日报
|
||||
|
||||
如果系统环境和微信版本符合要求,WechatExplorer 会自动填写数据库密钥并连接数据库。连接成功后即可查看、搜索和导出聊天记录,也可以配置 AI 服务生成群聊总结。
|
||||
1. 打开「日报」。
|
||||
2. 选择一个群聊和时间范围。
|
||||
3. 按需要选择日报内容和模板。
|
||||
4. 开始生成,完成后查看或导出 HTML 与 PNG。
|
||||
|
||||

|
||||
日报会整理讨论摘要、关键主题、重要消息、资源、问题和待跟进事项,并保留证据来源。
|
||||
|
||||
## 4. 图片解密密钥
|
||||
### 查看聊天
|
||||
|
||||
微信 4.0 及以上版本的图片通常以 `.dat` 文件存储,显示图片还需要:
|
||||
1. 打开「档案」。
|
||||
2. 选择好友或群聊。
|
||||
3. 浏览历史消息,也可以按关键词定位会话。
|
||||
|
||||
### 导出聊天
|
||||
|
||||
打开「导出」,选择联系人或群聊、时间范围和格式。支持 HTML、CSV、JSON 和 Markdown。
|
||||
|
||||
## 重新查看新手引导
|
||||
|
||||
连接成功后,首次弹窗关闭不会影响功能使用。需要重新查看时,点击主界面左下角的「新手引导」:
|
||||
|
||||
<p align="center">
|
||||
<img src="../../public/guide-entry.png" alt="主界面左下角新手引导入口" width="760" />
|
||||
</p>
|
||||
|
||||
新手引导会再次展示:
|
||||
|
||||
- AI 群聊日报入口。
|
||||
- 查看聊天记录入口。
|
||||
- 问问微信入口。
|
||||
- AI 模型配置入口。
|
||||
- 完整使用教程入口。
|
||||
|
||||
## 配置 AI
|
||||
|
||||
WechatExplorer 支持 OpenAI 兼容接口,也提供 DeepSeek、OpenAI、Claude、Moonshot 等常用配置方式。
|
||||
|
||||
1. 进入「设置 → AI 模型」。
|
||||
2. 添加模型服务商并填写 API Key。
|
||||
3. 确认 Base URL 和模型名称正确。
|
||||
4. 保存并测试连接。
|
||||
5. 返回「问问微信」或「日报」重试。
|
||||
|
||||
AI 功能使用你配置的模型服务。相关聊天内容会按请求发送给该服务;是否启用以及使用哪一个服务由你决定。
|
||||
|
||||
## 遇到问题
|
||||
|
||||
先判断你遇到的现象,再按对应路径处理。
|
||||
|
||||
| 现象 | 优先检查 |
|
||||
| --------------------------------- | -------------------------------------------------------- |
|
||||
| 软件打不开 | macOS 安全提示或应用损坏处理;Windows 重新运行安装包 |
|
||||
| 找不到微信数据 | 在首次连接页面或设置中确认数据目录,Windows 检查目录层级 |
|
||||
| 获取不到数据库密钥 | 微信是否停留在登录页面、微信和应用是否同时运行 |
|
||||
| 数据库连接失败 | 当前账号是否匹配、微信版本是否兼容、数据库目录是否正确 |
|
||||
| 已连接但图片不显示 | 配置图片 XOR Key 和 AES Key |
|
||||
| AI 问问微信或日报不可用 | 在「设置 → AI 模型」配置并测试模型服务 |
|
||||
| API、Reader Skill 或 Agent 不可用 | 先连接数据库,再确认 API 服务状态和对应配置 |
|
||||
|
||||
### 软件打不开
|
||||
|
||||
#### macOS
|
||||
|
||||
- 出现“无法打开,因为开发者无法验证”:前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
- 出现“应用已损坏”:确认应用位于“应用程序”目录,再执行:
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/WechatExplorer.app"
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
确认下载的是 Releases 中的 `-setup.exe` 安装包,并按安装向导完成安装。Windows 不需要关闭 SIP。
|
||||
|
||||
### 找不到微信数据
|
||||
|
||||
在首次连接页面确认“存储路径”。如果没有自动识别:
|
||||
|
||||
1. 打开「设置」。
|
||||
2. 手动选择微信数据所在目录。
|
||||
3. 返回连接页面,重新测试连接。
|
||||
|
||||
Windows 当前不会扫描二级目录,请确认目录没有多选或少选一层目录。
|
||||
|
||||
### 获取不到数据库密钥
|
||||
|
||||
按顺序检查:
|
||||
|
||||
1. 微信版本是否与上方已测试版本一致。
|
||||
2. 点击“开始获取”时,微信是否停留在未登录页面。
|
||||
3. 微信和 WechatExplorer 是否都保持运行。
|
||||
4. 微信数据目录是否准确。
|
||||
5. macOS 是否已关闭 SIP 并完成系统授权。
|
||||
|
||||
仍然失败时,可以在连接页面切换为“高级用户:已有数据库密钥?手动连接”,粘贴从其他兼容工具中取得的数据库密钥。手动输入的密钥必须与当前微信账号匹配。
|
||||
|
||||
### 数据库连接失败或账号不匹配
|
||||
|
||||
数据库密钥与微信账号绑定。请确认:
|
||||
|
||||
- 当前微信登录的是获取密钥时对应的账号。
|
||||
- WechatExplorer 选择的是该账号的数据目录。
|
||||
- 没有把其他账号或旧数据目录的密钥粘贴进来。
|
||||
|
||||
### 已连接但图片无法显示
|
||||
|
||||
微信 4.0 的图片通常以 `.dat` 文件存储。显示图片还需要:
|
||||
|
||||
- **XOR Key**:单字节十六进制值,例如 `0x40`。
|
||||
- **AES Key**:用于 AES-128-ECB 解密的 16 字符字符串。
|
||||
|
||||
可以通过以下方式配置:
|
||||
进入「设置 → 图片解密密钥」,选择自动获取或手动填写。也可以从 WeFlow 或 Chatlog 的设置中导出后填写。文字聊天记录不受图片密钥影响。
|
||||
|
||||
1. 使用首次连接页面的“自动获取密钥”。
|
||||
2. 在“设置 → 图片解密密钥”中自动获取或手动填写。
|
||||
3. 从 WeFlow 或 Chatlog 的设置中导出后手动填写。
|
||||
### 我已经连接成功,怎么重新查看教程?
|
||||
|
||||
数据库连接成功但图片无法显示时,请优先检查这两项密钥。
|
||||
点击左下角「新手引导」。
|
||||
|
||||
## 5. 常见问题
|
||||
首次连接流程、AI 配置入口、群聊日报、问问微信和完整教程都会再次展示。
|
||||
|
||||
### 自动获取密钥失败
|
||||
## 接入 API、Reader Skill 或 Agent
|
||||
|
||||
请依次确认:
|
||||
这是高级使用路径,请先完成数据库连接并熟悉「问问微信、日报、档案、导出」的基础流程。
|
||||
|
||||
1. 微信版本是否与上方已测试版本一致。
|
||||
2. 点击“自动获取密钥”时,微信是否停留在未登录页面。
|
||||
3. 微信数据目录是否正确;Windows 用户尤其需要检查是否多选或少选了一层目录。
|
||||
4. macOS 是否已按教程关闭 SIP,并完成系统授权。
|
||||
5. 微信和 WechatExplorer 是否都保持运行。
|
||||
### Reader Skill
|
||||
|
||||
仍然失败时,可以切换到“手动输入”,粘贴从其他兼容工具中取得的数据库密钥。
|
||||
1. 打开应用的「API」页面。
|
||||
2. 确认本地 API 已运行;如果已停止,点击“启动服务”。
|
||||
3. 在“快速接入”中选择 Codex 或 Claude Code。
|
||||
4. 复制安装指令,粘贴给对应 Agent 执行。
|
||||
5. 安装完成后,让 Agent 读取和总结本地聊天。
|
||||
|
||||
### Windows 使用时卡顿
|
||||
本地 API 默认地址为 `http://127.0.0.1:6131`,默认仅监听本机且无鉴权。详细端点和参数见 [Reader Skill 文档](../skill/wechatexplorer-reader/SKILL.md)。
|
||||
|
||||
Windows 支持仍处于初步阶段,不同微信版本、安装路径、数据目录和权限环境可能存在差异。建议优先使用上方已测试的微信版本。
|
||||
### Agent Hub
|
||||
|
||||
### 数据会上传吗?
|
||||
应用内的「Agent」页面用于管理 WechatExplorer 的 Agent 连接与运行状态,属于高级功能。
|
||||
|
||||
聊天数据库在本机读取和处理。只有使用 AI 总结功能时,相关聊天内容才会按你配置的模型服务发送;是否启用以及使用哪个服务由你决定。
|
||||
## 数据与隐私
|
||||
|
||||
## 6. 下一步
|
||||
- WechatExplorer 只读取你有权访问的本机微信数据。
|
||||
- 不使用 AI 时,应用不会因为读取聊天记录而自动上传聊天内容。
|
||||
- 使用 AI 问问微信、日报或图片理解时,相关内容会发送到你配置的模型服务。
|
||||
- 本地 API 默认监听 `127.0.0.1`,且无鉴权。不要将它暴露在不可信的局域网环境中。
|
||||
|
||||
- 在应用“设置”中填写兼容 OpenAI API 的模型服务和 API Key,使用 AI 总结功能。
|
||||
- 在应用的 **API** 页面安装 Reader Skill,让 Codex 或 Claude Code 读取和总结本地群聊。
|
||||
- 本地 API 的端点和调试方法见项目 [README](../../README.md#ai-集成本地-http-api)。
|
||||
## 仍然无法解决?
|
||||
|
||||
请先完成上面的自助排查,再进入交流/售后群。提问时一次性提供:
|
||||
|
||||
1. 操作系统和版本。
|
||||
2. 微信版本。
|
||||
3. WechatExplorer 版本。
|
||||
4. 当前处于哪一步,以及完整错误信息。
|
||||
5. 必要截图;请遮挡账号、数据库密钥、API Key 和其他敏感信息。
|
||||
|
||||
交流二维码位于项目 [README](../../README.md) 文末。
|
||||
|
||||
@@ -68,4 +68,4 @@ publish:
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
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",
|
||||
"version": "2.1.5",
|
||||
"version": "2.1.6",
|
||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"test": "pnpm typecheck && pnpm test:unit && pnpm test:component && pnpm test:integration && pnpm test:skill-install && pnpm test:wechat-connector && pnpm test:e2e:build && playwright test",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
@@ -28,6 +29,13 @@
|
||||
"start": "electron-vite preview",
|
||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
||||
"test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
||||
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||
"test:component": "vitest run --config vitest.component.config.ts",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:e2e:build": "electron-vite build",
|
||||
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
|
||||
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
|
||||
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
|
||||
"build:wechat-connector": "node scripts/build-wechat-connector.cjs",
|
||||
"build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64",
|
||||
"build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64",
|
||||
@@ -41,6 +49,8 @@
|
||||
"release": "npm run release:mac && npm run release:win",
|
||||
"release:mac": "npm run typecheck && npm run build:wechat-connector:mac && electron-vite build && electron-builder --config electron-builder.yml --mac --x64 --arm64 --publish always",
|
||||
"release:win": "npm run typecheck && npm run build:wechat-connector:win && electron-vite build && electron-builder --config electron-builder.yml --win --x64 --publish always",
|
||||
"release:beta": "cross-env RELEASE_TYPE=prerelease npm run release",
|
||||
"release:stable": "cross-env RELEASE_TYPE=release npm run release",
|
||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -48,6 +58,8 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@koromix/koffi-win32-x64": "3.1.0",
|
||||
"@tanstack/react-virtual": "^3.14.6",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"jsonrepair": "^3.15.0",
|
||||
@@ -60,12 +72,18 @@
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@rollup/rollup-darwin-arm64": "^4.62.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"electron": "^43.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
@@ -73,11 +91,14 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"jsdom": "^30.0.1",
|
||||
"prettier": "^3.7.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"sass": "^1.102.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"pnpm": {
|
||||
"supportedArchitectures": {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testMatch: /.*\.spec\.ts/,
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 8_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI
|
||||
? [['line'], ['html', { outputFolder: 'playwright-report', open: 'never' }]]
|
||||
: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
|
||||
outputDir: 'test-results',
|
||||
snapshotPathTemplate: 'tests/e2e/__screenshots__/{platform}/{testFilePath}/{arg}{ext}',
|
||||
use: {
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure'
|
||||
}
|
||||
})
|
||||
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -2,6 +2,7 @@ import { app, shell } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import { isPackagedRuntime } from './runtime-mode'
|
||||
|
||||
const MAX_LOG_BYTES = 5 * 1024 * 1024
|
||||
const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i
|
||||
@@ -12,6 +13,7 @@ const sanitize = (value: unknown, depth = 0): unknown => {
|
||||
return value
|
||||
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
|
||||
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
|
||||
.replace(/\b(?:0x)?[a-f0-9]{64}\b/gi, '***')
|
||||
.slice(0, 2000)
|
||||
}
|
||||
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1))
|
||||
@@ -52,14 +54,14 @@ export class AppLogger {
|
||||
this.rotateIfNeeded()
|
||||
const record = {
|
||||
timestamp: new Date().toISOString(),
|
||||
mode: app.isPackaged ? 'packaged' : 'development',
|
||||
mode: isPackagedRuntime() ? 'packaged' : 'development',
|
||||
level: entry.level,
|
||||
scope: String(entry.scope || 'app').slice(0, 80),
|
||||
message: String(entry.message || '').slice(0, 500),
|
||||
message: String(sanitize(entry.message || '')).slice(0, 500),
|
||||
details: sanitize(entry.details || {})
|
||||
}
|
||||
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
|
||||
if (!app.isPackaged) {
|
||||
if (!isPackagedRuntime()) {
|
||||
const method =
|
||||
entry.level === 'error'
|
||||
? console.error
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import type { DatabaseKeyStorageResult } from '../shared/database-key'
|
||||
|
||||
const normalizeDatabaseKey = (value: string): string => value.trim().replace(/^0x/i, '')
|
||||
@@ -9,32 +10,42 @@ export const isValidDatabaseKey = (value: string): boolean =>
|
||||
/^[0-9a-f]{64}$/i.test(normalizeDatabaseKey(value))
|
||||
|
||||
export class DatabaseKeyStore {
|
||||
private get filePath(): string {
|
||||
private get legacyFilePath(): string {
|
||||
return path.join(app.getPath('userData'), 'wechat-db-key.bin')
|
||||
}
|
||||
|
||||
async getStatus(): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
|
||||
private get directoryPath(): string {
|
||||
return path.join(app.getPath('userData'), 'database-keys')
|
||||
}
|
||||
|
||||
private filePath(accountRoot: string): string {
|
||||
const normalized = path.resolve(accountRoot).toLowerCase()
|
||||
const id = crypto.createHash('sha256').update(normalized).digest('hex')
|
||||
return path.join(this.directoryPath, `${id}.bin`)
|
||||
}
|
||||
|
||||
async getStatus(accountRoot: string): Promise<{ saved: boolean; encryptionAvailable: boolean }> {
|
||||
return {
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: Boolean(accountRoot) && (await fs.pathExists(this.filePath(accountRoot))),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<DatabaseKeyStorageResult> {
|
||||
async load(accountRoot: string): Promise<DatabaseKeyStorageResult> {
|
||||
try {
|
||||
const status = await this.getStatus()
|
||||
const status = await this.getStatus(accountRoot)
|
||||
if (!status.saved) return { success: true, ...status }
|
||||
if (!status.encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', ...status }
|
||||
}
|
||||
const encrypted = await fs.readFile(this.filePath)
|
||||
const encrypted = await fs.readFile(this.filePath(accountRoot))
|
||||
const key = normalizeDatabaseKey(safeStorage.decryptString(encrypted))
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return { success: false, error: '已保存的密钥格式无效', ...status }
|
||||
}
|
||||
return { success: true, key, ...status }
|
||||
} catch (error) {
|
||||
const status = await this.getStatus()
|
||||
const status = await this.getStatus(accountRoot)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -43,13 +54,49 @@ export class DatabaseKeyStore {
|
||||
}
|
||||
}
|
||||
|
||||
async save(rawKey: string): Promise<DatabaseKeyStorageResult> {
|
||||
async loadLegacy(): Promise<DatabaseKeyStorageResult> {
|
||||
const saved = await fs.pathExists(this.legacyFilePath)
|
||||
const encryptionAvailable = safeStorage.isEncryptionAvailable()
|
||||
if (!saved) return { success: true, saved, encryptionAvailable }
|
||||
if (!encryptionAvailable) {
|
||||
return { success: false, error: '系统安全存储不可用', saved, encryptionAvailable }
|
||||
}
|
||||
try {
|
||||
const key = normalizeDatabaseKey(
|
||||
safeStorage.decryptString(await fs.readFile(this.legacyFilePath))
|
||||
)
|
||||
return isValidDatabaseKey(key)
|
||||
? { success: true, key, saved, encryptionAvailable }
|
||||
: { success: false, error: '旧版密钥格式无效', saved, encryptionAvailable }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
saved,
|
||||
encryptionAvailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearLegacy(): Promise<void> {
|
||||
await fs.remove(this.legacyFilePath)
|
||||
}
|
||||
|
||||
async save(accountRoot: string, rawKey: string): Promise<DatabaseKeyStorageResult> {
|
||||
const key = normalizeDatabaseKey(rawKey)
|
||||
if (!accountRoot.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '请先选择微信账号',
|
||||
saved: false,
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
if (!isValidDatabaseKey(key)) {
|
||||
return {
|
||||
success: false,
|
||||
error: '密钥必须是 64 位十六进制字符',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
}
|
||||
@@ -57,29 +104,30 @@ export class DatabaseKeyStore {
|
||||
return {
|
||||
success: false,
|
||||
error: '系统安全存储不可用',
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.ensureDir(path.dirname(this.filePath))
|
||||
await fs.writeFile(this.filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
||||
await fs.chmod(this.filePath, 0o600)
|
||||
const filePath = this.filePath(accountRoot)
|
||||
await fs.ensureDir(this.directoryPath)
|
||||
await fs.writeFile(filePath, safeStorage.encryptString(key), { mode: 0o600 })
|
||||
await fs.chmod(filePath, 0o600)
|
||||
return { success: true, key, saved: true, encryptionAvailable: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
saved: await fs.pathExists(this.filePath),
|
||||
saved: await fs.pathExists(this.filePath(accountRoot)),
|
||||
encryptionAvailable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<{ success: boolean; error?: string }> {
|
||||
async clear(accountRoot: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await fs.remove(this.filePath)
|
||||
if (accountRoot) await fs.remove(this.filePath(accountRoot))
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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}`
|
||||
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}.media-status{margin-top:8px;padding:6px 8px;border-left:3px solid #b27a18;background:#fff8e8;color:#79530f;font-size:12px;line-height:1.5}.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}.lightbox-close{position:fixed;top:20px;right:20px;z-index:11;width:42px;height:42px;border:1px solid #ffffff66;border-radius:50%;background:#14231dcc;color:#fff;font-size:30px;line-height:1;cursor:pointer}`
|
||||
const safe = (value: unknown): string =>
|
||||
String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
@@ -14,7 +14,10 @@ export function renderExportPage(name: string, messages: Message[]): string {
|
||||
? `<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>`
|
||||
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${safe(m.voiceDataUrl)}"></audio></div>`
|
||||
: ''
|
||||
const mediaStatus = m.exportMediaError
|
||||
? `<div class="media-status">${safe(m.exportMediaError)}</div>`
|
||||
: ''
|
||||
const quote =
|
||||
m.contentData?.type === 'quote'
|
||||
@@ -34,8 +37,8 @@ export function renderExportPage(name: string, messages: Message[]): string {
|
||||
: `<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>`
|
||||
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>${mediaStatus}</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>`
|
||||
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"><button class="lightbox-close" id="lightbox-close" type="button" aria-label="关闭图片预览">×</button><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'),closeButton=document.querySelector('#lightbox-close');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const closeLightbox=()=>{box.classList.remove('open');zoom=1;updateZoom()};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)closeLightbox()});closeButton.addEventListener('click',closeLightbox);document.addEventListener('keydown',event=>{if(event.key==='Escape')closeLightbox()});update()})()</script></body></html>`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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'
|
||||
import { getImageExportAttempts } from '../shared/export-media'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
@@ -26,6 +27,10 @@ const exportStamp = (): string => {
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||
}
|
||||
const imageKeys = new ImageKeyConfigService()
|
||||
|
||||
const keepMediaError = (request: ExportRequest, message: Message, error: string): void => {
|
||||
if (request.keepMissing !== false) message.exportMediaError = error
|
||||
}
|
||||
function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
|
||||
const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
|
||||
if (!match) return null
|
||||
@@ -105,11 +110,20 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri
|
||||
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 `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}`
|
||||
return [
|
||||
'时间,发送者,类型,内容',
|
||||
'时间,发送者,类型,内容,媒体路径,媒体状态',
|
||||
...messages.map((m) =>
|
||||
[m.datetime, m.name || (m.isSender ? '我' : '联系人'), m.type, m.content].map(csv).join(',')
|
||||
[
|
||||
m.datetime,
|
||||
m.name || (m.isSender ? '我' : '联系人'),
|
||||
m.type,
|
||||
m.content,
|
||||
m.exportMediaUrl || m.voiceDataUrl || '',
|
||||
m.exportMediaError || ''
|
||||
]
|
||||
.map(csv)
|
||||
.join(',')
|
||||
)
|
||||
].join('\n')
|
||||
}
|
||||
@@ -126,9 +140,19 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
.listMessages(request.userMd5, request.startTime, request.endTime)
|
||||
.filter((m) => request.kinds.includes(kindOf(m)))
|
||||
for (const message of messages) {
|
||||
message.exportMediaUrl = undefined
|
||||
message.exportMediaType = undefined
|
||||
message.exportMediaError = undefined
|
||||
message.voiceDataUrl = undefined
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||
if (mappedName) message.name = mappedName
|
||||
if (
|
||||
request.format !== 'html' &&
|
||||
['image', 'video', 'voice', 'sticker'].includes(kindOf(message))
|
||||
) {
|
||||
message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件'
|
||||
}
|
||||
}
|
||||
send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 })
|
||||
if (!jobs.has(request.jobId)) {
|
||||
@@ -181,25 +205,46 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
: null
|
||||
if (voiceService) {
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (
|
||||
kindOf(message) !== 'voice' ||
|
||||
!message.sessionId ||
|
||||
!message.localId ||
|
||||
!message.createTime
|
||||
)
|
||||
if (kindOf(message) !== 'voice') continue
|
||||
if (!message.sessionId || message.localId == null || !message.createTime) {
|
||||
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||||
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)))
|
||||
}
|
||||
try {
|
||||
const voice = await voiceService.resolveVoice(
|
||||
message.sessionId,
|
||||
message.localId,
|
||||
message.createTime,
|
||||
message.serverId
|
||||
)
|
||||
if (!voice.success || !voice.data) {
|
||||
const detail = voice.error || '未知原因'
|
||||
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
|
||||
? `语音文件缺失:${detail}`
|
||||
: /Silk|解码|数据为空/.test(detail)
|
||||
? `语音解析失败:${detail}`
|
||||
: `语音格式不支持或读取失败:${detail}`
|
||||
keepMediaError(request, message, reason)
|
||||
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)))
|
||||
} catch (error) {
|
||||
keepMediaError(
|
||||
request,
|
||||
message,
|
||||
`语音文件写入失败:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (request.includeMedia) {
|
||||
for (const message of messages) {
|
||||
if (kindOf(message) === 'voice') {
|
||||
keepMediaError(request, message, '数据库未连接,无法读取本地语音')
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [index, message] of messages.entries()) {
|
||||
@@ -228,34 +273,78 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
})
|
||||
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'
|
||||
if (message.contentData.type === 'image') {
|
||||
if (!imageService) {
|
||||
keepMediaError(request, message, '未配置图片解密密钥,无法导出图片')
|
||||
} else {
|
||||
let fileFound = false
|
||||
let decryptedImage: { data: string; filePath: string } | null = null
|
||||
let usedFallback = false
|
||||
for (const attempt of getImageExportAttempts(request)) {
|
||||
const file = imageService.findImageFile(
|
||||
message.contentData.md5,
|
||||
message.contentData.datName,
|
||||
{
|
||||
allowThumbnail: attempt.allowThumbnail,
|
||||
preferThumbnail: attempt.preferThumbnail,
|
||||
sessionId: message.sessionId
|
||||
}
|
||||
)
|
||||
if (!file) continue
|
||||
fileFound = true
|
||||
const decrypted = imageService.decryptImageToBase64WithFallback(
|
||||
file,
|
||||
attempt.allowThumbnail
|
||||
)
|
||||
if (!decrypted) continue
|
||||
decryptedImage = decrypted
|
||||
usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath)
|
||||
break
|
||||
}
|
||||
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.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'
|
||||
if (usedFallback) {
|
||||
keepMediaError(request, message, '原图不可用,已降级使用缩略图')
|
||||
}
|
||||
} else if (!fileFound) {
|
||||
keepMediaError(
|
||||
request,
|
||||
message,
|
||||
request.fallbackThumbnail === false
|
||||
? '原图文件缺失,未启用缩略图降级'
|
||||
: '原图和缩略图文件均缺失'
|
||||
)
|
||||
} else {
|
||||
keepMediaError(request, message, '图片解析失败或当前格式不支持')
|
||||
}
|
||||
}
|
||||
} else if (message.contentData.type === 'video' && videoService) {
|
||||
} else if (message.contentData.type === 'video') {
|
||||
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'
|
||||
if (!videoService) {
|
||||
keepMediaError(request, message, '数据库未连接,无法定位本地视频')
|
||||
} else if (hashes.length === 0) {
|
||||
keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
|
||||
} else {
|
||||
const resolved = videoService.resolve(hashes)
|
||||
const source = resolved.url ? videoService.pathForUrl(resolved.url) : undefined
|
||||
if (!resolved.success || !source) {
|
||||
keepMediaError(request, message, resolved.error || '视频文件缺失或已移动')
|
||||
} else if (extname(source).toLowerCase() !== '.mp4') {
|
||||
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
|
||||
} else {
|
||||
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
|
||||
@@ -270,6 +359,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'sticker'
|
||||
} else {
|
||||
keepMediaError(request, message, result.error || '表情资源缺失或下载失败')
|
||||
}
|
||||
}
|
||||
send({
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
dialog,
|
||||
protocol
|
||||
} from 'electron'
|
||||
import { join } from 'path'
|
||||
import { dirname, join } from 'path'
|
||||
import { existsSync, promises as fsPromises } from 'fs'
|
||||
import { extname } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
@@ -21,7 +21,12 @@ import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
|
||||
import { VoiceService } from './voice-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
import { parseMessageContent } from './message-parser'
|
||||
import { ImageDecryptService } from './image-decrypt-service'
|
||||
import {
|
||||
ImageDecryptService,
|
||||
inspectImageDecoderExecutable,
|
||||
inspectImageDecoderStatus,
|
||||
type DecodedImage
|
||||
} from './image-decrypt-service'
|
||||
import { exportGroupReport } from './group-report-service'
|
||||
import {
|
||||
deleteGeneratedReport,
|
||||
@@ -53,13 +58,25 @@ import * as chat from './services/chat-service'
|
||||
import { apiServer } from './http-server'
|
||||
import { skillResourceService } from './services/skill-resource-service'
|
||||
import { testLocalApiRequest } from './services/local-api-test-service'
|
||||
import { isWindowsWechatRunning } from './services/wechat-process-status'
|
||||
import { isWechatRunning } from './services/wechat-process-status'
|
||||
import {
|
||||
inspectImageDecryptionStatus,
|
||||
testImageDecryption
|
||||
} from './services/image-decryption-status-service'
|
||||
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
||||
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
getSettingsPath,
|
||||
AppSettings,
|
||||
validateDbRoot
|
||||
} from './services/settings-store'
|
||||
import {
|
||||
detectDataStructureVersion,
|
||||
detectWechatVersion,
|
||||
getOsVersionLabel
|
||||
} from './services/connection-diagnostics'
|
||||
import { buildSafeDiagnosticSummary } from '../shared/connection-diagnostics'
|
||||
import {
|
||||
flushBootstrapCacheWritesSync,
|
||||
getBootstrapCache,
|
||||
@@ -76,10 +93,14 @@ import { installSafeConsole } from './safe-log'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { appLogger } from './app-logger'
|
||||
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'
|
||||
import { discoverAccounts } from './services/account-discovery'
|
||||
|
||||
// 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
|
||||
@@ -100,6 +121,68 @@ let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||
let recallProtectionGeneration = 0
|
||||
let recallJournalTimer: NodeJS.Timeout | null = null
|
||||
let wcdbBootstrapPromise: Promise<unknown> | null = null
|
||||
type ColdImageLoadItem = {
|
||||
priority: number
|
||||
sequence: number
|
||||
run: () => Promise<unknown>
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: unknown) => void
|
||||
}
|
||||
const coldImageLoadQueue: ColdImageLoadItem[] = []
|
||||
let activeColdImageLoads = 0
|
||||
let coldImageLoadSequence = 0
|
||||
let coldImageLoadTimer: NodeJS.Timeout | null = null
|
||||
let nextColdImageLoadAt = 0
|
||||
|
||||
const COLD_IMAGE_LOAD_GAP_MS = 100
|
||||
const MAX_CONCURRENT_COLD_IMAGE_LOADS = 2
|
||||
|
||||
function pumpColdImageLoads(): void {
|
||||
if (activeColdImageLoads >= MAX_CONCURRENT_COLD_IMAGE_LOADS || coldImageLoadQueue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const waitMs = Math.max(0, nextColdImageLoadAt - Date.now())
|
||||
if (waitMs > 0) {
|
||||
if (!coldImageLoadTimer) {
|
||||
coldImageLoadTimer = setTimeout(() => {
|
||||
coldImageLoadTimer = null
|
||||
pumpColdImageLoads()
|
||||
}, waitMs)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
coldImageLoadQueue.sort(
|
||||
(left, right) => left.priority - right.priority || left.sequence - right.sequence
|
||||
)
|
||||
const item = coldImageLoadQueue.shift()
|
||||
if (!item) return
|
||||
|
||||
activeColdImageLoads += 1
|
||||
nextColdImageLoadAt = Date.now() + COLD_IMAGE_LOAD_GAP_MS
|
||||
void item
|
||||
.run()
|
||||
.then(item.resolve, item.reject)
|
||||
.finally(() => {
|
||||
activeColdImageLoads -= 1
|
||||
pumpColdImageLoads()
|
||||
})
|
||||
pumpColdImageLoads()
|
||||
}
|
||||
|
||||
function enqueueColdImageLoad<T>(task: () => Promise<T> | T, priority = 0): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
coldImageLoadQueue.push({
|
||||
priority,
|
||||
sequence: coldImageLoadSequence++,
|
||||
run: async () => task(),
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject
|
||||
})
|
||||
pumpColdImageLoads()
|
||||
})
|
||||
}
|
||||
|
||||
function configureRecallProtection(
|
||||
wcdb4Client: Wcdb4Client,
|
||||
@@ -196,9 +279,58 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
|
||||
}
|
||||
}
|
||||
|
||||
function getImageMediaService(): VideoAssetService | null {
|
||||
if (videoAssetService) return videoAssetService
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
if (!client) return null
|
||||
videoAssetService = new VideoAssetService(client)
|
||||
return videoAssetService
|
||||
}
|
||||
|
||||
function getLocalMediaMimeType(filePath: string): string {
|
||||
switch (extname(filePath).toLowerCase()) {
|
||||
case '.mp4':
|
||||
return 'video/mp4'
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg'
|
||||
case '.png':
|
||||
return 'image/png'
|
||||
case '.gif':
|
||||
return 'image/gif'
|
||||
case '.webp':
|
||||
return 'image/webp'
|
||||
case '.bmp':
|
||||
return 'image/bmp'
|
||||
default:
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
}
|
||||
|
||||
function buildImageResponse(image: DecodedImage): {
|
||||
success: true
|
||||
data: string
|
||||
isThumb: boolean
|
||||
filePath: string
|
||||
mimeType?: string
|
||||
} {
|
||||
const mediaService = image.cacheFilePath ? getImageMediaService() : null
|
||||
const data =
|
||||
mediaService && image.cacheFilePath && existsSync(image.cacheFilePath)
|
||||
? mediaService.createLocalMediaUrl(image.cacheFilePath)
|
||||
: image.data
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
isThumb: image.isThumbnail,
|
||||
filePath: image.filePath,
|
||||
mimeType: image.mimeType
|
||||
}
|
||||
}
|
||||
|
||||
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 mimeType = getLocalMediaMimeType(filePath)
|
||||
const commonHeaders = {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Type': mimeType,
|
||||
@@ -289,8 +421,7 @@ function createWindow(): void {
|
||||
// 某些 API 只能在此事件发生后使用
|
||||
app.whenReady().then(async () => {
|
||||
protocol.handle('wxe-media', async (request) => {
|
||||
const token = new URL(request.url).pathname.replace(/^\/+/, '')
|
||||
const filePath = videoAssetService?.pathForToken(token)
|
||||
const filePath = videoAssetService?.pathForUrl(request.url)
|
||||
if (!filePath) return new Response('Not found', { status: 404 })
|
||||
try {
|
||||
return await createLocalMediaResponse(request, filePath)
|
||||
@@ -349,27 +480,57 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('app-log:write', (_, entry: AppLogEntry) => appLogger.write(entry))
|
||||
ipcMain.handle('app-log:getPath', () => appLogger.logPath)
|
||||
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, accountRoot?: string) => {
|
||||
if (dbInitInFlight) return dbInitInFlight
|
||||
|
||||
dbInitInFlight = (async () => {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
if (wcdbBootstrapPromise) await wcdbBootstrapPromise
|
||||
const trimmedKey = String(key || '').trim()
|
||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||
const settings = loadSettings()
|
||||
const selectedRoot = String(accountRoot || settings.dbRoot || '').trim()
|
||||
const rootValidation = validateDbRoot(selectedRoot)
|
||||
if (!rootValidation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'ROOT_UNAVAILABLE',
|
||||
error: rootValidation.error,
|
||||
monitoring: false
|
||||
}
|
||||
}
|
||||
if (!existsSync(join(selectedRoot, 'db_storage'))) {
|
||||
return {
|
||||
success: false,
|
||||
code: 'ACCOUNT_SELECTION_REQUIRED',
|
||||
error: '请先明确选择一个微信账号',
|
||||
monitoring: false
|
||||
}
|
||||
}
|
||||
if (
|
||||
chat.isReady() &&
|
||||
chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') &&
|
||||
(!settings.dbRoot || chat.getCurrentAccountRoot() === settings.dbRoot)
|
||||
chat.getCurrentAccountRoot() === selectedRoot
|
||||
) {
|
||||
console.log('[WCDB4] db:init reuse current connection')
|
||||
return { success: true, monitoring: true }
|
||||
}
|
||||
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
|
||||
const nextWechatDb = await WechatDb.create(key, selectedRoot)
|
||||
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
|
||||
if (resolvedRoot) {
|
||||
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
||||
saveSettings({
|
||||
...settings,
|
||||
@@ -379,25 +540,28 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
chat.setChatDb(nextWechatDb)
|
||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||
const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false })
|
||||
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||
voiceService = new VoiceService(wcdb4Client)
|
||||
stickerService = new StickerService(wcdb4Client)
|
||||
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
||||
const monitoring = await wcdb4Client.startMonitor((type, json) => {
|
||||
wcdb4Client.invalidateSessionCache()
|
||||
recallArchiveMonitor?.handleDatabaseChange(json)
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||
}
|
||||
})
|
||||
setImmediate(() => {
|
||||
const recentSession = wcdb4Client.getSessions()[0]
|
||||
if (!recentSession?.username) return
|
||||
const recentSession = sessions[0]
|
||||
if (recentSession?.username) {
|
||||
void wcdb4Client
|
||||
.getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 })
|
||||
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
|
||||
})
|
||||
}
|
||||
imageDecryptService = null
|
||||
console.log(
|
||||
`[WCDB4] db:init ready sessions=${sessions.length} monitoring=${monitoring} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return { success: true, monitoring }
|
||||
} catch (error) {
|
||||
console.error('Failed to init DB:', error)
|
||||
@@ -410,19 +574,43 @@ app.whenReady().then(async () => {
|
||||
return dbInitInFlight
|
||||
})
|
||||
|
||||
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load())
|
||||
ipcMain.handle('accounts:discover', (_, inputPath: string) =>
|
||||
discoverAccounts(inputPath, databaseKeyStore, chat.getCurrentAccountRoot())
|
||||
)
|
||||
|
||||
ipcMain.handle('key:getSavedDbKey', async (_, accountRoot: string) => {
|
||||
const selectedRoot = String(accountRoot || '').trim()
|
||||
const scoped = await databaseKeyStore.load(selectedRoot)
|
||||
if (scoped.saved || !selectedRoot) return scoped
|
||||
const legacy = await databaseKeyStore.loadLegacy()
|
||||
if (!legacy.success || !legacy.key) return scoped
|
||||
const validation = await chat.testConnection(legacy.key, selectedRoot)
|
||||
if (!validation.success) return scoped
|
||||
const migrated = await databaseKeyStore.save(selectedRoot, legacy.key)
|
||||
if (migrated.success) await databaseKeyStore.clearLegacy()
|
||||
return migrated
|
||||
})
|
||||
|
||||
ipcMain.handle('key:getEnvironment', async () => {
|
||||
const storage = await databaseKeyStore.getStatus()
|
||||
const storage = await databaseKeyStore.getStatus(
|
||||
chat.getCurrentAccountRoot() || loadSettings().dbRoot
|
||||
)
|
||||
const self = chat.getSelfAccountInfo()
|
||||
return {
|
||||
const settings = loadSettings()
|
||||
const environment = {
|
||||
platform: process.platform,
|
||||
osVersion: getOsVersionLabel(),
|
||||
appVersion: `v${app.getVersion()}`,
|
||||
wechatVersion: await detectWechatVersion(),
|
||||
dataStructureVersion: detectDataStructureVersion(settings.dbRoot),
|
||||
dataDirectoryDetected: validateDbRoot(settings.dbRoot).valid,
|
||||
autoDetectSupported: process.platform === 'win32',
|
||||
wechatRunning: await isWindowsWechatRunning(),
|
||||
wechatRunning: await isWechatRunning(),
|
||||
accountIdentified: Boolean(self?.wxid),
|
||||
dbConnected: chat.isReady(),
|
||||
encryptionAvailable: storage.encryptionAvailable
|
||||
}
|
||||
return { ...environment, diagnosticSummary: buildSafeDiagnosticSummary(environment) }
|
||||
})
|
||||
|
||||
ipcMain.handle('key:readClipboardDbKey', () => {
|
||||
@@ -433,36 +621,47 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('key:pasteAndSaveDbKey', async () => {
|
||||
ipcMain.handle('key:pasteAndSaveDbKey', async (_, accountRoot: string) => {
|
||||
const clipboardKey = clipboard.readText().trim()
|
||||
return databaseKeyStore.save(clipboardKey)
|
||||
return databaseKeyStore.save(String(accountRoot || ''), clipboardKey)
|
||||
})
|
||||
|
||||
ipcMain.handle('key:saveDbKey', async (_, key: string) =>
|
||||
databaseKeyStore.save(String(key || ''))
|
||||
ipcMain.handle('key:saveDbKey', async (_, accountRoot: string, key: string) =>
|
||||
databaseKeyStore.save(String(accountRoot || ''), String(key || ''))
|
||||
)
|
||||
|
||||
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
|
||||
ipcMain.handle('key:clearSavedDbKey', async (_, accountRoot: string) =>
|
||||
databaseKeyStore.clear(String(accountRoot || ''))
|
||||
)
|
||||
|
||||
ipcMain.handle('key:autoGetDbKey', async (event, options?: { save?: boolean }) => {
|
||||
const onStatus = (message: string): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
|
||||
ipcMain.handle(
|
||||
'key:autoGetDbKey',
|
||||
async (event, accountRoot: string, options?: { save?: boolean }) => {
|
||||
const onStatus = (message: string): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
|
||||
}
|
||||
const result =
|
||||
process.platform === 'win32'
|
||||
? await keyServiceWin.autoGetDbKey(60_000, onStatus)
|
||||
: await keyServiceMac.autoGetDbKey(onStatus)
|
||||
if (!result.success || !result.key) return result
|
||||
|
||||
const selectedRoot = String(accountRoot || '').trim()
|
||||
if (!selectedRoot) return { success: false, error: '请先选择微信账号' }
|
||||
const validation = await chat.testConnection(result.key, selectedRoot)
|
||||
if (!validation.success) {
|
||||
return { ...result, success: false, key: undefined, error: '获取到的密钥不属于所选账号' }
|
||||
}
|
||||
if (options?.save === false) return result
|
||||
|
||||
const saved = await databaseKeyStore.save(selectedRoot, result.key)
|
||||
return {
|
||||
...result,
|
||||
saved: saved.success,
|
||||
warning: saved.success ? undefined : saved.error
|
||||
}
|
||||
}
|
||||
const result =
|
||||
process.platform === 'win32'
|
||||
? await keyServiceWin.autoGetDbKey(60_000, onStatus)
|
||||
: await keyServiceMac.autoGetDbKey(onStatus)
|
||||
if (!result.success || !result.key) return result
|
||||
|
||||
if (options?.save === false) return result
|
||||
|
||||
const saved = await databaseKeyStore.save(result.key)
|
||||
return {
|
||||
...result,
|
||||
saved: saved.success,
|
||||
warning: saved.success ? undefined : saved.error
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
||||
const settings = loadSettings()
|
||||
@@ -532,6 +731,67 @@ app.whenReady().then(async () => {
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('image:selectDecoder', async (event) => {
|
||||
const settings = loadSettings()
|
||||
const owner = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(owner!, {
|
||||
title: '选择 FFmpeg 解压或安装目录',
|
||||
defaultPath: settings.ffmpegPath ? dirname(settings.ffmpegPath) : app.getPath('downloads'),
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
if (result.canceled) return { success: false, canceled: true }
|
||||
|
||||
const selectedDirectory = result.filePaths[0]
|
||||
if (!selectedDirectory) return { success: false, canceled: true }
|
||||
|
||||
const executable = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const candidates = [
|
||||
join(selectedDirectory, executable),
|
||||
join(selectedDirectory, 'bin', executable)
|
||||
]
|
||||
let selectedPath = ''
|
||||
for (const candidate of candidates) {
|
||||
if (!existsSync(candidate)) continue
|
||||
const inspection = await inspectImageDecoderExecutable(candidate)
|
||||
if (inspection.installed) {
|
||||
selectedPath = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedPath) {
|
||||
return {
|
||||
success: false,
|
||||
canceled: false,
|
||||
error: '所选目录中没有找到 FFmpeg,请选择解压后的文件夹或其中的 bin 文件夹。'
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings({ ...settings, ffmpegPath: selectedPath })
|
||||
return {
|
||||
success: true,
|
||||
canceled: false,
|
||||
status: await inspectImageDecoderStatus(selectedPath)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('image:getDecoderStatus', () => inspectImageDecoderStatus())
|
||||
|
||||
ipcMain.handle('image:openDecoderDownload', async () => {
|
||||
const url =
|
||||
process.platform === 'win32'
|
||||
? 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip'
|
||||
: process.platform === 'darwin'
|
||||
? 'https://brew.sh/'
|
||||
: 'https://ffmpeg.org/download.html'
|
||||
try {
|
||||
await shell.openExternal(url)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: '无法打开下载页面,请检查系统默认浏览器设置。' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getBootstrapCache', () => {
|
||||
if (!chat.isReady()) return null
|
||||
return getBootstrapCache(chat.getCurrentAccountRoot())
|
||||
@@ -560,19 +820,19 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('db:getContacts', (_, filter?: string) => {
|
||||
ipcMain.handle('db:getContacts', async (_, filter?: string) => {
|
||||
const accountRoot = chat.getCurrentAccountRoot()
|
||||
const contacts = accountRoot
|
||||
? mergeCachedContactAvatars(accountRoot, chat.listContacts(filter))
|
||||
: chat.listContacts(filter)
|
||||
? mergeCachedContactAvatars(accountRoot, await chat.listContactsAsync(filter))
|
||||
: await chat.listContactsAsync(filter)
|
||||
if (!filter && chat.isReady() && accountRoot) {
|
||||
saveBootstrapContacts(accountRoot, contacts)
|
||||
}
|
||||
return contacts
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) => {
|
||||
const avatars = chat.getContactAvatars(usernames)
|
||||
ipcMain.handle('db:getContactAvatars', async (_, usernames: string[]) => {
|
||||
const avatars = await chat.getContactAvatars(usernames)
|
||||
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
|
||||
return avatars
|
||||
})
|
||||
@@ -629,9 +889,15 @@ app.whenReady().then(async () => {
|
||||
aiProviderService.migrateLegacy(config)
|
||||
)
|
||||
|
||||
ipcMain.handle('copy-image', async (_, base64String) => {
|
||||
ipcMain.handle('copy-image', async (_, imageSource: unknown) => {
|
||||
try {
|
||||
const image = nativeImage.createFromDataURL(base64String)
|
||||
if (typeof imageSource !== 'string' || !imageSource) {
|
||||
return { success: false, error: 'Image source is empty' }
|
||||
}
|
||||
const image = imageSource.startsWith('wxe-media://')
|
||||
? nativeImage.createFromPath(getImageMediaService()?.pathForUrl(imageSource) || '')
|
||||
: nativeImage.createFromDataURL(imageSource)
|
||||
if (image.isEmpty()) return { success: false, error: 'Image source is invalid' }
|
||||
clipboard.writeImage(image)
|
||||
return { success: true }
|
||||
} catch (error: unknown) {
|
||||
@@ -702,68 +968,102 @@ app.whenReady().then(async () => {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
_sessionId?: string,
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
) => {
|
||||
void _sessionId
|
||||
if (!imageDecryptService) {
|
||||
let service = imageDecryptService
|
||||
if (!service) {
|
||||
const { xorKey, aesKey } = getConfiguredImageKeys()
|
||||
if (!aesKey) {
|
||||
return { success: false, error: '未配置图片解密密钥' }
|
||||
}
|
||||
imageDecryptService = new ImageDecryptService(
|
||||
// Reading an already-decoded cache entry does not require the AES key.
|
||||
// Before db:init resolves the account identity, secure storage may not
|
||||
// expose that key yet, so keep this cache-only service local.
|
||||
service = new ImageDecryptService(
|
||||
xorKey,
|
||||
aesKey,
|
||||
chat.getChatDb()?.getWcdb4Client()
|
||||
chat.getChatDb()?.getWcdb4Client(),
|
||||
loadSettings().dbRoot
|
||||
)
|
||||
if (aesKey) imageDecryptService = service
|
||||
}
|
||||
|
||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||
const force = options?.force === true
|
||||
const preferThumbnail = options?.preferThumbnail === true
|
||||
const priority = Number.isFinite(options?.priority) ? Number(options?.priority) : 0
|
||||
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
|
||||
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
|
||||
: null
|
||||
if (!filePath) {
|
||||
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
|
||||
allowThumbnail: true,
|
||||
preferThumbnail
|
||||
})
|
||||
}
|
||||
if (!filePath) {
|
||||
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
|
||||
}
|
||||
|
||||
const decrypted = imageDecryptService.decryptImageToBase64WithFallback(filePath, true)
|
||||
if (!decrypted) {
|
||||
return { success: false, error: '图片解密失败' }
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
data: decrypted.data,
|
||||
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
|
||||
filePath: decrypted.filePath
|
||||
}
|
||||
imageDecryptService.cacheDecodedImage(imageCacheKey, {
|
||||
data: result.data,
|
||||
filePath: result.filePath,
|
||||
isThumbnail: result.isThumb
|
||||
const mediaService = getImageMediaService()
|
||||
const cachedImage = await service.getCachedDecodedImage(imageCacheKey, {
|
||||
includeData: !mediaService
|
||||
})
|
||||
return result
|
||||
if (cachedImage && (!force || !cachedImage.isThumbnail)) {
|
||||
return buildImageResponse(cachedImage)
|
||||
}
|
||||
|
||||
return enqueueColdImageLoad(async () => {
|
||||
// Disk cache was already checked without waiting for database startup.
|
||||
// Only a real miss needs the initialized WCDB client and hardlink index.
|
||||
if (dbInitInFlight) {
|
||||
await dbInitInFlight.catch(() => undefined)
|
||||
}
|
||||
|
||||
let coldService = imageDecryptService
|
||||
if (!coldService) {
|
||||
const { xorKey, aesKey } = getConfiguredImageKeys()
|
||||
if (!aesKey) return { success: false, error: '未配置图片解密密钥' }
|
||||
coldService = new ImageDecryptService(
|
||||
xorKey,
|
||||
aesKey,
|
||||
chat.getChatDb()?.getWcdb4Client(),
|
||||
loadSettings().dbRoot
|
||||
)
|
||||
imageDecryptService = coldService
|
||||
}
|
||||
|
||||
// A previous queued request may have populated the cache while this one waited.
|
||||
const queuedMediaService = getImageMediaService()
|
||||
const queuedCacheHit = await coldService.getCachedDecodedImage(imageCacheKey, {
|
||||
includeData: !queuedMediaService
|
||||
})
|
||||
if (queuedCacheHit && (!force || !queuedCacheHit.isThumbnail)) {
|
||||
return buildImageResponse(queuedCacheHit)
|
||||
}
|
||||
|
||||
let filePath = force
|
||||
? await coldService.findImageFileAsync(imageMd5, imageDatName, {
|
||||
allowThumbnail: false,
|
||||
sessionId: _sessionId
|
||||
})
|
||||
: null
|
||||
if (!filePath) {
|
||||
filePath = await coldService.findImageFileAsync(imageMd5, imageDatName, {
|
||||
allowThumbnail: true,
|
||||
preferThumbnail,
|
||||
sessionId: _sessionId
|
||||
})
|
||||
}
|
||||
if (!filePath) {
|
||||
return {
|
||||
success: false,
|
||||
error: force ? '未找到原图或缩略图文件' : '未找到图片文件'
|
||||
}
|
||||
}
|
||||
|
||||
const decrypted = await coldService.decryptImageToBase64WithFallbackAsync(filePath, true)
|
||||
if (!decrypted) {
|
||||
return { success: false, error: '图片解密失败' }
|
||||
}
|
||||
|
||||
const decodedImage: DecodedImage = {
|
||||
data: decrypted.data,
|
||||
filePath: decrypted.filePath,
|
||||
isThumbnail: coldService.isThumbnailFile(decrypted.filePath)
|
||||
}
|
||||
await coldService.cacheDecodedImage(imageCacheKey, decodedImage)
|
||||
return buildImageResponse(decodedImage)
|
||||
}, priority)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,19 @@ type ShareContent = {
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
type ForwardedMessageItem = {
|
||||
messageType: number
|
||||
sender?: string
|
||||
sentAt?: string
|
||||
text: string
|
||||
nested?: ForwardedMessageItem[]
|
||||
}
|
||||
type ForwardBundleContent = {
|
||||
type: 'forwardBundle'
|
||||
title: string
|
||||
description?: string
|
||||
items: ForwardedMessageItem[]
|
||||
}
|
||||
type MiniProgramContent = {
|
||||
type: 'miniProgram'
|
||||
title: string
|
||||
@@ -82,7 +95,7 @@ type SystemContent = {
|
||||
recallTime?: number
|
||||
}
|
||||
}
|
||||
type UnknownContent = { type: 'unknown'; raw: string }
|
||||
type UnknownContent = { type: 'unknown'; raw: string; messageType?: string | number }
|
||||
|
||||
export type ParsedContent =
|
||||
| TextContent
|
||||
@@ -90,6 +103,7 @@ export type ParsedContent =
|
||||
| LocationContent
|
||||
| CardContent
|
||||
| ShareContent
|
||||
| ForwardBundleContent
|
||||
| MiniProgramContent
|
||||
| RedPacketContent
|
||||
| VoipContent
|
||||
@@ -108,6 +122,10 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
const normalized = content.trim()
|
||||
|
||||
switch (messageType) {
|
||||
case 1:
|
||||
return { type: 'text', content: normalized }
|
||||
case 34:
|
||||
return { type: 'voice' }
|
||||
case 3:
|
||||
return parseImageMessage(normalized)
|
||||
case 42:
|
||||
@@ -126,7 +144,7 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
case 10002:
|
||||
return parseSystemMessage(normalized)
|
||||
default:
|
||||
return { type: 'text', content: normalized }
|
||||
return { type: 'unknown', raw: normalized, messageType }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +430,9 @@ function parseLocationMessage(content: string): ParsedContent {
|
||||
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
if (appMsgType === '19' || /<recorditem\b|<dataitem\b/i.test(content)) {
|
||||
return parseForwardBundle(content)
|
||||
}
|
||||
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
||||
const sticker = parseStickerMessage(content)
|
||||
if (sticker.type === 'sticker') return sticker
|
||||
@@ -469,6 +490,94 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
return { type: 'share', title, des, url, appname, typeVal }
|
||||
}
|
||||
|
||||
function parseForwardBundle(content: string): ForwardBundleContent {
|
||||
const normalized = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const title = decodeXmlEntities(extractXmlValue(normalized, 'title')) || '聊天记录'
|
||||
const description = decodeXmlEntities(extractXmlValue(normalized, 'des')) || undefined
|
||||
const containers = Array.from(
|
||||
normalized.matchAll(/<recorditem\b[^>]*>([\s\S]*?)<\/recorditem>/gi),
|
||||
(match) => match[1] || ''
|
||||
)
|
||||
const sources = containers.length ? containers : [normalized]
|
||||
const items = dedupeForwardedItems(sources.flatMap((source) => parseForwardedItems(source)))
|
||||
return { type: 'forwardBundle', title, description, items }
|
||||
}
|
||||
|
||||
function parseForwardedItems(container: string, depth = 0): ForwardedMessageItem[] {
|
||||
if (!container || depth > 4) return []
|
||||
const variants = new Set<string>([container, decodeXmlEntities(container)])
|
||||
for (const match of container.matchAll(/<!\[CDATA\[([\s\S]*?)\]\]>/g)) {
|
||||
if (match[1]) variants.add(decodeXmlEntities(match[1]))
|
||||
}
|
||||
|
||||
const items: ForwardedMessageItem[] = []
|
||||
for (const variant of variants) {
|
||||
for (const match of variant.matchAll(/<dataitem\b([^>]*)>([\s\S]*?)<\/dataitem>/gi)) {
|
||||
const attributes = match[1] || ''
|
||||
const body = match[2] || ''
|
||||
const attrType = /datatype\s*=\s*["']?(\d+)/i.exec(attributes)?.[1]
|
||||
const messageType = Number.parseInt(attrType || extractXmlValue(body, 'datatype') || '0', 10)
|
||||
const sender = decodeXmlEntities(extractXmlValue(body, 'sourcename')) || undefined
|
||||
const sentAt = extractXmlValue(body, 'sourcetime') || undefined
|
||||
const title = decodeXmlEntities(extractXmlValue(body, 'datatitle'))
|
||||
const description = decodeXmlEntities(
|
||||
extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content')
|
||||
)
|
||||
const nestedXml = extractXmlBody(body, 'recordxml')
|
||||
const nested =
|
||||
messageType === 17 && nestedXml
|
||||
? parseForwardedItems(decodeXmlEntities(nestedXml), depth + 1)
|
||||
: undefined
|
||||
const text = description || title || forwardedTypeLabel(messageType)
|
||||
if (!sender && !text && !nested?.length) continue
|
||||
items.push({
|
||||
messageType: Number.isFinite(messageType) ? messageType : 0,
|
||||
sender,
|
||||
sentAt,
|
||||
text: text || '[消息]',
|
||||
nested: nested?.length ? nested : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
return dedupeForwardedItems(items)
|
||||
}
|
||||
|
||||
function dedupeForwardedItems(items: ForwardedMessageItem[]): ForwardedMessageItem[] {
|
||||
const seen = new Set<string>()
|
||||
return items.filter((item) => {
|
||||
const key = `${item.messageType}|${item.sender || ''}|${item.sentAt || ''}|${item.text}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function forwardedTypeLabel(messageType: number): string {
|
||||
switch (messageType) {
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
return '[语音]'
|
||||
case 43:
|
||||
return '[视频]'
|
||||
case 47:
|
||||
return '[表情包]'
|
||||
case 8:
|
||||
case 49:
|
||||
return '[文件或分享]'
|
||||
case 17:
|
||||
return '[聊天记录]'
|
||||
default:
|
||||
return '[消息]'
|
||||
}
|
||||
}
|
||||
|
||||
function extractXmlBody(xml: string, tagName: string): string {
|
||||
const match = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i').exec(xml)
|
||||
if (!match?.[1]) return ''
|
||||
return match[1].replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/, '$1').trim()
|
||||
}
|
||||
|
||||
function parseQuoteMessage(content: string): {
|
||||
content?: string
|
||||
sender?: string
|
||||
@@ -713,9 +822,7 @@ export function parseImageDatNameFromRow(row: Record<string, unknown>): string |
|
||||
return hexMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
export function parseImageBufferDataUrlFromRow(
|
||||
row: Record<string, unknown>
|
||||
): string | undefined {
|
||||
export function parseImageBufferDataUrlFromRow(row: Record<string, unknown>): string | undefined {
|
||||
const raw = pickRowString(row, [
|
||||
'ImgBuf',
|
||||
'imgBuf',
|
||||
|
||||
@@ -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'))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { AccountDiscoveryResult, WechatAccountCandidate } from '../../shared/database-key'
|
||||
import { DatabaseKeyStore } from '../database-key-store'
|
||||
import { getBootstrapCache } from './bootstrap-cache'
|
||||
import { validateDbRoot } from './settings-store'
|
||||
|
||||
function accountId(accountRoot: string): string {
|
||||
return crypto.createHash('sha256').update(path.resolve(accountRoot).toLowerCase()).digest('hex')
|
||||
}
|
||||
|
||||
export async function discoverAccounts(
|
||||
inputPath: string,
|
||||
keyStore: DatabaseKeyStore,
|
||||
currentAccountRoot?: string
|
||||
): Promise<AccountDiscoveryResult> {
|
||||
const validation = validateDbRoot(inputPath)
|
||||
if (!validation.valid) return { success: false, accounts: [], error: validation.error }
|
||||
|
||||
const normalizedInput = path.resolve(inputPath)
|
||||
const isAccount = await fs.pathExists(path.join(normalizedInput, 'db_storage'))
|
||||
const roots = isAccount
|
||||
? [normalizedInput]
|
||||
: (await fs.readdir(normalizedInput, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(normalizedInput, entry.name))
|
||||
.filter((candidate) => fs.existsSync(path.join(candidate, 'db_storage')))
|
||||
|
||||
const accounts: WechatAccountCandidate[] = await Promise.all(
|
||||
roots.map(async (accountRoot) => {
|
||||
const cached = getBootstrapCache(accountRoot)?.self
|
||||
return {
|
||||
id: accountId(accountRoot),
|
||||
accountRoot,
|
||||
directoryName: path.basename(accountRoot),
|
||||
wxid: cached?.wxid,
|
||||
nickname: cached?.nickname,
|
||||
avatar: cached?.avatar,
|
||||
hasSavedDbKey: (await keyStore.getStatus(accountRoot)).saved,
|
||||
loginStatus: currentAccountRoot
|
||||
? path.resolve(currentAccountRoot).toLowerCase() ===
|
||||
path.resolve(accountRoot).toLowerCase()
|
||||
? 'current'
|
||||
: 'other'
|
||||
: 'unknown',
|
||||
selectedByInput: isAccount
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
inputKind: isAccount ? 'account' : 'root',
|
||||
accounts,
|
||||
preselectedAccountId: isAccount ? accounts[0]?.id : undefined
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import type { AppSettings } from './settings-store'
|
||||
import { generateAgentGroupReport } from './agent-group-report-service'
|
||||
import { AIProviderService } from './ai-provider-service'
|
||||
import { isPackagedRuntime } from '../runtime-mode'
|
||||
import {
|
||||
getGroupSnapshot,
|
||||
isReady,
|
||||
@@ -68,7 +69,7 @@ const agentAIProvider = new AIProviderService()
|
||||
function resolveBundledBinary(
|
||||
resourceSegments: string[],
|
||||
executable: string,
|
||||
packaged = app.isPackaged,
|
||||
packaged = isPackagedRuntime(),
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
): string {
|
||||
@@ -80,7 +81,7 @@ function resolveBundledBinary(
|
||||
}
|
||||
|
||||
export function resolveWechatConnectorBinaryPath(
|
||||
packaged = app.isPackaged,
|
||||
packaged = isPackagedRuntime(),
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
): 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()
|
||||
@@ -24,135 +24,364 @@ export interface CachedGroupSnapshot {
|
||||
}[]
|
||||
}
|
||||
|
||||
interface CachedMessageBucket {
|
||||
interface StartupCacheFile {
|
||||
version: 2
|
||||
platform: NodeJS.Platform
|
||||
accountRoot: string
|
||||
updatedAt: number
|
||||
self?: CachedSelfInfo
|
||||
contacts: Contact[]
|
||||
}
|
||||
|
||||
interface CachedMessageBucketFile {
|
||||
version: 2
|
||||
platform: NodeJS.Platform
|
||||
accountRoot: string
|
||||
cacheKey: string
|
||||
updatedAt: number
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
items: Message[]
|
||||
}
|
||||
|
||||
interface BootstrapCacheFile {
|
||||
version: 1
|
||||
interface CachedGroupSnapshotFile {
|
||||
version: 2
|
||||
platform: NodeJS.Platform
|
||||
accountRoot: string
|
||||
userMd5: string
|
||||
updatedAt: number
|
||||
self?: CachedSelfInfo
|
||||
contacts?: Contact[]
|
||||
messages?: Record<string, CachedMessageBucket>
|
||||
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
|
||||
snapshot: CachedGroupSnapshot
|
||||
}
|
||||
|
||||
const CACHE_VERSION = 1
|
||||
interface ScheduledWrite {
|
||||
value: unknown
|
||||
revision: number
|
||||
generation: number
|
||||
cleanupFile?: string
|
||||
prune?: { directory: string; maxFiles: number }
|
||||
}
|
||||
|
||||
interface AccountCachePaths {
|
||||
root: string
|
||||
startup: string
|
||||
messages: string
|
||||
groups: string
|
||||
legacy: string
|
||||
}
|
||||
|
||||
const CACHE_VERSION = 2
|
||||
const MAX_MESSAGE_BUCKETS = 768
|
||||
const MAX_GROUP_SNAPSHOTS = 768
|
||||
const MAX_MESSAGES_PER_BUCKET = 120
|
||||
const MAX_MEMORY_MESSAGE_BUCKETS = 32
|
||||
const MAX_MEMORY_GROUP_SNAPSHOTS = 32
|
||||
const WRITE_DEBOUNCE_MS = 300
|
||||
const memoryCache = new Map<string, BootstrapCacheFile>()
|
||||
const PRUNE_INTERVAL_MS = 30_000
|
||||
|
||||
const startupMemory = new Map<string, StartupCacheFile>()
|
||||
const messageMemory = new Map<string, CachedMessageBucketFile>()
|
||||
const groupMemory = new Map<string, CachedGroupSnapshotFile>()
|
||||
const writeTimers = new Map<string, NodeJS.Timeout>()
|
||||
const writeQueues = new Map<string, Promise<void>>()
|
||||
const scheduledWrites = new Map<string, ScheduledWrite>()
|
||||
const writeRevisions = new Map<string, number>()
|
||||
const lastPrunedAt = new Map<string, number>()
|
||||
let cacheGeneration = 0
|
||||
|
||||
function normalizeRoot(accountRoot?: string): string {
|
||||
return String(accountRoot || '').trim()
|
||||
}
|
||||
|
||||
function getCacheFile(accountRoot?: string): string {
|
||||
const normalizedRoot = normalizeRoot(accountRoot) || 'default'
|
||||
const hash = crypto
|
||||
.createHash('sha1')
|
||||
.update(`${process.platform}:${normalizedRoot}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
return path.join(
|
||||
app.getPath('userData'),
|
||||
'cache',
|
||||
'bootstrap',
|
||||
`${process.platform}-${hash}.json`
|
||||
)
|
||||
function digest(value: string): string {
|
||||
return crypto.createHash('sha1').update(value).digest('hex').slice(0, 24)
|
||||
}
|
||||
|
||||
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||
function getAccountCachePaths(accountRoot: string): AccountCachePaths {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getCacheFile(normalizedRoot)
|
||||
const cached = memoryCache.get(file)
|
||||
if (cached) return cached
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
|
||||
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
|
||||
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
|
||||
const result: BootstrapCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
self: raw.self,
|
||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {},
|
||||
groupSnapshots:
|
||||
raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {}
|
||||
}
|
||||
memoryCache.set(file, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] read failed:', error)
|
||||
return null
|
||||
const accountKey = digest(`${process.platform}:${normalizedRoot}`)
|
||||
const bootstrapRoot = path.join(app.getPath('userData'), 'cache', 'bootstrap')
|
||||
const root = path.join(bootstrapRoot, `${process.platform}-${accountKey}`)
|
||||
return {
|
||||
root,
|
||||
startup: path.join(root, 'startup.json'),
|
||||
messages: path.join(root, 'messages'),
|
||||
groups: path.join(root, 'groups'),
|
||||
legacy: path.join(bootstrapRoot, `${process.platform}-${accountKey.slice(0, 16)}.json`)
|
||||
}
|
||||
}
|
||||
|
||||
function writeCacheFile(cache: BootstrapCacheFile): void {
|
||||
const file = getCacheFile(cache.accountRoot)
|
||||
memoryCache.set(file, cache)
|
||||
const existingTimer = writeTimers.get(file)
|
||||
if (existingTimer) clearTimeout(existingTimer)
|
||||
writeTimers.set(
|
||||
file,
|
||||
setTimeout(() => {
|
||||
writeTimers.delete(file)
|
||||
const serialized = JSON.stringify(memoryCache.get(file) || cache)
|
||||
const previous = writeQueues.get(file) || Promise.resolve()
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.ensureDir(path.dirname(file))
|
||||
await fs.writeFile(file, serialized, 'utf8')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[BootstrapCache] write failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (writeQueues.get(file) === next) writeQueues.delete(file)
|
||||
})
|
||||
writeQueues.set(file, next)
|
||||
}, WRITE_DEBOUNCE_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const existing = readCacheFile(normalizedRoot)
|
||||
if (existing) return existing
|
||||
const created: BootstrapCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Date.now(),
|
||||
contacts: [],
|
||||
messages: {},
|
||||
groupSnapshots: {}
|
||||
}
|
||||
memoryCache.set(getCacheFile(normalizedRoot), created)
|
||||
return created
|
||||
}
|
||||
|
||||
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
|
||||
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 getMessageCacheFile(accountRoot: string, cacheKey: string): string {
|
||||
return path.join(getAccountCachePaths(accountRoot).messages, `${digest(cacheKey)}.json`)
|
||||
}
|
||||
|
||||
function getGroupCacheFile(accountRoot: string, userMd5: string): string {
|
||||
return path.join(getAccountCachePaths(accountRoot).groups, `${digest(userMd5)}.json`)
|
||||
}
|
||||
|
||||
function readScheduledValue<T>(file: string): T | null {
|
||||
const scheduled = scheduledWrites.get(file)
|
||||
return scheduled ? (scheduled.value as T) : null
|
||||
}
|
||||
|
||||
function touchMemory<T>(memory: Map<string, T>, file: string, value: T, maxEntries: number): void {
|
||||
memory.delete(file)
|
||||
memory.set(file, value)
|
||||
while (memory.size > maxEntries) {
|
||||
const oldest = memory.keys().next().value
|
||||
if (!oldest) break
|
||||
memory.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentAccountFile(
|
||||
value: {
|
||||
version?: number
|
||||
platform?: NodeJS.Platform
|
||||
accountRoot?: string
|
||||
},
|
||||
accountRoot: string
|
||||
): boolean {
|
||||
return (
|
||||
value.version === CACHE_VERSION &&
|
||||
value.platform === process.platform &&
|
||||
normalizeRoot(value.accountRoot) === normalizeRoot(accountRoot)
|
||||
)
|
||||
}
|
||||
|
||||
function readStartupCacheFile(accountRoot: string): StartupCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getAccountCachePaths(normalizedRoot).startup
|
||||
const scheduled = readScheduledValue<StartupCacheFile>(file)
|
||||
if (scheduled) return scheduled
|
||||
const memory = startupMemory.get(file)
|
||||
if (memory) return memory
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<StartupCacheFile>
|
||||
if (!isCurrentAccountFile(raw, normalizedRoot)) return null
|
||||
const result: StartupCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
self: raw.self,
|
||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : []
|
||||
}
|
||||
startupMemory.set(file, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] startup read failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readMessageBucketFile(
|
||||
accountRoot: string,
|
||||
cacheKey: string
|
||||
): CachedMessageBucketFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getMessageCacheFile(normalizedRoot, cacheKey)
|
||||
const scheduled = readScheduledValue<CachedMessageBucketFile>(file)
|
||||
if (scheduled) return scheduled
|
||||
const memory = messageMemory.get(file)
|
||||
if (memory) {
|
||||
touchMemory(messageMemory, file, memory, MAX_MEMORY_MESSAGE_BUCKETS)
|
||||
return memory
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<CachedMessageBucketFile>
|
||||
if (!isCurrentAccountFile(raw, normalizedRoot) || raw.cacheKey !== cacheKey) return null
|
||||
const result: CachedMessageBucketFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
cacheKey,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
startTime: raw.startTime,
|
||||
endTime: raw.endTime,
|
||||
items: Array.isArray(raw.items) ? raw.items : []
|
||||
}
|
||||
touchMemory(messageMemory, file, result, MAX_MEMORY_MESSAGE_BUCKETS)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] message read failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readGroupSnapshotFile(
|
||||
accountRoot: string,
|
||||
userMd5: string
|
||||
): CachedGroupSnapshotFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getGroupCacheFile(normalizedRoot, userMd5)
|
||||
const scheduled = readScheduledValue<CachedGroupSnapshotFile>(file)
|
||||
if (scheduled) return scheduled
|
||||
const memory = groupMemory.get(file)
|
||||
if (memory) {
|
||||
touchMemory(groupMemory, file, memory, MAX_MEMORY_GROUP_SNAPSHOTS)
|
||||
return memory
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<CachedGroupSnapshotFile>
|
||||
if (!isCurrentAccountFile(raw, normalizedRoot) || raw.userMd5 !== userMd5 || !raw.snapshot) {
|
||||
return null
|
||||
}
|
||||
const result: CachedGroupSnapshotFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
userMd5,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
snapshot: raw.snapshot
|
||||
}
|
||||
touchMemory(groupMemory, file, result, MAX_MEMORY_GROUP_SNAPSHOTS)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] group snapshot read failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function pruneCacheDirectory(directory: string, maxFiles: number): Promise<void> {
|
||||
const now = Date.now()
|
||||
if (now - (lastPrunedAt.get(directory) || 0) < PRUNE_INTERVAL_MS) return
|
||||
lastPrunedAt.set(directory, now)
|
||||
|
||||
try {
|
||||
const names = (await fs.readdir(directory)).filter((name) => name.endsWith('.json'))
|
||||
if (names.length <= maxFiles) return
|
||||
const entries = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const file = path.join(directory, name)
|
||||
const stat = await fs.stat(file)
|
||||
return { file, modifiedAt: stat.mtimeMs }
|
||||
})
|
||||
)
|
||||
const expired = entries
|
||||
.sort((left, right) => right.modifiedAt - left.modifiedAt)
|
||||
.slice(maxFiles)
|
||||
await Promise.all(
|
||||
expired.map(async ({ file }) => {
|
||||
messageMemory.delete(file)
|
||||
groupMemory.delete(file)
|
||||
await fs.remove(file)
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn('[BootstrapCache] prune failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queueWrite(file: string): void {
|
||||
const scheduled = scheduledWrites.get(file)
|
||||
if (!scheduled) return
|
||||
const previous = writeQueues.get(file) || Promise.resolve()
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const current = scheduledWrites.get(file)
|
||||
if (
|
||||
!current ||
|
||||
current.revision !== scheduled.revision ||
|
||||
current.generation !== cacheGeneration
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const tempFile = `${file}.${process.pid}.${scheduled.revision}.tmp`
|
||||
await fs.ensureDir(path.dirname(file))
|
||||
await fs.writeFile(tempFile, JSON.stringify(current.value), 'utf8')
|
||||
const latest = scheduledWrites.get(file)
|
||||
if (
|
||||
!latest ||
|
||||
latest.revision !== scheduled.revision ||
|
||||
latest.generation !== cacheGeneration
|
||||
) {
|
||||
await fs.remove(tempFile)
|
||||
return
|
||||
}
|
||||
await fs.move(tempFile, file, { overwrite: true })
|
||||
const completed = scheduledWrites.get(file)
|
||||
if (
|
||||
completed?.revision === scheduled.revision &&
|
||||
completed.generation === scheduled.generation
|
||||
) {
|
||||
scheduledWrites.delete(file)
|
||||
}
|
||||
if (current.cleanupFile) await fs.remove(current.cleanupFile)
|
||||
if (current.prune) {
|
||||
void pruneCacheDirectory(current.prune.directory, current.prune.maxFiles)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[BootstrapCache] write failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (writeQueues.get(file) === next) writeQueues.delete(file)
|
||||
})
|
||||
writeQueues.set(file, next)
|
||||
}
|
||||
|
||||
function scheduleWrite(
|
||||
file: string,
|
||||
value: unknown,
|
||||
options?: { cleanupFile?: string; prune?: { directory: string; maxFiles: number } }
|
||||
): void {
|
||||
const existingTimer = writeTimers.get(file)
|
||||
if (existingTimer) clearTimeout(existingTimer)
|
||||
const revision = (writeRevisions.get(file) || 0) + 1
|
||||
writeRevisions.set(file, revision)
|
||||
scheduledWrites.set(file, {
|
||||
value,
|
||||
revision,
|
||||
generation: cacheGeneration,
|
||||
cleanupFile: options?.cleanupFile,
|
||||
prune: options?.prune
|
||||
})
|
||||
writeTimers.set(
|
||||
file,
|
||||
setTimeout(() => {
|
||||
writeTimers.delete(file)
|
||||
queueWrite(file)
|
||||
}, WRITE_DEBOUNCE_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function loadOrCreateStartupCache(accountRoot: string): StartupCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const existing = readStartupCacheFile(normalizedRoot)
|
||||
if (existing) return existing
|
||||
const created: StartupCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Date.now(),
|
||||
contacts: []
|
||||
}
|
||||
startupMemory.set(getAccountCachePaths(normalizedRoot).startup, created)
|
||||
return created
|
||||
}
|
||||
|
||||
function writeStartupCache(cache: StartupCacheFile): void {
|
||||
const paths = getAccountCachePaths(cache.accountRoot)
|
||||
startupMemory.set(paths.startup, cache)
|
||||
scheduleWrite(paths.startup, cache, { cleanupFile: paths.legacy })
|
||||
}
|
||||
|
||||
function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
|
||||
@@ -160,8 +389,7 @@ function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
|
||||
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)
|
||||
/<weappinfo\b/i.test(content.raw) && /<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
|
||||
)
|
||||
}
|
||||
if (
|
||||
@@ -176,27 +404,16 @@ function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
||||
const entries = Object.entries(messages)
|
||||
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
||||
entries
|
||||
.sort((left, right) => (right[1].updatedAt || 0) - (left[1].updatedAt || 0))
|
||||
.slice(MAX_MESSAGE_BUCKETS)
|
||||
.forEach(([key]) => {
|
||||
delete messages[key]
|
||||
})
|
||||
}
|
||||
|
||||
export function getBootstrapCache(accountRoot?: string): {
|
||||
self?: CachedSelfInfo
|
||||
contacts: Contact[]
|
||||
updatedAt: number
|
||||
} | null {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const cache = readStartupCacheFile(normalizeRoot(accountRoot))
|
||||
if (!cache) return null
|
||||
return {
|
||||
self: cache.self,
|
||||
contacts: cache.contacts || [],
|
||||
contacts: cache.contacts,
|
||||
updatedAt: cache.updatedAt
|
||||
}
|
||||
}
|
||||
@@ -212,8 +429,8 @@ function isRawContactName(contact: Contact): boolean {
|
||||
}
|
||||
|
||||
export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact[]): Contact[] {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
if (!cache?.contacts?.length) return contacts
|
||||
const cache = readStartupCacheFile(accountRoot)
|
||||
if (!cache?.contacts.length) return contacts
|
||||
const avatarByUsername = new Map(
|
||||
cache.contacts
|
||||
.filter((contact) => contact.m_nsUsrName && contact.avatar)
|
||||
@@ -241,23 +458,23 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
|
||||
}
|
||||
|
||||
export function saveBootstrapSelf(accountRoot: string, self: CachedSelfInfo): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
const cache = loadOrCreateStartupCache(accountRoot)
|
||||
if (!cache) return
|
||||
cache.self = self
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
writeStartupCache(cache)
|
||||
}
|
||||
|
||||
export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
const cache = loadOrCreateStartupCache(accountRoot)
|
||||
if (!cache) return
|
||||
const avatarByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
cache.contacts
|
||||
.filter((contact) => contact.m_nsUsrName && contact.avatar)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.avatar as string])
|
||||
)
|
||||
const nameByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
cache.contacts
|
||||
.filter(
|
||||
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||
)
|
||||
@@ -275,12 +492,12 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]):
|
||||
: nameByUsername.get(contact.m_nsUsrName) || contact.m_nsNickName
|
||||
}))
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
writeStartupCache(cache)
|
||||
}
|
||||
|
||||
export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<string, string>): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache || !cache.contacts?.length) return
|
||||
const cache = loadOrCreateStartupCache(accountRoot)
|
||||
if (!cache?.contacts.length) return
|
||||
let changed = false
|
||||
cache.contacts = cache.contacts.map((contact) => {
|
||||
const avatar = avatars[contact.m_nsUsrName]
|
||||
@@ -290,7 +507,7 @@ export function mergeBootstrapAvatars(accountRoot: string, avatars: Record<strin
|
||||
})
|
||||
if (!changed) return
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
writeStartupCache(cache)
|
||||
}
|
||||
|
||||
export function getCachedMessages(
|
||||
@@ -299,9 +516,9 @@ export function getCachedMessages(
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Message[] {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const bucket = cache?.messages?.[messageBucketKey(userMd5, startTime, endTime)]
|
||||
return bucket?.items || []
|
||||
return (
|
||||
readMessageBucketFile(accountRoot, messageBucketKey(userMd5, startTime, endTime))?.items || []
|
||||
)
|
||||
}
|
||||
|
||||
export function getCachedMessagePage(
|
||||
@@ -310,35 +527,12 @@ export function getCachedMessagePage(
|
||||
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)
|
||||
}
|
||||
}
|
||||
const bucket = readMessageBucketFile(accountRoot, messageBucketKey(userMd5, startTime, endTime))
|
||||
const messages = bucket?.items || []
|
||||
return {
|
||||
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []),
|
||||
messages: bucket?.items || [],
|
||||
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
|
||||
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(messages),
|
||||
messages,
|
||||
groupSnapshot: readGroupSnapshotFile(accountRoot, userMd5)?.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,26 +541,22 @@ export function saveCachedGroupSnapshot(
|
||||
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)
|
||||
}
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot || !userMd5) return
|
||||
const paths = getAccountCachePaths(normalizedRoot)
|
||||
const file = getGroupCacheFile(normalizedRoot, userMd5)
|
||||
const value: CachedGroupSnapshotFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
userMd5,
|
||||
updatedAt: Date.now(),
|
||||
snapshot
|
||||
}
|
||||
touchMemory(groupMemory, file, value, MAX_MEMORY_GROUP_SNAPSHOTS)
|
||||
scheduleWrite(file, value, {
|
||||
prune: { directory: paths.groups, maxFiles: MAX_GROUP_SNAPSHOTS }
|
||||
})
|
||||
}
|
||||
|
||||
export function saveCachedMessages(
|
||||
@@ -376,17 +566,52 @@ export function saveCachedMessages(
|
||||
endTime: number | undefined,
|
||||
messages: Message[]
|
||||
): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
const nextMessages = cache.messages || {}
|
||||
nextMessages[messageBucketKey(userMd5, startTime, endTime)] = {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot || !userMd5) return
|
||||
const cacheKey = messageBucketKey(userMd5, startTime, endTime)
|
||||
const paths = getAccountCachePaths(normalizedRoot)
|
||||
const file = getMessageCacheFile(normalizedRoot, cacheKey)
|
||||
const value: CachedMessageBucketFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
cacheKey,
|
||||
updatedAt: Date.now(),
|
||||
startTime,
|
||||
endTime,
|
||||
items: messages.slice(-MAX_MESSAGES_PER_BUCKET)
|
||||
}
|
||||
pruneMessageBuckets(nextMessages)
|
||||
cache.messages = nextMessages
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
touchMemory(messageMemory, file, value, MAX_MEMORY_MESSAGE_BUCKETS)
|
||||
scheduleWrite(file, value, {
|
||||
prune: { directory: paths.messages, maxFiles: MAX_MESSAGE_BUCKETS }
|
||||
})
|
||||
}
|
||||
|
||||
export function flushBootstrapCacheWritesSync(): void {
|
||||
for (const timer of writeTimers.values()) clearTimeout(timer)
|
||||
writeTimers.clear()
|
||||
const writes = Array.from(scheduledWrites.entries())
|
||||
scheduledWrites.clear()
|
||||
for (const [file, scheduled] of writes) {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(file))
|
||||
fs.writeFileSync(file, JSON.stringify(scheduled.value), 'utf8')
|
||||
if (scheduled.cleanupFile) fs.removeSync(scheduled.cleanupFile)
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] flush failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearBootstrapCache(): void {
|
||||
cacheGeneration += 1
|
||||
for (const timer of writeTimers.values()) clearTimeout(timer)
|
||||
writeTimers.clear()
|
||||
scheduledWrites.clear()
|
||||
writeQueues.clear()
|
||||
writeRevisions.clear()
|
||||
lastPrunedAt.clear()
|
||||
startupMemory.clear()
|
||||
messageMemory.clear()
|
||||
groupMemory.clear()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -37,6 +37,8 @@ export interface FormattedContact {
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
isFolded?: boolean
|
||||
isMuted?: boolean
|
||||
}
|
||||
|
||||
export interface FormattedMessage {
|
||||
@@ -140,7 +142,9 @@ export function listContacts(filter?: string): FormattedContact[] {
|
||||
type: isGroup ? 'group' : 'user',
|
||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
||||
wechatNickname: user.wechatNickname,
|
||||
remark: user.remark
|
||||
remark: user.remark,
|
||||
isFolded: user.isFolded,
|
||||
isMuted: user.isMuted
|
||||
})
|
||||
}
|
||||
|
||||
@@ -172,13 +176,24 @@ export function listContacts(filter?: string): FormattedContact[] {
|
||||
return contacts
|
||||
}
|
||||
|
||||
export function getContactAvatars(usernames: string[]): Record<string, string> {
|
||||
export async function listContactsAsync(filter?: string): Promise<FormattedContact[]> {
|
||||
if (!dbRef) return []
|
||||
await dbRef.getWcdb4Client().getSessionsAsync({
|
||||
// macOS session rows frequently contain only wxid/chatroom ids. Hydrate
|
||||
// contact display names before exposing the list to the renderer.
|
||||
hydrateDisplayNames: true,
|
||||
hydrateStatuses: true
|
||||
})
|
||||
return listContacts(filter)
|
||||
}
|
||||
|
||||
export async function getContactAvatars(usernames: string[]): Promise<Record<string, string>> {
|
||||
if (!dbRef) return {}
|
||||
const normalized = Array.from(
|
||||
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (normalized.length === 0) return {}
|
||||
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
||||
return dbRef.getWcdb4Client().getAvatarUrlsAsync(normalized)
|
||||
}
|
||||
|
||||
function listSourceMessages(
|
||||
@@ -245,7 +260,13 @@ function listSourceMessages(
|
||||
const patContent =
|
||||
system.type === 'system'
|
||||
? { ...system, pat: true }
|
||||
: { type: 'system' as const, content: String(content || '').replace(/<[^>]+>/g, '').trim(), pat: true }
|
||||
: {
|
||||
type: 'system' as const,
|
||||
content: String(content || '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim(),
|
||||
pat: true
|
||||
}
|
||||
contentData = patContent
|
||||
content = patContent.content
|
||||
displayType = '系统消息'
|
||||
@@ -259,11 +280,9 @@ function listSourceMessages(
|
||||
try {
|
||||
const isQuotePayload = /<refermsg\b/i.test(content)
|
||||
const hasStickerPayload =
|
||||
/<(?:emoji|sticker|emoticon)\b/i.test(content) ||
|
||||
/<type>\s*47\s*<\/type>/i.test(content)
|
||||
/<(?:emoji|sticker|emoticon)\b/i.test(content) || /<type>\s*47\s*<\/type>/i.test(content)
|
||||
const rowSticker =
|
||||
inferredMsgType === 47 ||
|
||||
(inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
||||
inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
||||
? parseStickerMessageFromRow(msg, content)
|
||||
: undefined
|
||||
const parsedContent = parseMessageContent(content, inferredMsgType)
|
||||
@@ -295,7 +314,7 @@ function listSourceMessages(
|
||||
if (parsed.type === 'system') {
|
||||
content = parsed.content
|
||||
contentData = parsed
|
||||
} else if (parsed.type !== 'unknown') {
|
||||
} else {
|
||||
content = ''
|
||||
}
|
||||
if (parsed.type === 'image') {
|
||||
@@ -305,8 +324,7 @@ function listSourceMessages(
|
||||
contentData = {
|
||||
...parsed,
|
||||
thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg),
|
||||
thumbDataUrl:
|
||||
parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
|
||||
thumbDataUrl: parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
|
||||
}
|
||||
} else if (parsed.type !== 'system') {
|
||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
||||
@@ -321,6 +339,11 @@ function listSourceMessages(
|
||||
if (parsed.type === 'sticker') displayType = '表情包'
|
||||
if (parsed.type === 'miniProgram') displayType = '小程序'
|
||||
if (parsed.type === 'redPacket') displayType = '微信红包'
|
||||
if (parsed.type === 'forwardBundle') displayType = '合并转发'
|
||||
if (parsed.type === 'unknown') {
|
||||
displayType = '不支持的消息'
|
||||
contentData = { ...parsed, messageType: msgType }
|
||||
}
|
||||
if (parsed.type === 'share') {
|
||||
if (parsed.typeVal === '5') displayType = '公众号链接'
|
||||
if (parsed.typeVal === '6') displayType = '文件'
|
||||
@@ -345,6 +368,12 @@ function listSourceMessages(
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentData && !MSG_TYPE_DICT[msgType] && msgType !== 0) {
|
||||
contentData = { type: 'unknown', raw: rawContent, messageType: msgType }
|
||||
content = ''
|
||||
displayType = '不支持的消息'
|
||||
}
|
||||
|
||||
if (msgType === 34) content = '[语音消息]'
|
||||
|
||||
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
|
||||
@@ -397,13 +426,7 @@ export async function listMessagesAsync(
|
||||
): Promise<FormattedMessage[]> {
|
||||
if (!dbRef) return []
|
||||
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
|
||||
const sourceMessages = listSourceMessages(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
rawMessages
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { execFile } from 'child_process'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { isUsableDbRoot } from './settings-store'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const platformLabel = (): string => {
|
||||
if (process.platform === 'win32') return `Windows ${os.release()} (${process.arch})`
|
||||
if (process.platform === 'darwin') return `macOS ${os.release()} (${process.arch})`
|
||||
return `${process.platform} ${os.release()} (${process.arch})`
|
||||
}
|
||||
|
||||
async function detectWindowsWechatVersion(): Promise<string> {
|
||||
const script = [
|
||||
'$process = Get-Process Weixin,WeChat -ErrorAction SilentlyContinue | Where-Object Path | Select-Object -First 1',
|
||||
'$candidate = if ($process) { $process.Path } else {',
|
||||
" @($env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ } | ForEach-Object { Join-Path $_ 'Tencent\\WeChat\\WeChat.exe' } | Where-Object { Test-Path $_ } | Select-Object -First 1",
|
||||
'}',
|
||||
'if ($candidate) { (Get-Item -LiteralPath $candidate).VersionInfo.ProductVersion }'
|
||||
].join('; ')
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-Command', script],
|
||||
{ timeout: 3000, windowsHide: true }
|
||||
)
|
||||
return stdout.trim() || '未检测到'
|
||||
} catch {
|
||||
return '未检测到'
|
||||
}
|
||||
}
|
||||
|
||||
async function detectMacWechatVersion(): Promise<string> {
|
||||
const candidates = [
|
||||
'/Applications/WeChat.app/Contents/Info',
|
||||
path.join(os.homedir(), 'Applications/WeChat.app/Contents/Info')
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(`${candidate}.plist`)) continue
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'/usr/bin/defaults',
|
||||
['read', candidate, 'CFBundleShortVersionString'],
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
if (stdout.trim()) return stdout.trim()
|
||||
} catch {
|
||||
// Continue to the next known installation location.
|
||||
}
|
||||
}
|
||||
return '未检测到'
|
||||
}
|
||||
|
||||
export async function detectWechatVersion(): Promise<string> {
|
||||
if (process.platform === 'win32') return detectWindowsWechatVersion()
|
||||
if (process.platform === 'darwin') return detectMacWechatVersion()
|
||||
return '未检测到'
|
||||
}
|
||||
|
||||
export function detectDataStructureVersion(dbRoot: string): string {
|
||||
return isUsableDbRoot(dbRoot) ? '微信 4.x(WCDB)' : '未检测到'
|
||||
}
|
||||
|
||||
export function getOsVersionLabel(): string {
|
||||
return platformLabel()
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
ImageResourceCheck,
|
||||
TestImageDecryptionRequest
|
||||
} from '../../shared/image-decryption'
|
||||
import { ImageDecryptService } from '../image-decrypt-service'
|
||||
import { ImageDecryptService, inspectImageDecoderStatus } from '../image-decrypt-service'
|
||||
import * as chat from './chat-service'
|
||||
import { validateImageKeyRequest } from './image-key-config-service'
|
||||
import { isWechatRunning } from './wechat-process-status'
|
||||
@@ -25,6 +25,10 @@ export async function inspectImageDecryptionStatus(
|
||||
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
||||
fs.existsSync(path.join(os.homedir(), 'Documents', 'WechatExplorer', 'Emojis'))
|
||||
const dbConnected = chat.isReady()
|
||||
const [wechatRunning, decoder] = await Promise.all([
|
||||
isWechatRunning(),
|
||||
inspectImageDecoderStatus()
|
||||
])
|
||||
|
||||
return {
|
||||
configured: config.configured,
|
||||
@@ -36,9 +40,10 @@ export async function inspectImageDecryptionStatus(
|
||||
updatedAt: config.updatedAt,
|
||||
platform: process.platform,
|
||||
autoDetectSupported: process.platform === 'win32' || process.platform === 'darwin',
|
||||
wechatRunning: await isWechatRunning(),
|
||||
wechatRunning,
|
||||
accountIdentified: Boolean(chat.getSelfAccountInfo()?.wxid),
|
||||
cacheState: canUseCacheRoot() ? 'normal' : 'unavailable',
|
||||
decoder,
|
||||
resources: {
|
||||
imageIndex: check(dbConnected, dbConnected ? '可用' : '数据库尚未连接'),
|
||||
imageDirectory: check(imageDirectoryFound, imageDirectoryFound ? '已找到' : '未找到'),
|
||||
|
||||
@@ -28,10 +28,14 @@ export interface AppSettings {
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
imageKeyFallbackDisabled: boolean
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
}
|
||||
|
||||
function getDefaultDbRoot(): string {
|
||||
@@ -82,7 +86,7 @@ function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values))
|
||||
}
|
||||
|
||||
function isUsableDbRoot(candidate?: string): boolean {
|
||||
export function isUsableDbRoot(candidate?: string): boolean {
|
||||
if (!candidate || !fs.existsSync(candidate)) return false
|
||||
if (fs.existsSync(path.join(candidate, 'db_storage'))) return true
|
||||
try {
|
||||
@@ -94,6 +98,21 @@ function isUsableDbRoot(candidate?: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDbRoot(candidate?: string): { valid: boolean; error?: string } {
|
||||
const root = String(candidate || '').trim()
|
||||
if (!root) return { valid: false, error: '微信数据目录为空,请重新选择目录' }
|
||||
if (!fs.existsSync(root)) {
|
||||
return { valid: false, error: '微信数据目录不存在,请检查路径或重新选择目录' }
|
||||
}
|
||||
if (!isUsableDbRoot(root)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: '所选目录中未找到微信 4.x 数据库(db_storage),请选择 xwechat_files 或账号目录'
|
||||
}
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const defaultDbRoot = getDefaultDbRoot()
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -105,6 +124,7 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: false,
|
||||
ffmpegPath: '',
|
||||
recallProtectionEnabled: false,
|
||||
debugEnabled: false,
|
||||
autoLogin: ['1', 'true', 'yes', 'on'].includes(
|
||||
@@ -112,7 +132,10 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
),
|
||||
autoLoginPreferenceSet: false
|
||||
autoLoginPreferenceSet: false,
|
||||
appearanceTheme: 'system',
|
||||
compactMode: false,
|
||||
showStartupProgress: true
|
||||
}
|
||||
|
||||
const SETTINGS_FILE = path.join(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app, shell } from 'electron'
|
||||
import { existsSync, promises as fs } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { isPackagedRuntime } from '../runtime-mode'
|
||||
|
||||
const SKILL_RELATIVE_PATH = join('skill', 'wechatexplorer-reader', 'SKILL.md')
|
||||
const GITHUB_URL =
|
||||
@@ -23,7 +24,7 @@ function getSkillCandidates(): { path: string; source: 'development' | 'bundled'
|
||||
join(dirname(app.getAppPath()), SKILL_RELATIVE_PATH),
|
||||
join(dirname(process.execPath), 'resources', SKILL_RELATIVE_PATH)
|
||||
]
|
||||
return app.isPackaged
|
||||
return isPackagedRuntime()
|
||||
? bundledPaths.map((path) => ({ path, source: 'bundled' as const }))
|
||||
: [
|
||||
{ path: developmentPath, source: 'development' as const },
|
||||
|
||||
@@ -5,8 +5,15 @@ import https from 'https'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
import { classifyStickerHttpFailure, StickerFailureCode } from '../shared/sticker'
|
||||
|
||||
type StickerResult = { success: boolean; data?: string; error?: string }
|
||||
type StickerResult = {
|
||||
success: boolean
|
||||
data?: string
|
||||
error?: string
|
||||
failureCode?: StickerFailureCode
|
||||
httpStatus?: number
|
||||
}
|
||||
|
||||
const downloadCache = new Map<string, Promise<StickerResult>>()
|
||||
|
||||
@@ -129,15 +136,24 @@ export class StickerService {
|
||||
const redirectUrl = response.headers.location
|
||||
if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) {
|
||||
const nextUrl = new URL(redirectUrl, url).toString()
|
||||
response.resume()
|
||||
this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
const statusCode = Number(response.statusCode || 0)
|
||||
const failure = classifyStickerHttpFailure(statusCode, url)
|
||||
response.resume()
|
||||
console.warn(
|
||||
`[StickerService] download failed: HTTP ${response.statusCode}; md5=${cacheKey}; url=${url}`
|
||||
`[StickerService] download failed code=${failure.code} status=${statusCode} md5=${cacheKey} host=${this.getUrlHost(url)}`
|
||||
)
|
||||
resolve({ success: false, error: `表情包下载失败: HTTP ${response.statusCode}` })
|
||||
resolve({
|
||||
success: false,
|
||||
error: failure.message,
|
||||
failureCode: failure.code,
|
||||
httpStatus: statusCode
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,6 +214,14 @@ export class StickerService {
|
||||
}
|
||||
}
|
||||
|
||||
private getUrlHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname || 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
private toDataUrl(buffer: Buffer, ext: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.gif': 'image/gif',
|
||||
|
||||
@@ -10,6 +10,7 @@ type VideoAsset = {
|
||||
|
||||
export class VideoAssetService {
|
||||
private readonly urlTokens = new Map<string, string>()
|
||||
private readonly fileTokens = new Map<string, string>()
|
||||
private index: Map<string, VideoAsset> | null = null
|
||||
|
||||
constructor(private readonly client: Wcdb4Client) {}
|
||||
@@ -48,8 +49,8 @@ export class VideoAssetService {
|
||||
if (!asset) continue
|
||||
return {
|
||||
success: true,
|
||||
url: this.createUrl(asset.filePath),
|
||||
poster: asset.posterPath ? this.createUrl(asset.posterPath) : undefined
|
||||
url: this.createLocalMediaUrl(asset.filePath),
|
||||
poster: asset.posterPath ? this.createLocalMediaUrl(asset.posterPath) : undefined
|
||||
}
|
||||
}
|
||||
return { success: false, error: '本地未找到该视频文件' }
|
||||
@@ -61,12 +62,33 @@ export class VideoAssetService {
|
||||
return filePath
|
||||
}
|
||||
|
||||
private createUrl(filePath: string): string {
|
||||
pathForUrl(url: string): string | undefined {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'wxe-media:' || parsed.hostname !== 'local') return undefined
|
||||
return this.pathForToken(parsed.pathname.replace(/^\/+/, ''))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
createLocalMediaUrl(filePath: string): string {
|
||||
const normalizedPath = path.resolve(filePath)
|
||||
const existingToken = this.fileTokens.get(normalizedPath)
|
||||
if (existingToken && this.urlTokens.get(existingToken) === normalizedPath) {
|
||||
return `wxe-media://local/${existingToken}`
|
||||
}
|
||||
|
||||
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)
|
||||
this.urlTokens.set(token, normalizedPath)
|
||||
this.fileTokens.set(normalizedPath, token)
|
||||
if (this.urlTokens.size > 2048) {
|
||||
const oldestToken = this.urlTokens.keys().next().value
|
||||
if (oldestToken) {
|
||||
const oldestPath = this.urlTokens.get(oldestToken)
|
||||
this.urlTokens.delete(oldestToken)
|
||||
if (oldestPath) this.fileTokens.delete(oldestPath)
|
||||
}
|
||||
}
|
||||
return `wxe-media://local/${token}`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
import { isPackagedRuntime } from './runtime-mode'
|
||||
|
||||
export class VoiceService {
|
||||
private wcdb4Client: Wcdb4Client
|
||||
@@ -101,7 +102,7 @@ export class VoiceService {
|
||||
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
|
||||
try {
|
||||
let wasmPath: string
|
||||
if (app.isPackaged) {
|
||||
if (isPackagedRuntime()) {
|
||||
wasmPath = join(
|
||||
process.resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface Wcdb4Session {
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
isFolded?: boolean
|
||||
isMuted?: boolean
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -32,6 +34,11 @@ export interface Wcdb4MessageQueryOptions {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface Wcdb4SessionQueryOptions {
|
||||
hydrateDisplayNames?: boolean
|
||||
hydrateStatuses?: boolean
|
||||
}
|
||||
|
||||
type Wcdb4MessageStore = {
|
||||
tableName: string
|
||||
dbPath: string
|
||||
@@ -173,9 +180,12 @@ export function bootstrapWcdbNativeAsync(
|
||||
) => number
|
||||
const resourceRoots = Array.from(
|
||||
new Set(
|
||||
[libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter(
|
||||
Boolean
|
||||
)
|
||||
[
|
||||
libDir,
|
||||
path.dirname(libDir),
|
||||
process.env.WCDB_RESOURCES_PATH || '',
|
||||
...getResourceRoots()
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
let initOk = false
|
||||
@@ -238,9 +248,16 @@ export class Wcdb4Client {
|
||||
private handle: number | null = null
|
||||
private displayNameCache = new Map<string, string>()
|
||||
private avatarCache = new Map<string, string>()
|
||||
private sessionStatusCache = new Map<string, { isFolded: boolean; isMuted: boolean }>()
|
||||
private groupNicknameCache = new Map<string, Map<string, string>>()
|
||||
private cachedSessions: Wcdb4Session[] | null = null
|
||||
private cachedChatTables: { name: string; db_number: string }[] | null = null
|
||||
private sessionsInFlight: Promise<Wcdb4Session[]> | null = null
|
||||
private sessionDisplayNamesInFlight: Promise<void> | null = null
|
||||
private sessionDisplayNamesHydrated = false
|
||||
private sessionStatusesInFlight: Promise<void> | null = null
|
||||
private sessionStatusesUpdatedAt = 0
|
||||
private sessionCacheGeneration = 0
|
||||
|
||||
private wcdbShutdown: (() => number) | null = null
|
||||
private wcdbOpenAccount:
|
||||
@@ -268,6 +285,12 @@ export class Wcdb4Client {
|
||||
private wcdbGetAvatarUrls:
|
||||
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbGetContactStatus:
|
||||
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbGetHeadImageBuffers:
|
||||
| ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbExecQuery:
|
||||
| ((handle: number, kind: string, dbPath: string, sql: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
@@ -531,7 +554,11 @@ export class Wcdb4Client {
|
||||
this.handle = handleOut[0]
|
||||
if (this.wcdbSetMyWxid) {
|
||||
try {
|
||||
this.wcdbSetMyWxid(this.handle, this.wxid)
|
||||
await this.callAsyncCode(
|
||||
this.wcdbSetMyWxid as unknown as KoffiAsyncFunction,
|
||||
this.handle,
|
||||
this.wxid
|
||||
)
|
||||
} catch {
|
||||
// Optional helper. Failure does not block message reads.
|
||||
}
|
||||
@@ -552,19 +579,25 @@ export class Wcdb4Client {
|
||||
|
||||
this.handle = null
|
||||
this.cachedSessions = null
|
||||
this.sessionDisplayNamesHydrated = false
|
||||
this.sessionStatusesInFlight = null
|
||||
this.sessionStatusesUpdatedAt = 0
|
||||
this.displayNameCache.clear()
|
||||
this.avatarCache.clear()
|
||||
this.sessionStatusCache.clear()
|
||||
this.groupNicknameCache.clear()
|
||||
}
|
||||
|
||||
startMonitor(callback: (type: string, json: string) => void): boolean {
|
||||
async startMonitor(callback: (type: string, json: string) => void): Promise<boolean> {
|
||||
if (!this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) return false
|
||||
|
||||
this.stopMonitor()
|
||||
this.monitorCallback = callback
|
||||
|
||||
try {
|
||||
const startResult = this.wcdbStartMonitorPipe()
|
||||
const startResult = await this.callAsyncCode(
|
||||
this.wcdbStartMonitorPipe as unknown as KoffiAsyncFunction
|
||||
)
|
||||
if (startResult !== 0) {
|
||||
this.monitorCallback = null
|
||||
console.warn(`[WCDB4] wcdb_start_monitor_pipe 失败,错误码: ${startResult}`)
|
||||
@@ -573,7 +606,10 @@ export class Wcdb4Client {
|
||||
this.monitorStarted = true
|
||||
|
||||
const outName: WcdbVoidOut = [null]
|
||||
const nameResult = this.wcdbGetMonitorPipeName(outName)
|
||||
const nameResult = await this.callAsyncCode(
|
||||
this.wcdbGetMonitorPipeName as unknown as KoffiAsyncFunction,
|
||||
outName
|
||||
)
|
||||
if (nameResult !== 0 || !outName[0]) {
|
||||
console.warn(`[WCDB4] wcdb_get_monitor_pipe_name 失败,错误码: ${nameResult}`)
|
||||
this.stopMonitor()
|
||||
@@ -702,22 +738,107 @@ export class Wcdb4Client {
|
||||
.map((row) => this.normalizeSession(row))
|
||||
.filter((session) => session.username)
|
||||
|
||||
this.hydrateDisplayNames(
|
||||
sessions
|
||||
.filter((session) => this.shouldHydrateSessionDisplayName(session))
|
||||
.map((session) => session.username)
|
||||
)
|
||||
this.cachedSessions = sessions.map((session) => ({
|
||||
...session,
|
||||
nickname: this.displayNameCache.get(session.username) || session.nickname || session.username
|
||||
}))
|
||||
this.sessionDisplayNamesHydrated = false
|
||||
|
||||
return this.cachedSessions
|
||||
}
|
||||
|
||||
async getSessionsAsync(options: Wcdb4SessionQueryOptions = {}): Promise<Wcdb4Session[]> {
|
||||
const hydrateDisplayNames = options.hydrateDisplayNames !== false
|
||||
if (this.cachedSessions) {
|
||||
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
||||
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
|
||||
return this.cachedSessions
|
||||
}
|
||||
if (this.sessionsInFlight) {
|
||||
await this.sessionsInFlight
|
||||
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
||||
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
|
||||
return this.cachedSessions || []
|
||||
}
|
||||
if (!this.wcdbGetSessions) return []
|
||||
|
||||
const generation = this.sessionCacheGeneration
|
||||
const request = (async (): Promise<Wcdb4Session[]> => {
|
||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||
this.wcdbGetSessions as unknown as KoffiAsyncFunction
|
||||
)
|
||||
const sessions = (Array.isArray(rows) ? rows : [])
|
||||
.map((row) => this.normalizeSession(row))
|
||||
.filter((session) => session.username)
|
||||
if (generation === this.sessionCacheGeneration) this.cachedSessions = sessions
|
||||
return sessions
|
||||
})()
|
||||
this.sessionsInFlight = request
|
||||
try {
|
||||
await request
|
||||
} finally {
|
||||
if (this.sessionsInFlight === request) this.sessionsInFlight = null
|
||||
}
|
||||
if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync()
|
||||
if (options.hydrateStatuses) await this.refreshSessionStatusesAsync()
|
||||
return this.cachedSessions || []
|
||||
}
|
||||
|
||||
private async refreshSessionStatusesAsync(): Promise<void> {
|
||||
if (Date.now() - this.sessionStatusesUpdatedAt < 5 * 60 * 1000) return
|
||||
if (this.sessionStatusesInFlight) {
|
||||
await this.sessionStatusesInFlight
|
||||
return
|
||||
}
|
||||
const sessions = this.cachedSessions
|
||||
if (!sessions?.length || !this.wcdbGetContactStatus) return
|
||||
const groupUsernames = sessions
|
||||
.map((session) => session.username)
|
||||
.filter((username) => username.endsWith('@chatroom'))
|
||||
if (!groupUsernames.length) {
|
||||
this.sessionStatusesUpdatedAt = Date.now()
|
||||
return
|
||||
}
|
||||
const request = (async (): Promise<void> => {
|
||||
try {
|
||||
const map = await this.callJsonAsync<
|
||||
Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
||||
>(
|
||||
this.wcdbGetContactStatus as unknown as KoffiAsyncFunction,
|
||||
JSON.stringify(groupUsernames)
|
||||
)
|
||||
for (const username of groupUsernames) {
|
||||
const status = map?.[username]
|
||||
this.sessionStatusCache.set(username, {
|
||||
isFolded: Boolean(status?.isFolded),
|
||||
isMuted: Boolean(status?.isMuted)
|
||||
})
|
||||
}
|
||||
if (this.cachedSessions) {
|
||||
this.cachedSessions = this.cachedSessions.map((session) => {
|
||||
const status = this.sessionStatusCache.get(session.username)
|
||||
return status ? { ...session, ...status } : session
|
||||
})
|
||||
}
|
||||
this.sessionStatusesUpdatedAt = Date.now()
|
||||
} catch (error) {
|
||||
console.warn('[WCDB4] session status lookup failed:', error)
|
||||
}
|
||||
})()
|
||||
this.sessionStatusesInFlight = request
|
||||
try {
|
||||
await request
|
||||
} finally {
|
||||
if (this.sessionStatusesInFlight === request) this.sessionStatusesInFlight = null
|
||||
}
|
||||
}
|
||||
|
||||
invalidateSessionCache(): void {
|
||||
this.sessionCacheGeneration += 1
|
||||
this.cachedSessions = null
|
||||
this.cachedChatTables = null
|
||||
this.sessionsInFlight = null
|
||||
this.sessionDisplayNamesHydrated = false
|
||||
}
|
||||
|
||||
getChatTables(): { name: string; db_number: string }[] {
|
||||
@@ -817,9 +938,27 @@ export class Wcdb4Client {
|
||||
): Promise<Wcdb4Message[]> {
|
||||
const startedAt = Date.now()
|
||||
const maxRows = this.normalizeMessageLimit(options.limit)
|
||||
const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
|
||||
let cursorMessages: Wcdb4Message[] = []
|
||||
try {
|
||||
cursorMessages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] async cursor messages failed username=${username}:`, error)
|
||||
}
|
||||
|
||||
if (endTime && (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery)) {
|
||||
throw new Error('当前数据服务无法检查历史消息分片,请更新应用或核对微信数据版本')
|
||||
}
|
||||
|
||||
// Older pages may live in message shards that the native cursor does not
|
||||
// enumerate. A bounded query must inspect all matching stores so history
|
||||
// cannot silently stop at a shard boundary.
|
||||
let tableMessages: Wcdb4Message[] = []
|
||||
if (endTime || cursorMessages.length === 0) {
|
||||
tableMessages = await this.getMessagesByTableScanAsync(username, startTime, endTime, maxRows)
|
||||
}
|
||||
const messages = this.mergeMessageRows(cursorMessages, tableMessages, maxRows)
|
||||
console.log(
|
||||
`[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
|
||||
`[WCDB4] getMessages async username=${username} rows=${messages.length} cursor=${cursorMessages.length} tables=${tableMessages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return messages
|
||||
}
|
||||
@@ -877,6 +1016,71 @@ export class Wcdb4Client {
|
||||
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||
}
|
||||
|
||||
private async getMessagesByTableScanAsync(
|
||||
username: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
limit?: number
|
||||
): Promise<Wcdb4Message[]> {
|
||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
||||
|
||||
let tables: Wcdb4MessageStore[] = []
|
||||
try {
|
||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||
this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction,
|
||||
username
|
||||
)
|
||||
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) {
|
||||
console.warn(`[WCDB4] async message table stats failed username=${username}:`, error)
|
||||
throw new Error(
|
||||
`无法读取历史消息分片信息:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
|
||||
const begin = this.normalizeTimestamp(startTime || 0)
|
||||
const end = this.normalizeTimestamp(endTime || 0)
|
||||
const where = [
|
||||
begin > 0 ? `"create_time" >= ${begin}` : '',
|
||||
end > 0 ? `"create_time" <= ${end}` : ''
|
||||
].filter(Boolean)
|
||||
const whereSql = where.length ? ` WHERE ${where.join(' AND ')}` : ''
|
||||
const rowLimit = limit || 5000
|
||||
const order = limit ? 'DESC' : 'ASC'
|
||||
|
||||
const allRows: Record<string, unknown>[] = []
|
||||
let successfulTables = 0
|
||||
for (const table of tables) {
|
||||
try {
|
||||
const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}`
|
||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
|
||||
'message',
|
||||
table.dbPath,
|
||||
sql
|
||||
)
|
||||
successfulTables += 1
|
||||
if (Array.isArray(rows)) allRows.push(...rows)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[WCDB4] async message table scan failed username=${username} db=${table.dbPath} table=${table.tableName}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (tables.length > 0 && successfulTables === 0) {
|
||||
throw new Error('历史消息分片均读取失败,请检查数据目录或微信数据版本')
|
||||
}
|
||||
|
||||
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)) {
|
||||
@@ -1066,6 +1270,52 @@ export class Wcdb4Client {
|
||||
return result
|
||||
}
|
||||
|
||||
async getAvatarUrlsAsync(usernames: string[]): Promise<Record<string, string>> {
|
||||
const normalized = this.uniq(usernames)
|
||||
await this.hydrateAvatarUrlsAsync(normalized)
|
||||
const localCandidates = normalized.filter((username) => {
|
||||
const avatar = this.avatarCache.get(username)
|
||||
return !avatar || !avatar.startsWith('data:')
|
||||
})
|
||||
if (localCandidates.length && this.wcdbGetHeadImageBuffers) {
|
||||
try {
|
||||
const buffers = await this.callJsonAsync<Record<string, string>>(
|
||||
this.wcdbGetHeadImageBuffers as unknown as KoffiAsyncFunction,
|
||||
JSON.stringify(localCandidates)
|
||||
)
|
||||
for (const [username, hex] of Object.entries(buffers || {})) {
|
||||
const avatar = this.avatarHexToDataUrl(hex)
|
||||
if (avatar) this.avatarCache.set(username, avatar)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[WCDB4] local avatar fallback failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {}
|
||||
for (const username of normalized) {
|
||||
const avatar = this.avatarCache.get(username)
|
||||
if (avatar) result[username] = avatar
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private avatarHexToDataUrl(value: string): string | undefined {
|
||||
const hex = String(value || '').trim()
|
||||
if (!hex || hex.length % 2 !== 0 || !/^[a-f0-9]+$/i.test(hex)) return undefined
|
||||
const buffer = Buffer.from(hex, 'hex')
|
||||
let mime = 'image/jpeg'
|
||||
if (buffer.length >= 8 && buffer.subarray(1, 4).toString('ascii') === 'PNG') mime = 'image/png'
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
mime = 'image/webp'
|
||||
}
|
||||
return `data:${mime};base64,${buffer.toString('base64')}`
|
||||
}
|
||||
|
||||
getMyGroupNickname(chatroomId: string): string | undefined {
|
||||
const groupNicknames = this.getGroupNicknames(chatroomId)
|
||||
for (const candidate of this.getMyUsernameCandidates()) {
|
||||
@@ -1432,9 +1682,10 @@ export class Wcdb4Client {
|
||||
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)
|
||||
const rows = await this.callJsonAsync<Record<string, string> | Record<string, unknown>[]>(
|
||||
this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction,
|
||||
chatroomId
|
||||
)
|
||||
this.readStringMap(rows, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
@@ -1527,6 +1778,25 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
async resolveImageHardlinkAsync(md5: string): Promise<Wcdb4ImageHardlink | null> {
|
||||
if (!this.wcdbResolveImageHardlink) return null
|
||||
const normalizedMd5 = String(md5 || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!normalizedMd5) return null
|
||||
|
||||
try {
|
||||
return await this.callJsonAsync<Wcdb4ImageHardlink>(
|
||||
this.wcdbResolveImageHardlink as unknown as KoffiAsyncFunction,
|
||||
normalizedMd5,
|
||||
this.accountRoot
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[WCDB4] async image hardlink resolve failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null {
|
||||
if (!this.wcdbResolveVideoHardlink) return null
|
||||
const normalizedMd5 = String(md5 || '')
|
||||
@@ -1681,6 +1951,22 @@ export class Wcdb4Client {
|
||||
this.wcdbGetAvatarUrls = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbGetContactStatus = lib.func(
|
||||
'int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)'
|
||||
) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number
|
||||
} catch {
|
||||
this.wcdbGetContactStatus = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbGetHeadImageBuffers = lib.func(
|
||||
'int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)'
|
||||
) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number
|
||||
} catch {
|
||||
this.wcdbGetHeadImageBuffers = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbExecQuery = lib.func(
|
||||
'int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)'
|
||||
@@ -2004,7 +2290,16 @@ export class Wcdb4Client {
|
||||
'contactRemark',
|
||||
'contact_remark'
|
||||
])
|
||||
return { username, nickname, wechatNickname, remark, raw: row }
|
||||
const status = this.sessionStatusCache.get(username)
|
||||
return {
|
||||
username,
|
||||
nickname,
|
||||
wechatNickname,
|
||||
remark,
|
||||
isFolded: status?.isFolded,
|
||||
isMuted: status?.isMuted,
|
||||
raw: row
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
|
||||
@@ -2133,9 +2428,10 @@ export class Wcdb4Client {
|
||||
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))
|
||||
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',
|
||||
@@ -2148,6 +2444,34 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureSessionDisplayNamesAsync(): Promise<void> {
|
||||
if (this.sessionDisplayNamesHydrated || !this.cachedSessions) return
|
||||
if (this.sessionDisplayNamesInFlight) return this.sessionDisplayNamesInFlight
|
||||
|
||||
const generation = this.sessionCacheGeneration
|
||||
const sessions = this.cachedSessions
|
||||
const request = (async (): Promise<void> => {
|
||||
await this.hydrateDisplayNamesAsync(
|
||||
sessions
|
||||
.filter((session) => this.shouldHydrateSessionDisplayName(session))
|
||||
.map((session) => session.username)
|
||||
)
|
||||
if (generation !== this.sessionCacheGeneration || this.cachedSessions !== sessions) return
|
||||
this.cachedSessions = sessions.map((session) => ({
|
||||
...session,
|
||||
nickname:
|
||||
this.displayNameCache.get(session.username) || session.nickname || session.username
|
||||
}))
|
||||
this.sessionDisplayNamesHydrated = true
|
||||
})()
|
||||
this.sessionDisplayNamesInFlight = request
|
||||
try {
|
||||
await request
|
||||
} finally {
|
||||
if (this.sessionDisplayNamesInFlight === request) this.sessionDisplayNamesInFlight = null
|
||||
}
|
||||
}
|
||||
|
||||
private hydrateAvatarUrls(usernames: string[]): void {
|
||||
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||
if (missing.length === 0) return
|
||||
@@ -2187,9 +2511,10 @@ export class Wcdb4Client {
|
||||
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))
|
||||
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',
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface UserContact {
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
isFolded?: boolean
|
||||
isMuted?: boolean
|
||||
}
|
||||
|
||||
export interface WechatMessage {
|
||||
@@ -80,7 +82,9 @@ export class WechatDb {
|
||||
nickname: session.nickname || session.username,
|
||||
avatar: session.avatar,
|
||||
wechatNickname: session.wechatNickname,
|
||||
remark: session.remark
|
||||
remark: session.remark,
|
||||
isFolded: session.isFolded,
|
||||
isMuted: session.isMuted
|
||||
}))
|
||||
.filter((contact) => {
|
||||
if (!keyword) return true
|
||||
@@ -176,12 +180,7 @@ export class WechatDb {
|
||||
this.ensureChatTableMapping()
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
const messages = await this.wcdb4Client.getMessagesAsync(
|
||||
username,
|
||||
startTime,
|
||||
endTime,
|
||||
options
|
||||
)
|
||||
const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options)
|
||||
return messages.map((message) => ({ ...message, ...message.raw }))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
import type {
|
||||
DatabaseKeyEnvironment,
|
||||
DatabaseKeyStorageResult,
|
||||
DatabaseKeyValidationResult
|
||||
DatabaseKeyValidationResult,
|
||||
AccountDiscoveryResult
|
||||
} from '../shared/database-key'
|
||||
import type {
|
||||
ImageDecoderSelectionResult,
|
||||
ImageDecoderStatus,
|
||||
ImageDecryptionStatus,
|
||||
ImageDecryptionTestResult,
|
||||
ImageKeyConfigResult,
|
||||
@@ -39,6 +42,8 @@ import type {
|
||||
} from '../shared/image-insight'
|
||||
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||
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 =
|
||||
@@ -103,9 +108,18 @@ declare global {
|
||||
writeAppLog: (entry: AppLogEntry) => Promise<void>
|
||||
getAppLogPath: () => Promise<string>
|
||||
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: (
|
||||
key: string
|
||||
key: string,
|
||||
accountRoot: string
|
||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||
discoverAccounts: (inputPath: string) => Promise<AccountDiscoveryResult>
|
||||
getBootstrapCache: () => Promise<{
|
||||
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||
contacts: Contact[]
|
||||
@@ -196,7 +210,7 @@ declare global {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
@@ -210,7 +224,13 @@ declare global {
|
||||
getSticker: (
|
||||
cdnUrl?: string,
|
||||
md5?: string
|
||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
error?: string
|
||||
failureCode?: import('../shared/sticker').StickerFailureCode
|
||||
httpStatus?: number
|
||||
}>
|
||||
startExport: (request: ExportRequest) => Promise<ExportResult>
|
||||
cancelExport: (jobId: string) => Promise<{ success: boolean }>
|
||||
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
|
||||
@@ -222,14 +242,17 @@ declare global {
|
||||
) => Promise<SaveGeneratedReportResult>
|
||||
deleteGeneratedReport: (reportId: string) => Promise<DeleteGeneratedReportResult>
|
||||
revealGroupReport: (filePath: string) => Promise<{ success: boolean; error?: string }>
|
||||
getSavedDbKey: () => Promise<DatabaseKeyStorageResult>
|
||||
getSavedDbKey: (accountRoot: string) => Promise<DatabaseKeyStorageResult>
|
||||
getDatabaseKeyEnvironment: () => Promise<DatabaseKeyEnvironment>
|
||||
readDatabaseKeyClipboard: () => Promise<{
|
||||
success: boolean
|
||||
value?: string
|
||||
error?: string
|
||||
}>
|
||||
autoGetDbKey: (options?: { save?: boolean }) => Promise<{
|
||||
autoGetDbKey: (
|
||||
accountRoot: string,
|
||||
options?: { save?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
key?: string
|
||||
error?: string
|
||||
@@ -251,24 +274,33 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
}>
|
||||
getImageKeyConfig: () => Promise<ImageKeyConfigResult>
|
||||
getImageDecryptionStatus: () => Promise<ImageDecryptionStatus>
|
||||
selectImageDecoder: () => Promise<ImageDecoderSelectionResult>
|
||||
getImageDecoderStatus: () => Promise<ImageDecoderStatus>
|
||||
openImageDecoderDownload: () => Promise<{ success: boolean; error?: string }>
|
||||
saveImageKeyConfig: (request: SaveImageKeyRequest) => Promise<ImageKeyConfigResult>
|
||||
testImageDecryption: (
|
||||
request: TestImageDecryptionRequest
|
||||
) => Promise<ImageDecryptionTestResult>
|
||||
clearImageKeyConfig: () => Promise<{ success: boolean; error?: string }>
|
||||
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
||||
saveDbKey: (key: string) => Promise<DatabaseKeyStorageResult>
|
||||
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
|
||||
pasteAndSaveDbKey: (
|
||||
accountRoot: string
|
||||
) => Promise<{ success: boolean; key?: string; error?: string }>
|
||||
saveDbKey: (accountRoot: string, key: string) => Promise<DatabaseKeyStorageResult>
|
||||
clearSavedDbKey: (accountRoot: string) => Promise<{ success: boolean; error?: string }>
|
||||
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
|
||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||
@@ -279,10 +311,14 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
@@ -295,10 +331,14 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}>
|
||||
@@ -309,10 +349,14 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
ffmpegPath: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
appearanceTheme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
showStartupProgress: boolean
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
}
|
||||
|
||||
@@ -17,14 +17,33 @@ import type {
|
||||
} from '../shared/image-insight'
|
||||
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||
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'
|
||||
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
|
||||
import type { AccountDiscoveryResult } from '../shared/database-key'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry),
|
||||
getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'),
|
||||
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||
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, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot),
|
||||
discoverAccounts: (inputPath: string): Promise<AccountDiscoveryResult> =>
|
||||
ipcRenderer.invoke('accounts:discover', inputPath),
|
||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
|
||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||
@@ -61,7 +80,7 @@ const api = {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||
) => 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),
|
||||
@@ -82,20 +101,29 @@ const api = {
|
||||
deleteGeneratedReport: (reportId: string) =>
|
||||
ipcRenderer.invoke('report:deleteGenerated', reportId),
|
||||
revealGroupReport: (filePath: string) => ipcRenderer.invoke('report:reveal', filePath),
|
||||
getSavedDbKey: () => ipcRenderer.invoke('key:getSavedDbKey'),
|
||||
getSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:getSavedDbKey', accountRoot),
|
||||
getDatabaseKeyEnvironment: () => ipcRenderer.invoke('key:getEnvironment'),
|
||||
readDatabaseKeyClipboard: () => ipcRenderer.invoke('key:readClipboardDbKey'),
|
||||
autoGetDbKey: (options?: { save?: boolean }) => ipcRenderer.invoke('key:autoGetDbKey', options),
|
||||
autoGetDbKey: (accountRoot: string, options?: { save?: boolean }) =>
|
||||
ipcRenderer.invoke('key:autoGetDbKey', accountRoot, options),
|
||||
autoGetImageKey: (options?: { save?: boolean }) =>
|
||||
ipcRenderer.invoke('key:autoGetImageKey', options),
|
||||
getImageKeyConfig: () => ipcRenderer.invoke('image:getConfig'),
|
||||
getImageDecryptionStatus: () => ipcRenderer.invoke('image:getStatus'),
|
||||
selectImageDecoder: (): Promise<ImageDecoderSelectionResult> =>
|
||||
ipcRenderer.invoke('image:selectDecoder'),
|
||||
getImageDecoderStatus: (): Promise<ImageDecoderStatus> =>
|
||||
ipcRenderer.invoke('image:getDecoderStatus'),
|
||||
openImageDecoderDownload: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('image:openDecoderDownload'),
|
||||
saveImageKeyConfig: (request) => ipcRenderer.invoke('image:saveConfig', request),
|
||||
testImageDecryption: (request) => ipcRenderer.invoke('image:testConfig', request),
|
||||
clearImageKeyConfig: () => ipcRenderer.invoke('image:clearConfig'),
|
||||
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
|
||||
saveDbKey: (key: string) => ipcRenderer.invoke('key:saveDbKey', key),
|
||||
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
|
||||
pasteAndSaveDbKey: (accountRoot: string) =>
|
||||
ipcRenderer.invoke('key:pasteAndSaveDbKey', accountRoot),
|
||||
saveDbKey: (accountRoot: string, key: string) =>
|
||||
ipcRenderer.invoke('key:saveDbKey', accountRoot, key),
|
||||
clearSavedDbKey: (accountRoot: string) => ipcRenderer.invoke('key:clearSavedDbKey', accountRoot),
|
||||
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
|
||||
@@ -20,12 +20,36 @@ import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportG
|
||||
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
|
||||
import { Contact, Message } from '../../shared/types'
|
||||
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
|
||||
import { FirstUseWelcome } from './components/FirstUseWelcome'
|
||||
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
||||
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
|
||||
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
|
||||
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../shared/database-key'
|
||||
import {
|
||||
getMessageIdentity,
|
||||
mergeMessagePages,
|
||||
sortMessagesChronologically
|
||||
} from './utils/message-pages'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
const DATABASE_CONNECT_TIMEOUT_MS = 30_000
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
promise.then(
|
||||
(value) => {
|
||||
window.clearTimeout(timer)
|
||||
resolve(value)
|
||||
},
|
||||
(error) => {
|
||||
window.clearTimeout(timer)
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function getDevelopmentDatabaseKey(): string {
|
||||
if (!import.meta.env.DEV) return ''
|
||||
@@ -39,18 +63,13 @@ interface SelfInfo {
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
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/Wxw-Gu/WechatExplorer/blob/main/docs/mac-disable-sip.md'
|
||||
const FIRST_USE_WELCOME_SEEN_KEY = 'wxe_first_use_welcome_seen'
|
||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
||||
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 => {
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
if (message.id) return `id:${message.id}`
|
||||
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||
}
|
||||
|
||||
const normalizeQuotedText = (value: string | undefined): string =>
|
||||
String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -192,27 +211,16 @@ const buildSyntheticGroupMessages = (
|
||||
|
||||
void buildSyntheticGroupMessages
|
||||
|
||||
const sortMessagesChronologically = (items: Message[]): Message[] =>
|
||||
[...items].sort((left, right) => {
|
||||
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
|
||||
if (timeDelta !== 0) return timeDelta
|
||||
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 {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
||||
const [isDatabaseConnecting, setIsDatabaseConnecting] = useState(false)
|
||||
const [dbKey, setDbKey] = useState(getDevelopmentDatabaseKey)
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
|
||||
const [messageHistoryStatus, setMessageHistoryStatus] = useState<'idle' | 'end' | 'error'>('idle')
|
||||
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
||||
const [contentFilter, setContentFilter] = useState('')
|
||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||
@@ -220,10 +228,16 @@ function App(): React.ReactElement {
|
||||
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
||||
const [showDbKey, setShowDbKey] = useState(false)
|
||||
const [dbRootInput, setDbRootInput] = useState('')
|
||||
const [discoveredAccounts, setDiscoveredAccounts] = useState<WechatAccountCandidate[]>([])
|
||||
const [selectedAccountId, setSelectedAccountId] = useState('')
|
||||
const selectedAccount = discoveredAccounts.find((account) => account.id === selectedAccountId)
|
||||
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
||||
const [databaseConnectionMode, setDatabaseConnectionMode] = useState<DatabaseConnectionMode>(
|
||||
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
|
||||
)
|
||||
const [connectionGuideStep, setConnectionGuideStep] = useState<1 | 2 | 3 | 4 | 5 | 6>(1)
|
||||
const [databaseEnvironment, setDatabaseEnvironment] = useState<DatabaseKeyEnvironment>()
|
||||
const connectionOperationRef = React.useRef(0)
|
||||
const [activePage, setActivePage] = useState<AppPage>('archive')
|
||||
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
|
||||
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
|
||||
@@ -256,6 +270,18 @@ function App(): React.ReactElement {
|
||||
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
|
||||
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
|
||||
const [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null)
|
||||
const [showFirstUseWelcome, setShowFirstUseWelcome] = useState(false)
|
||||
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 syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
|
||||
@@ -268,6 +294,43 @@ function App(): React.ReactElement {
|
||||
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [reportNotice])
|
||||
React.useEffect(() => {
|
||||
void window.api.getSettings().then((result) => {
|
||||
setAppearanceSettings({
|
||||
theme: result.settings.appearanceTheme,
|
||||
compactMode: result.settings.compactMode,
|
||||
showStartupProgress: result.settings.showStartupProgress
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const refreshConnectionEnvironment = React.useCallback(async (): Promise<void> => {
|
||||
const root = dbRootInput.trim()
|
||||
try {
|
||||
if (root) {
|
||||
const discovery = await window.api.discoverAccounts(root)
|
||||
if (!discovery.success) throw new Error(discovery.error || '账号目录识别失败')
|
||||
setDiscoveredAccounts(discovery.accounts)
|
||||
setSelectedAccountId(discovery.preselectedAccountId || '')
|
||||
}
|
||||
const environment = await window.api.getDatabaseKeyEnvironment()
|
||||
setDatabaseEnvironment(environment)
|
||||
setDbKeyStatus('环境检查已更新')
|
||||
setDbKeyStatusKind('normal')
|
||||
} catch (error) {
|
||||
setDbKeyStatus(
|
||||
error instanceof Error ? `环境检查失败:${error.message}` : '环境检查失败,请重试'
|
||||
)
|
||||
setDbKeyStatusKind('error')
|
||||
}
|
||||
}, [dbRootInput])
|
||||
|
||||
React.useEffect(() => {
|
||||
void window.api
|
||||
.getDatabaseKeyEnvironment()
|
||||
.then(setDatabaseEnvironment)
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
React.useEffect(() => {
|
||||
const loadAIConfig = async (): Promise<void> => {
|
||||
try {
|
||||
@@ -523,13 +586,26 @@ function App(): React.ReactElement {
|
||||
if (active && settingsResult.settings.dbRoot) {
|
||||
setDbRootInput(settingsResult.settings.dbRoot)
|
||||
}
|
||||
const discovery = settingsResult.settings.dbRoot
|
||||
? await window.api.discoverAccounts(settingsResult.settings.dbRoot)
|
||||
: { success: false, accounts: [] }
|
||||
if (active && discovery.success) {
|
||||
setDiscoveredAccounts(discovery.accounts)
|
||||
setSelectedAccountId(discovery.preselectedAccountId || '')
|
||||
}
|
||||
const startupAccountRoot = discovery.success
|
||||
? discovery.accounts.find((account) => account.id === discovery.preselectedAccountId)
|
||||
?.accountRoot
|
||||
: undefined
|
||||
const autoLoginEnabled = settingsResult.settings.autoLogin
|
||||
// 开发环境允许使用 VITE_DB_KEY;生产安装包只能读取目标电脑自己的 safeStorage。
|
||||
const envKey = getDevelopmentDatabaseKey()
|
||||
// 生产环境以及未配置开发密钥时,读取上一次保存到 safeStorage 的密钥。
|
||||
let savedKey = ''
|
||||
if (!envKey) {
|
||||
const result = await window.api.getSavedDbKey()
|
||||
const result = startupAccountRoot
|
||||
? await window.api.getSavedDbKey(startupAccountRoot)
|
||||
: { success: true, saved: false, encryptionAvailable: true }
|
||||
if (result.success && result.key) savedKey = result.key
|
||||
}
|
||||
const key = envKey || savedKey
|
||||
@@ -559,7 +635,12 @@ function App(): React.ReactElement {
|
||||
if (!autoLoginEnabled) return
|
||||
try {
|
||||
const startupCacheReady = await loadStartupCache()
|
||||
const initPromise = window.api.initDb(key)
|
||||
setIsDatabaseConnecting(true)
|
||||
if (!startupAccountRoot) {
|
||||
setBootState('login')
|
||||
return
|
||||
}
|
||||
const initPromise = window.api.initDb(key, startupAccountRoot)
|
||||
if (startupCacheReady) {
|
||||
setIsAuthenticated(true)
|
||||
setIsDatabaseConnected(false)
|
||||
@@ -576,13 +657,19 @@ function App(): React.ReactElement {
|
||||
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.
|
||||
// The cached list paints first. Refresh lightweight session flags and
|
||||
// missing avatars after the database is connected.
|
||||
void loadContacts({ waitForAvatars: false }).catch((error) => {
|
||||
console.warn('[Startup] background contact refresh failed:', error)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[Startup] background database init failed:', error)
|
||||
setDbKeyStatusKind('error')
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setIsDatabaseConnecting(false)
|
||||
})
|
||||
return
|
||||
}
|
||||
const result = await initPromise
|
||||
@@ -615,6 +702,8 @@ function App(): React.ReactElement {
|
||||
setDbKeyStatus(`自动连接失败: ${message}`)
|
||||
setDbKeyStatusKind('error')
|
||||
setBootState('login')
|
||||
} finally {
|
||||
if (active) setIsDatabaseConnecting(false)
|
||||
}
|
||||
}
|
||||
void attemptAutoConnect()
|
||||
@@ -636,12 +725,36 @@ function App(): React.ReactElement {
|
||||
void loadGeneratedReports()
|
||||
}, [isAuthenticated, loadGeneratedReports])
|
||||
|
||||
const handleLogin = async (keyInput?: string): Promise<void> => {
|
||||
const handleLogin = async (keyInput?: string, accountRootInput?: string): Promise<void> => {
|
||||
const keyToUse = keyInput || dbKey
|
||||
if (!keyToUse) return
|
||||
setBootState('connecting')
|
||||
let accountRoot = accountRootInput || selectedAccount?.accountRoot
|
||||
if (!keyToUse || isDatabaseConnecting) return
|
||||
if (!accountRoot) {
|
||||
const discovery = await window.api.discoverAccounts(dbRootInput.trim())
|
||||
if (!discovery.success) {
|
||||
setDbKeyStatus(discovery.error || '微信数据目录不可用')
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
if (!discovery.preselectedAccountId) {
|
||||
setDiscoveredAccounts(discovery.accounts)
|
||||
setDbKeyStatus('请选择要连接的微信账号')
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
const account = discovery.accounts.find(
|
||||
(candidate) => candidate.id === discovery.preselectedAccountId
|
||||
)
|
||||
if (!account) return
|
||||
setDiscoveredAccounts(discovery.accounts)
|
||||
setSelectedAccountId(account.id)
|
||||
accountRoot = account.accountRoot
|
||||
}
|
||||
const operationId = ++connectionOperationRef.current
|
||||
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(6)
|
||||
setIsDatabaseConnecting(true)
|
||||
// 持久化用户手动指定的微信聊天文件路径,供 db:init 读取 settings.dbRoot
|
||||
const trimmedRoot = dbRootInput.trim()
|
||||
const trimmedRoot = accountRoot
|
||||
if (trimmedRoot) {
|
||||
try {
|
||||
await window.api.setSettings({ dbRoot: trimmedRoot })
|
||||
@@ -662,7 +775,12 @@ function App(): React.ReactElement {
|
||||
detail: '正在打开 WCDB 数据库',
|
||||
percent: 15
|
||||
})
|
||||
const result = await window.api.initDb(keyToUse)
|
||||
const result = await withTimeout(
|
||||
window.api.initDb(keyToUse, trimmedRoot),
|
||||
DATABASE_CONNECT_TIMEOUT_MS,
|
||||
'数据库连接超时,请检查数据目录后重试'
|
||||
)
|
||||
if (operationId !== connectionOperationRef.current) return
|
||||
const success = typeof result === 'boolean' ? result : result.success
|
||||
if (success) {
|
||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||
@@ -673,8 +791,9 @@ function App(): React.ReactElement {
|
||||
percent: 25
|
||||
})
|
||||
const hasBootstrap = await loadBootstrapCache()
|
||||
if (operationId !== connectionOperationRef.current) return
|
||||
// 持久化手动输入的密钥,供下次启动继续使用
|
||||
void window.api.saveDbKey(keyToUse).catch(() => undefined)
|
||||
void window.api.saveDbKey(trimmedRoot, keyToUse).catch(() => undefined)
|
||||
void window.api.getSettings().then((current) => {
|
||||
if (!current.settings.autoLoginPreferenceSet) {
|
||||
void window.api.setSettings({ autoLogin: true })
|
||||
@@ -708,20 +827,29 @@ function App(): React.ReactElement {
|
||||
})
|
||||
setIsDatabaseConnected(true)
|
||||
setBootState('login')
|
||||
maybeShowFirstUseWelcome()
|
||||
window.setTimeout(() => {
|
||||
setStartupProgress(null)
|
||||
}, 500)
|
||||
} else {
|
||||
const error = typeof result === 'boolean' ? '' : result.error
|
||||
setDbKeyStatus(error || '数据库连接失败,请检查密钥和数据目录后重试')
|
||||
setDbKeyStatusKind('error')
|
||||
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
|
||||
setBootState('login')
|
||||
setStartupProgress(null)
|
||||
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setDbKeyStatus(
|
||||
error instanceof Error ? `数据库连接失败:${error.message}` : '数据库连接失败,请重试'
|
||||
)
|
||||
setDbKeyStatusKind('error')
|
||||
if (databaseConnectionMode === 'automatic') setConnectionGuideStep(5)
|
||||
setBootState('login')
|
||||
setStartupProgress(null)
|
||||
alert('Error connecting to database')
|
||||
} finally {
|
||||
if (operationId === connectionOperationRef.current) setIsDatabaseConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,31 +969,42 @@ function App(): React.ReactElement {
|
||||
|
||||
const handleAutoGetDbKey = async (): Promise<void> => {
|
||||
if (isFetchingDbKey) return
|
||||
const operationId = ++connectionOperationRef.current
|
||||
setConnectionGuideStep(4)
|
||||
setIsFetchingDbKey(true)
|
||||
setDbKeyStatus('正在准备获取密钥...')
|
||||
setDbKeyStatusKind('normal')
|
||||
setShowMacKeyFaq(false)
|
||||
try {
|
||||
const result = await window.api.autoGetDbKey()
|
||||
if (!selectedAccount) throw new Error('请先选择微信账号')
|
||||
const result = await window.api.autoGetDbKey(selectedAccount.accountRoot)
|
||||
if (operationId !== connectionOperationRef.current) return
|
||||
if (!result.success || !result.key) {
|
||||
setShowMacKeyFaq(result.code === 'SCAN_FAILED')
|
||||
throw new Error(result.error || '获取密钥失败')
|
||||
}
|
||||
setDbKey(result.key)
|
||||
setDatabaseConnectionMode('manual')
|
||||
setConnectionGuideStep(5)
|
||||
setDbKeyStatus(result.saved ? '密钥已获取并安全保存' : result.warning || '密钥已获取')
|
||||
setDbKeyStatusKind(result.saved ? 'success' : 'normal')
|
||||
} catch (error) {
|
||||
if (operationId !== connectionOperationRef.current) return
|
||||
setDbKeyStatus(error instanceof Error ? error.message : String(error))
|
||||
setDbKeyStatusKind('error')
|
||||
setConnectionGuideStep(3)
|
||||
} finally {
|
||||
setIsFetchingDbKey(false)
|
||||
if (operationId === connectionOperationRef.current) setIsFetchingDbKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasteAndSaveDbKey = async (): Promise<void> => {
|
||||
setShowMacKeyFaq(false)
|
||||
const result = await window.api.pasteAndSaveDbKey()
|
||||
if (!selectedAccount) {
|
||||
setDbKeyStatus('请先选择微信账号')
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
const result = await window.api.pasteAndSaveDbKey(selectedAccount.accountRoot)
|
||||
if (result.success && result.key) {
|
||||
setDbKey(result.key)
|
||||
setDatabaseConnectionMode('manual')
|
||||
@@ -879,7 +1018,8 @@ function App(): React.ReactElement {
|
||||
|
||||
const handleClearSavedDbKey = async (): Promise<void> => {
|
||||
setShowMacKeyFaq(false)
|
||||
const result = await window.api.clearSavedDbKey()
|
||||
if (!selectedAccount) return
|
||||
const result = await window.api.clearSavedDbKey(selectedAccount.accountRoot)
|
||||
if (!result.success) {
|
||||
setDbKeyStatus(result.error || '清除密钥失败')
|
||||
setDbKeyStatusKind('error')
|
||||
@@ -894,6 +1034,7 @@ function App(): React.ReactElement {
|
||||
const handleReturnToLogin = (): void => {
|
||||
setIsAuthenticated(false)
|
||||
setIsDatabaseConnected(false)
|
||||
setIsDatabaseConnecting(false)
|
||||
setBootState('login')
|
||||
setDatabaseConnectionMode(dbKey ? 'manual' : 'automatic')
|
||||
setActivePage('archive')
|
||||
@@ -911,12 +1052,54 @@ function App(): React.ReactElement {
|
||||
setStartupProgress(null)
|
||||
}
|
||||
|
||||
const handleSwitchAccount = async (account: WechatAccountCandidate): Promise<void> => {
|
||||
connectionOperationRef.current += 1
|
||||
await window.api.disconnectDb({ closeNative: true })
|
||||
setIsAuthenticated(false)
|
||||
setIsDatabaseConnected(false)
|
||||
setSelectedContact(null)
|
||||
setMessages([])
|
||||
setContacts([])
|
||||
setFilteredContacts([])
|
||||
setContentFilter('')
|
||||
setSelfInfo(null)
|
||||
setReportSourceContact(null)
|
||||
setExportTasks([])
|
||||
messageHistoryRef.current = []
|
||||
messagesRef.current = []
|
||||
selectedContactMd5Ref.current = ''
|
||||
currentGroupSnapshotRef.current = null
|
||||
groupMemberMetaRef.current = {}
|
||||
syntheticGroupMessagesRef.current = {}
|
||||
setDiscoveredAccounts((current) =>
|
||||
current.some((item) => item.id === account.id) ? current : [account]
|
||||
)
|
||||
setSelectedAccountId(account.id)
|
||||
setDbRootInput(account.accountRoot)
|
||||
await window.api.setSettings({ dbRoot: account.accountRoot, imageKeyRoot: account.accountRoot })
|
||||
const saved = await window.api.getSavedDbKey(account.accountRoot)
|
||||
if (!saved.success || !saved.key) {
|
||||
setDbKey('')
|
||||
setDatabaseConnectionMode('automatic')
|
||||
setBootState('login')
|
||||
setConnectionGuideStep(3)
|
||||
setDbKeyStatus('该账号尚无可用密钥,请为所选账号获取密钥')
|
||||
setDbKeyStatusKind('normal')
|
||||
return
|
||||
}
|
||||
setDbKey(saved.key)
|
||||
setDatabaseConnectionMode('manual')
|
||||
setBootState('login')
|
||||
await handleLogin(saved.key, account.accountRoot)
|
||||
}
|
||||
|
||||
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
|
||||
setArchiveJumpTime(null)
|
||||
setSelectedContact(contact)
|
||||
selectedContactMd5Ref.current = contact.md5
|
||||
currentGroupSnapshotRef.current = null
|
||||
setIsMessagesLoading(true)
|
||||
setMessageHistoryStatus('idle')
|
||||
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
|
||||
const cachedMsgs = cachedPage.messages
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
@@ -1028,7 +1211,7 @@ function App(): React.ReactElement {
|
||||
|
||||
const handleLoadOlderMessages = async (): Promise<void> => {
|
||||
const contact = selectedContact
|
||||
if (!contact || messagesRef.current.length === 0) return
|
||||
if (!contact || messagesRef.current.length === 0 || messageHistoryStatus === 'end') return
|
||||
if (messagePrefetchRef.current) await messagePrefetchRef.current
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
const currentMessages = messagesRef.current
|
||||
@@ -1065,6 +1248,7 @@ function App(): React.ReactElement {
|
||||
limit: MESSAGE_PAGE_SIZE
|
||||
})
|
||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||
setMessageHistoryStatus(olderMessages.length === 0 ? 'end' : 'idle')
|
||||
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
|
||||
setMessages((current) =>
|
||||
applyGroupMemberMeta(
|
||||
@@ -1074,6 +1258,7 @@ function App(): React.ReactElement {
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[Messages] older page load failed:', error)
|
||||
if (selectedContactMd5Ref.current === contact.md5) setMessageHistoryStatus('error')
|
||||
} finally {
|
||||
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
||||
}
|
||||
@@ -1202,6 +1387,45 @@ function App(): React.ReactElement {
|
||||
setActivePage('settings')
|
||||
}
|
||||
|
||||
const dismissFirstUseWelcome = (): void => {
|
||||
try {
|
||||
localStorage.setItem(FIRST_USE_WELCOME_SEEN_KEY, '1')
|
||||
} catch {
|
||||
// The welcome prompt is optional and should not interrupt normal use.
|
||||
}
|
||||
setShowFirstUseWelcome(false)
|
||||
}
|
||||
|
||||
const maybeShowFirstUseWelcome = (): void => {
|
||||
try {
|
||||
if (localStorage.getItem(FIRST_USE_WELCOME_SEEN_KEY) === '1') return
|
||||
} catch {
|
||||
// If localStorage is unavailable, still show the one-time prompt for this session.
|
||||
}
|
||||
setShowFirstUseWelcome(true)
|
||||
}
|
||||
|
||||
const openFirstUseSearch = (): void => {
|
||||
dismissFirstUseWelcome()
|
||||
setActivePage('search')
|
||||
}
|
||||
|
||||
const openFirstUseReport = (): void => {
|
||||
dismissFirstUseWelcome()
|
||||
setReportWorkspaceView('configure')
|
||||
setSelectedReportId(null)
|
||||
setActivePage('report')
|
||||
}
|
||||
|
||||
const openFirstUseAISettings = (): void => {
|
||||
dismissFirstUseWelcome()
|
||||
openModelSettings()
|
||||
}
|
||||
|
||||
const openFirstUseGuide = (): void => {
|
||||
setShowFirstUseWelcome(true)
|
||||
}
|
||||
|
||||
const openReport = (reportId: string): void => {
|
||||
setSelectedReportId(reportId)
|
||||
setReportWorkspaceView('result')
|
||||
@@ -1362,6 +1586,7 @@ function App(): React.ReactElement {
|
||||
width={sidebarWidth}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isDatabaseConnected}
|
||||
dbConnecting={isDatabaseConnecting}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
<div className="resizer" onMouseDown={startResizing} />
|
||||
@@ -1370,6 +1595,7 @@ function App(): React.ReactElement {
|
||||
contact={selectedContact}
|
||||
messages={messages}
|
||||
isLoadingMessages={isMessagesLoading}
|
||||
messageHistoryStatus={messageHistoryStatus}
|
||||
contentFilter={contentFilter}
|
||||
onContentFilterChange={setContentFilter}
|
||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
|
||||
@@ -1391,6 +1617,7 @@ function App(): React.ReactElement {
|
||||
selectedReportId={selectedReportId}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isDatabaseConnected}
|
||||
dbConnecting={isDatabaseConnecting}
|
||||
onSelectReport={openReport}
|
||||
onCreateReport={openReportConfigure}
|
||||
onDeleteReport={handleDeleteReport}
|
||||
@@ -1413,6 +1640,7 @@ function App(): React.ReactElement {
|
||||
selectedContact={reportSourceContact}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isDatabaseConnected}
|
||||
dbConnecting={isDatabaseConnecting}
|
||||
onSelectContact={handleSelectReportSource}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
@@ -1483,6 +1711,7 @@ function App(): React.ReactElement {
|
||||
onCategoryChange={setSettingsCategory}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isDatabaseConnected}
|
||||
dbConnecting={isDatabaseConnecting}
|
||||
dbKey={dbKey}
|
||||
onDbKeyChange={setDbKey}
|
||||
onDatabaseConnectionChange={setIsDatabaseConnected}
|
||||
@@ -1493,6 +1722,8 @@ function App(): React.ReactElement {
|
||||
onAIRuntimeChange={(config: AIRuntimeModelConfig) => setAiModelConfig(config)}
|
||||
onNotice={setReportNotice}
|
||||
onOpenSettings={openSettings}
|
||||
onAppearanceChange={handleAppearanceChange}
|
||||
onSwitchAccount={handleSwitchAccount}
|
||||
/>
|
||||
)
|
||||
case 'search':
|
||||
@@ -1579,7 +1810,7 @@ function App(): React.ReactElement {
|
||||
: '使用上次安全保存的密钥'
|
||||
: 'WechatExplorer')
|
||||
return (
|
||||
<div className="boot-splash">
|
||||
<div className={`boot-splash ${appearanceSettings.showStartupProgress ? '' : 'is-quiet'}`}>
|
||||
<div className="boot-splash-spinner" aria-hidden />
|
||||
<div className="boot-splash-title">{title}</div>
|
||||
<div className="boot-splash-subtitle">{subtitle}</div>
|
||||
@@ -1607,15 +1838,80 @@ function App(): React.ReactElement {
|
||||
dbRoot={dbRootInput}
|
||||
showDbKey={showDbKey}
|
||||
isFetching={isFetchingDbKey}
|
||||
isConnecting={isDatabaseConnecting}
|
||||
guideStep={connectionGuideStep}
|
||||
environment={databaseEnvironment}
|
||||
accounts={discoveredAccounts}
|
||||
selectedAccountId={selectedAccountId}
|
||||
status={dbKeyStatus}
|
||||
statusKind={dbKeyStatusKind}
|
||||
showMacKeyFaq={showMacKeyFaq}
|
||||
macKeyFaqUrl={MAC_KEY_FAQ_URL}
|
||||
onModeChange={setDatabaseConnectionMode}
|
||||
onDbKeyChange={setDbKey}
|
||||
onDbRootChange={setDbRootInput}
|
||||
onDbRootChange={(value) => {
|
||||
setDbRootInput(value)
|
||||
setDiscoveredAccounts([])
|
||||
setSelectedAccountId('')
|
||||
}}
|
||||
onSelectAccount={(account) => {
|
||||
setSelectedAccountId(account.id)
|
||||
setDbKey('')
|
||||
void window.api.getSavedDbKey(account.accountRoot).then((result) => {
|
||||
if (result.success && result.key) setDbKey(result.key)
|
||||
})
|
||||
}}
|
||||
onSelectDbRoot={() => {
|
||||
void window.api.selectDbRoot().then((result) => {
|
||||
if (!result.canceled && result.path) {
|
||||
setDbRootInput(result.path)
|
||||
void window.api.discoverAccounts(result.path).then((discovery) => {
|
||||
if (!discovery.success) {
|
||||
setDiscoveredAccounts([])
|
||||
setSelectedAccountId('')
|
||||
setDbKeyStatus(discovery.error || '账号目录识别失败')
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
setDiscoveredAccounts(discovery.accounts)
|
||||
setSelectedAccountId(discovery.preselectedAccountId || '')
|
||||
})
|
||||
}
|
||||
})
|
||||
}}
|
||||
onToggleDbKey={() => setShowDbKey((visible) => !visible)}
|
||||
onAutoGetKey={handleAutoGetDbKey}
|
||||
onRefreshEnvironment={() => void refreshConnectionEnvironment()}
|
||||
onGuideNext={() =>
|
||||
setConnectionGuideStep((current) => (current === 1 ? 2 : current === 2 ? 3 : current))
|
||||
}
|
||||
onGuideBack={() =>
|
||||
setConnectionGuideStep((current) =>
|
||||
current === 5 ? 3 : current > 1 ? ((current - 1) as 1 | 2 | 3 | 4 | 5 | 6) : 1
|
||||
)
|
||||
}
|
||||
onGuideCancel={() => {
|
||||
connectionOperationRef.current += 1
|
||||
setIsFetchingDbKey(false)
|
||||
setIsDatabaseConnecting(false)
|
||||
setBootState('login')
|
||||
setStartupProgress(null)
|
||||
setConnectionGuideStep(1)
|
||||
setDbKeyStatus('已取消,可以重新检查环境')
|
||||
setDbKeyStatusKind('normal')
|
||||
}}
|
||||
onValidateConnection={() => void handleLogin()}
|
||||
onCopyDiagnostics={() => {
|
||||
if (!databaseEnvironment?.diagnosticSummary) {
|
||||
setDbKeyStatus('诊断信息尚未准备好,请先重新检查环境')
|
||||
setDbKeyStatusKind('error')
|
||||
return
|
||||
}
|
||||
void window.api.copyText(databaseEnvironment.diagnosticSummary).then(() => {
|
||||
setDbKeyStatus('脱敏诊断摘要已复制')
|
||||
setDbKeyStatusKind('success')
|
||||
})
|
||||
}}
|
||||
onManualConnect={() => handleLogin()}
|
||||
onPasteKey={handlePasteAndSaveDbKey}
|
||||
onClearKey={handleClearSavedDbKey}
|
||||
@@ -1628,10 +1924,22 @@ function App(): React.ReactElement {
|
||||
activePage={activePage}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isDatabaseConnected}
|
||||
dbConnecting={isDatabaseConnecting}
|
||||
onPageChange={handlePageChange}
|
||||
onOpenSettings={openSettings}
|
||||
onOpenGuide={openFirstUseGuide}
|
||||
appearanceTheme={appearanceSettings.theme}
|
||||
compactMode={appearanceSettings.compactMode}
|
||||
>
|
||||
{reportNotice && <div className="app-toast">{reportNotice}</div>}
|
||||
{showFirstUseWelcome && (
|
||||
<FirstUseWelcome
|
||||
onDismiss={dismissFirstUseWelcome}
|
||||
onOpenSearch={openFirstUseSearch}
|
||||
onOpenReport={openFirstUseReport}
|
||||
onOpenAISettings={openFirstUseAISettings}
|
||||
/>
|
||||
)}
|
||||
{renderCurrentWorkspace()}
|
||||
</AppShell>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ChatWindowProps {
|
||||
contact: Contact | null
|
||||
messages: Message[]
|
||||
isLoadingMessages?: boolean
|
||||
messageHistoryStatus?: 'idle' | 'end' | 'error'
|
||||
contentFilter?: string
|
||||
onContentFilterChange?: (keyword: string) => void
|
||||
onRefresh?: () => void
|
||||
@@ -25,6 +26,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
contact,
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
messageHistoryStatus,
|
||||
contentFilter,
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
@@ -205,6 +207,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
messages={filteredMessages}
|
||||
hiddenMessageCount={0}
|
||||
isLoadingMessages={isLoadingMessages}
|
||||
messageHistoryStatus={messageHistoryStatus}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
listRef={messageListRef}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import React from 'react'
|
||||
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key'
|
||||
|
||||
const GUIDE_URL =
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||
|
||||
export type DatabaseConnectionMode = 'automatic' | 'manual'
|
||||
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
|
||||
@@ -10,6 +14,11 @@ interface DatabaseConnectionPageProps {
|
||||
dbRoot: string
|
||||
showDbKey: boolean
|
||||
isFetching: boolean
|
||||
isConnecting: boolean
|
||||
guideStep: 1 | 2 | 3 | 4 | 5 | 6
|
||||
environment?: DatabaseKeyEnvironment
|
||||
accounts: WechatAccountCandidate[]
|
||||
selectedAccountId: string
|
||||
status: string
|
||||
statusKind: DatabaseConnectionStatusKind
|
||||
showMacKeyFaq: boolean
|
||||
@@ -17,8 +26,16 @@ interface DatabaseConnectionPageProps {
|
||||
onModeChange: (mode: DatabaseConnectionMode) => void
|
||||
onDbKeyChange: (value: string) => void
|
||||
onDbRootChange: (value: string) => void
|
||||
onSelectAccount: (account: WechatAccountCandidate) => void
|
||||
onSelectDbRoot: () => void
|
||||
onToggleDbKey: () => void
|
||||
onAutoGetKey: () => void
|
||||
onRefreshEnvironment: () => void
|
||||
onGuideNext: () => void
|
||||
onGuideBack: () => void
|
||||
onGuideCancel: () => void
|
||||
onValidateConnection: () => void
|
||||
onCopyDiagnostics: () => void
|
||||
onManualConnect: () => void
|
||||
onPasteKey: () => void
|
||||
onClearKey: () => void
|
||||
@@ -89,6 +106,11 @@ export function DatabaseConnectionPage({
|
||||
dbRoot,
|
||||
showDbKey,
|
||||
isFetching,
|
||||
isConnecting,
|
||||
guideStep,
|
||||
environment,
|
||||
accounts = [],
|
||||
selectedAccountId = '',
|
||||
status,
|
||||
statusKind,
|
||||
showMacKeyFaq,
|
||||
@@ -96,8 +118,16 @@ export function DatabaseConnectionPage({
|
||||
onModeChange,
|
||||
onDbKeyChange,
|
||||
onDbRootChange,
|
||||
onSelectAccount,
|
||||
onSelectDbRoot,
|
||||
onToggleDbKey,
|
||||
onAutoGetKey,
|
||||
onRefreshEnvironment,
|
||||
onGuideNext,
|
||||
onGuideBack,
|
||||
onGuideCancel,
|
||||
onValidateConnection,
|
||||
onCopyDiagnostics,
|
||||
onManualConnect,
|
||||
onPasteKey,
|
||||
onClearKey
|
||||
@@ -116,9 +146,9 @@ export function DatabaseConnectionPage({
|
||||
<LineIcon name="database" />
|
||||
</div>
|
||||
<h1>WechatExplorer</h1>
|
||||
<p className="database-login-tagline">你的本地微信聊天档案</p>
|
||||
<p className="database-login-tagline">让 AI 读懂你的微信</p>
|
||||
<p className="database-login-description">
|
||||
连接本机微信数据库,开始检索、整理和分析聊天记录。
|
||||
连接成功后,你可以搜索聊天记录、生成群聊日报,并按需使用 AI 分析。
|
||||
</p>
|
||||
<div className="database-login-promises">
|
||||
<div>
|
||||
@@ -131,7 +161,7 @@ export function DatabaseConnectionPage({
|
||||
</div>
|
||||
<div>
|
||||
<LineIcon name="cloud" />
|
||||
<span>不会上传</span>
|
||||
<span>AI 按需启用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,6 +170,42 @@ export function DatabaseConnectionPage({
|
||||
|
||||
<section className="database-login-workspace" aria-label="数据库连接">
|
||||
<div className="database-login-panel">
|
||||
<div className="database-login-start">
|
||||
<p className="database-login-eyebrow">第一次使用</p>
|
||||
<h2>开始连接微信</h2>
|
||||
<p>按顺序检查环境、准备连接组件并验证数据库,通常几分钟即可完成。</p>
|
||||
<ol>
|
||||
<li>
|
||||
<span>1</span>
|
||||
<div>
|
||||
<strong>检查本机环境</strong>
|
||||
<small>确认微信版本、数据目录和运行状态</small>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span>2</span>
|
||||
<div>
|
||||
<strong>准备连接组件</strong>
|
||||
<small>页面会按当前系统给出对应步骤</small>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span>3</span>
|
||||
<div>
|
||||
<strong>登录并验证连接</strong>
|
||||
<small>验证通过后进入主界面</small>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<a
|
||||
className="database-login-guide-link"
|
||||
href={GUIDE_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
查看 5 分钟上手教程 →
|
||||
</a>
|
||||
</div>
|
||||
<div className="database-login-tabs" role="tablist" aria-label="连接方式">
|
||||
<button
|
||||
type="button"
|
||||
@@ -148,21 +214,26 @@ export function DatabaseConnectionPage({
|
||||
className={mode === 'automatic' ? 'active' : ''}
|
||||
onClick={() => onModeChange('automatic')}
|
||||
>
|
||||
自动获取
|
||||
开始连接
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'manual'}
|
||||
className={mode === 'manual' ? 'active' : ''}
|
||||
className={`database-login-manual-tab ${mode === 'manual' ? 'active' : ''}`}
|
||||
onClick={() => onModeChange('manual')}
|
||||
>
|
||||
手动输入
|
||||
高级用户:已有数据库密钥?手动连接
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'automatic' ? (
|
||||
<div className="database-login-auto" role="tabpanel">
|
||||
<div className="database-login-guide-progress" aria-label={`连接进度 ${guideStep}/6`}>
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<span key={index} className={index + 1 <= guideStep ? 'active' : ''} />
|
||||
))}
|
||||
</div>
|
||||
<div className={`database-login-state-card ${statusKind}`}>
|
||||
<div className="database-login-state-heading">
|
||||
<span className="database-login-state-icon">
|
||||
@@ -170,69 +241,229 @@ export function DatabaseConnectionPage({
|
||||
</span>
|
||||
<div>
|
||||
<strong>
|
||||
{statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'}
|
||||
{statusKind === 'error'
|
||||
? '当前步骤未完成'
|
||||
: [
|
||||
'检查本机环境',
|
||||
'让微信停在登录页面',
|
||||
'确认开始准备',
|
||||
`正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`,
|
||||
'现在可以登录微信',
|
||||
'验证数据库连接'
|
||||
][guideStep - 1]}
|
||||
</strong>
|
||||
<p>
|
||||
{statusKind === 'error'
|
||||
? status
|
||||
: status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'}
|
||||
: status ||
|
||||
[
|
||||
'确认下方检测结果;没有找到目录时可以手动选择。',
|
||||
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
|
||||
'开始后请按页面提示完成系统授权。',
|
||||
'正在准备连接组件,请不要关闭微信或 WechatExplorer。',
|
||||
'请回到微信完成登录,登录成功后再回来验证。',
|
||||
'正在验证密钥和本地数据库,请稍候。'
|
||||
][guideStep - 1]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="database-login-diagnostics">
|
||||
<div>
|
||||
<dt>微信客户端</dt>
|
||||
<dd>{isFetching ? '正在检测' : '等待检测'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
存储路径
|
||||
<StoragePathHelp />
|
||||
</dt>
|
||||
<dd>
|
||||
<span className="database-login-path-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
aria-label="微信数据存储路径"
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<span className="database-login-path-value" role="status">
|
||||
{dbRoot || defaultPath}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>数据库状态</dt>
|
||||
<dd>{statusKind === 'error' ? '无法连接' : '准备连接'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{guideStep === 1 && (
|
||||
<>
|
||||
<dl className="database-login-diagnostics">
|
||||
<div>
|
||||
<dt>操作系统</dt>
|
||||
<dd>{environment?.osVersion || (isMac ? 'macOS' : 'Windows')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>微信版本</dt>
|
||||
<dd>{environment?.wechatVersion || '未检测到'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>数据结构</dt>
|
||||
<dd>{environment?.dataStructureVersion || '未检测到'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
存储路径
|
||||
<StoragePathHelp />
|
||||
</dt>
|
||||
<dd>
|
||||
<span className="database-login-path-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
aria-label="微信数据存储路径"
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<span className="database-login-path-value" role="status">
|
||||
{dbRoot || defaultPath}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-path-select"
|
||||
onClick={onSelectDbRoot}
|
||||
disabled={isFetching || isConnecting}
|
||||
>
|
||||
选择目录
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>微信状态</dt>
|
||||
<dd>{environment?.wechatRunning ? '运行中' : '未检测到'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{accounts.length > 0 && (
|
||||
<section className="database-account-list" aria-label="选择微信账号">
|
||||
<h3>选择微信账号</h3>
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
type="button"
|
||||
key={account.id}
|
||||
className={`database-account-card ${selectedAccountId === account.id ? 'selected' : ''}`}
|
||||
aria-pressed={selectedAccountId === account.id}
|
||||
onClick={() => onSelectAccount(account)}
|
||||
>
|
||||
<span className="database-account-avatar">
|
||||
{account.avatar ? (
|
||||
<img src={account.avatar} alt="" />
|
||||
) : (
|
||||
(account.nickname || '?').charAt(0)
|
||||
)}
|
||||
</span>
|
||||
<span className="database-account-identity">
|
||||
<strong>{account.nickname || '昵称未识别'}</strong>
|
||||
<small>{account.wxid || 'wxid 未识别'}</small>
|
||||
<code title={account.accountRoot}>{account.accountRoot}</code>
|
||||
</span>
|
||||
<span className="database-account-status">
|
||||
{account.hasSavedDbKey ? '已有可用密钥' : '尚无可用密钥'}
|
||||
<small>
|
||||
{account.loginStatus === 'current'
|
||||
? '当前已连接账号'
|
||||
: account.loginStatus === 'other'
|
||||
? '非当前账号'
|
||||
: '登录状态未确认'}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-secondary"
|
||||
onClick={onSelectDbRoot}
|
||||
disabled={isFetching || isConnecting}
|
||||
>
|
||||
选择其他账号
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onAutoGetKey}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching
|
||||
? '正在获取密钥…'
|
||||
: statusKind === 'error'
|
||||
? '重新检测'
|
||||
: '自动获取密钥'}
|
||||
</button>
|
||||
{showMacKeyFaq && (
|
||||
{guideStep === 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onGuideNext}
|
||||
disabled={!selectedAccountId}
|
||||
>
|
||||
检查完成,继续
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-secondary"
|
||||
onClick={onRefreshEnvironment}
|
||||
>
|
||||
重新检查环境
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-text-action"
|
||||
onClick={onCopyDiagnostics}
|
||||
>
|
||||
复制脱敏诊断摘要
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{guideStep === 2 && (
|
||||
<button type="button" className="database-login-primary" onClick={onGuideNext}>
|
||||
我已准备好
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 3 && (
|
||||
<button type="button" className="database-login-primary" onClick={onAutoGetKey}>
|
||||
开始准备连接组件
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 4 && (
|
||||
<button type="button" className="database-login-primary" disabled>
|
||||
正在准备连接组件…
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 5 && (
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onValidateConnection}
|
||||
disabled={!dbKey || isConnecting}
|
||||
>
|
||||
{isConnecting ? '正在验证…' : '微信已登录,验证连接'}
|
||||
</button>
|
||||
)}
|
||||
{guideStep === 6 && (
|
||||
<button type="button" className="database-login-primary" disabled>
|
||||
正在验证数据库…
|
||||
</button>
|
||||
)}
|
||||
{guideStep > 1 && !isFetching && !isConnecting && (
|
||||
<div className="database-login-guide-actions">
|
||||
<button type="button" onClick={onGuideBack}>
|
||||
返回上一步
|
||||
</button>
|
||||
<button type="button" onClick={onGuideCancel}>
|
||||
取消并重新检查
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(isFetching || isConnecting) && (
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-text-action"
|
||||
onClick={onGuideCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
<p className="database-login-platform-note">
|
||||
{isMac ? (
|
||||
<>
|
||||
macOS 首次获取密钥需要关闭 SIP。{' '}
|
||||
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
|
||||
查看说明
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
'Windows 已完整支持,不需要关闭 SIP。'
|
||||
)}
|
||||
</p>
|
||||
{showMacKeyFaq && isMac && (
|
||||
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
|
||||
查看详情 · 连接帮助
|
||||
获取失败?查看连接排查
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="database-login-manual" role="tabpanel">
|
||||
<p className="database-login-manual-note">
|
||||
仅适用于已经通过其他方式获得当前微信账号数据库密钥的高级用户。第一次使用请返回“开始连接”。
|
||||
</p>
|
||||
<div className="database-login-field">
|
||||
<label htmlFor="database-login-key">数据库密钥</label>
|
||||
<div className="database-login-key-input">
|
||||
@@ -261,15 +492,21 @@ export function DatabaseConnectionPage({
|
||||
微信数据目录
|
||||
<StoragePathHelp />
|
||||
</label>
|
||||
<input
|
||||
id="database-login-root"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<div className="database-login-root-control">
|
||||
<input
|
||||
id="database-login-root"
|
||||
aria-label="微信数据目录"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<button type="button" onClick={onSelectDbRoot} disabled={isConnecting}>
|
||||
选择目录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
|
||||
@@ -277,11 +514,25 @@ export function DatabaseConnectionPage({
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onManualConnect}
|
||||
disabled={!keyIsValid}
|
||||
disabled={!keyIsValid || isConnecting}
|
||||
>
|
||||
连接数据库
|
||||
{isConnecting ? '正在连接…' : '连接数据库'}
|
||||
</button>
|
||||
<button type="button" className="database-login-secondary" onClick={onPasteKey}>
|
||||
{isConnecting && (
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-text-action"
|
||||
onClick={onGuideCancel}
|
||||
>
|
||||
取消连接
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-secondary"
|
||||
onClick={onPasteKey}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
从剪贴板粘贴并安全保存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react'
|
||||
|
||||
interface FirstUseWelcomeProps {
|
||||
onDismiss: () => void
|
||||
onOpenSearch: () => void
|
||||
onOpenReport: () => void
|
||||
onOpenAISettings: () => void
|
||||
}
|
||||
|
||||
const GUIDE_URL =
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||
|
||||
export function FirstUseWelcome({
|
||||
onDismiss,
|
||||
onOpenSearch,
|
||||
onOpenReport,
|
||||
onOpenAISettings
|
||||
}: FirstUseWelcomeProps): React.ReactElement {
|
||||
return (
|
||||
<div className="first-use-welcome-overlay" role="presentation">
|
||||
<section
|
||||
className="first-use-welcome"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="first-use-welcome-title"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="first-use-welcome-close"
|
||||
onClick={onDismiss}
|
||||
aria-label="关闭欢迎提示"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="first-use-welcome-mark" aria-hidden="true">
|
||||
✦
|
||||
</div>
|
||||
<p className="first-use-welcome-eyebrow">微信已连接</p>
|
||||
<h2 id="first-use-welcome-title">开始探索你的微信</h2>
|
||||
<p className="first-use-welcome-lead">
|
||||
最关键的一步已经完成。现在,让 AI 帮你看看最近的聊天都发生了什么。
|
||||
</p>
|
||||
|
||||
<button type="button" className="first-use-welcome-feature" onClick={onOpenReport}>
|
||||
<span className="first-use-welcome-feature-icon" aria-hidden="true">
|
||||
✦
|
||||
</span>
|
||||
<span className="first-use-welcome-feature-copy">
|
||||
<strong>试试 AI 群聊日报</strong>
|
||||
<small>选择一个群聊,看看最近聊了什么</small>
|
||||
</span>
|
||||
<span className="first-use-welcome-feature-arrow" aria-hidden="true">
|
||||
立即体验 →
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="first-use-welcome-secondary-actions">
|
||||
<button type="button" onClick={onDismiss}>
|
||||
查看聊天记录
|
||||
</button>
|
||||
<button type="button" onClick={onOpenSearch}>
|
||||
问问你的微信
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="first-use-welcome-footer">
|
||||
<span>还没有配置 AI?</span>
|
||||
<button type="button" onClick={onOpenAISettings}>
|
||||
配置 AI 模型
|
||||
</button>
|
||||
<a href={GUIDE_URL} target="_blank" rel="noreferrer">
|
||||
查看完整使用教程
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,97 +25,94 @@ export function ImageBubble({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||
const [usingFallback, setUsingFallback] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const backgroundUpgradeRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
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 () => {
|
||||
if (imageUrl || loading) return
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
const loadImage = useCallback(
|
||||
async (priority = 0) => {
|
||||
if (imageUrl) return
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setError('缺少图片标识')
|
||||
return
|
||||
}
|
||||
setError('缺少图片标识')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await requestImage(
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
{ preferThumbnail: true },
|
||||
0
|
||||
)
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(result.isThumbnail)
|
||||
setError(null)
|
||||
if (result.isThumbnail) upgradeOriginalInBackground()
|
||||
} catch (error) {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(error instanceof Error ? error.message : '加载图片失败')
|
||||
if (loading) {
|
||||
if (priority === 0) {
|
||||
void requestImage(
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
{ preferThumbnail: true },
|
||||
priority
|
||||
).catch(() => undefined)
|
||||
}
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [
|
||||
fallbackUrl,
|
||||
imageDatName,
|
||||
imageMd5,
|
||||
imageUrl,
|
||||
loading,
|
||||
sessionId,
|
||||
upgradeOriginalInBackground
|
||||
])
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await requestImage(
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
{ preferThumbnail: true },
|
||||
priority
|
||||
)
|
||||
if (!mountedRef.current) return
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(error instanceof Error ? error.message : '加载图片失败')
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) setLoading(false)
|
||||
}
|
||||
},
|
||||
[fallbackUrl, imageDatName, imageMd5, imageUrl, loading, sessionId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
|
||||
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageUrl || loading || error) return
|
||||
if (imageUrl || error) return
|
||||
const element = containerRef.current
|
||||
if (!element || typeof IntersectionObserver === 'undefined') {
|
||||
const timer = window.setTimeout(() => void loadImage(), 0)
|
||||
const timer = window.setTimeout(() => void loadImage(0), 0)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return
|
||||
const entry = entries.find((candidate) => candidate.isIntersecting)
|
||||
if (!entry) return
|
||||
const rect = entry.boundingClientRect
|
||||
const isInViewport =
|
||||
rect.bottom >= 0 &&
|
||||
rect.top <= window.innerHeight &&
|
||||
rect.right >= 0 &&
|
||||
rect.left <= window.innerWidth
|
||||
observer.disconnect()
|
||||
void loadImage()
|
||||
void loadImage(isInViewport ? 0 : 1)
|
||||
},
|
||||
{ rootMargin: '400px 0px' }
|
||||
{ rootMargin: loading ? '0px' : '400px 0px' }
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
@@ -143,10 +140,9 @@ export function ImageBubble({
|
||||
setUpgrading(true)
|
||||
try {
|
||||
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
|
||||
if (result.data.startsWith('data:image/')) {
|
||||
if (result.data.startsWith('data:image/') || result.data.startsWith('wxe-media://')) {
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(result.isThumbnail)
|
||||
setError(null)
|
||||
onImageClick?.(result.data)
|
||||
return
|
||||
@@ -162,7 +158,7 @@ export function ImageBubble({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="image-bubble image-loading" onClick={handleClick}>
|
||||
<div ref={containerRef} className="image-bubble image-loading" onClick={handleClick}>
|
||||
<div className="image-loading-skeleton" aria-hidden />
|
||||
<div className="image-quality-badge">加载中</div>
|
||||
</div>
|
||||
@@ -171,7 +167,7 @@ export function ImageBubble({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="image-bubble image-error" onClick={loadImage}>
|
||||
<div className="image-bubble image-error" onClick={() => void loadImage(0)}>
|
||||
<div className="image-error-text">{error || '图片未缓存'}</div>
|
||||
<div className="image-quality-badge">加载失败</div>
|
||||
</div>
|
||||
@@ -194,9 +190,7 @@ export function ImageBubble({
|
||||
alt="图片"
|
||||
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
|
||||
/>
|
||||
{(upgrading || isThumbnail) && (
|
||||
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
||||
)}
|
||||
{upgrading && <div className="image-quality-badge">正在查找原图</div>}
|
||||
<div className="image-actions">
|
||||
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
|
||||
复制
|
||||
|
||||
@@ -24,13 +24,11 @@ export function RichMessageBubble({
|
||||
return <CardBubble data={contentData} />
|
||||
case 'share':
|
||||
return <ShareBubble data={contentData} />
|
||||
case 'forwardBundle':
|
||||
return <ForwardBundleBubble data={contentData} />
|
||||
case 'miniProgram':
|
||||
return (
|
||||
<MiniProgramBubble
|
||||
data={contentData}
|
||||
sessionId={sessionId}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
<MiniProgramBubble data={contentData} sessionId={sessionId} onImageClick={onImageClick} />
|
||||
)
|
||||
case 'redPacket':
|
||||
return <RedPacketBubble data={contentData} />
|
||||
@@ -44,8 +42,11 @@ export function RichMessageBubble({
|
||||
return <SystemBubble data={contentData} />
|
||||
case 'unknown':
|
||||
return (
|
||||
<div className="message-text">
|
||||
{renderWechatEmojiText((contentData as { raw?: string }).raw || '[未知消息]')}
|
||||
<div className="unsupported-message">
|
||||
<strong>暂不支持此消息</strong>
|
||||
<span>
|
||||
消息类型 {(contentData as { messageType?: string | number }).messageType || '未知'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
@@ -53,6 +54,56 @@ export function RichMessageBubble({
|
||||
}
|
||||
}
|
||||
|
||||
function ForwardBundleBubble({
|
||||
data
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'forwardBundle' }>
|
||||
}): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const visibleItems = expanded ? data.items : data.items.slice(0, 3)
|
||||
const hiddenCount = Math.max(0, data.items.length - visibleItems.length)
|
||||
|
||||
return (
|
||||
<div className="forward-bundle-message">
|
||||
<button
|
||||
type="button"
|
||||
className="forward-bundle-header"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span>{data.title || '聊天记录'}</span>
|
||||
<small>
|
||||
{data.items.length ? `${data.items.length} 条消息` : data.description || '聊天记录'}
|
||||
</small>
|
||||
</button>
|
||||
<div className="forward-bundle-list">
|
||||
{visibleItems.length ? (
|
||||
visibleItems.map((item, index) => (
|
||||
<div
|
||||
className="forward-bundle-item"
|
||||
key={`${item.sender || ''}-${item.sentAt || ''}-${index}`}
|
||||
>
|
||||
{item.sender && <b>{item.sender}</b>}
|
||||
<span>{renderWechatEmojiText(item.text, 24)}</span>
|
||||
{item.nested?.length ? <small>包含 {item.nested.length} 条聊天记录</small> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="forward-bundle-empty">暂未解析到可展示的记录</div>
|
||||
)}
|
||||
</div>
|
||||
{(hiddenCount > 0 || expanded) && data.items.length > 3 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="forward-bundle-toggle"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? '收起' : `展开其余 ${hiddenCount} 条`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocationBubble({
|
||||
data
|
||||
}: {
|
||||
@@ -161,12 +212,7 @@ function MiniProgramBubble({
|
||||
/>
|
||||
</div>
|
||||
) : data.iconUrl ? (
|
||||
<img
|
||||
className="mini-program-icon"
|
||||
src={data.iconUrl}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<img className="mini-program-icon" src={data.iconUrl} alt="" referrerPolicy="no-referrer" />
|
||||
) : null}
|
||||
<div className="mini-program-footer">
|
||||
<span aria-hidden>⌁</span>
|
||||
@@ -242,6 +288,7 @@ function StickerBubble({
|
||||
)
|
||||
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
|
||||
const [error, setError] = useState(false)
|
||||
const [errorText, setErrorText] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheKey || displayUrl || error) return
|
||||
@@ -255,12 +302,17 @@ function StickerBubble({
|
||||
stickerDataUrlCache.set(cacheKey, result.data)
|
||||
setDisplayUrl(result.data)
|
||||
setError(false)
|
||||
setErrorText('')
|
||||
} else {
|
||||
setError(true)
|
||||
setErrorText(result.error || '表情包未缓存')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true)
|
||||
if (!cancelled) {
|
||||
setError(true)
|
||||
setErrorText('表情包加载失败')
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
@@ -289,7 +341,7 @@ function StickerBubble({
|
||||
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<div className="sticker-placeholder">{error ? '表情包未缓存' : '表情包'}</div>
|
||||
<div className="sticker-placeholder">{error ? errorText || '表情包未缓存' : '表情包'}</div>
|
||||
{md5 && <div className="sticker-md5">MD5: {md5}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -16,15 +16,16 @@ export function VoicePlayer({
|
||||
sessionId,
|
||||
localId,
|
||||
createTime,
|
||||
svrId
|
||||
svrId,
|
||||
duration
|
||||
}: VoicePlayerProps): JSX.Element {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [audioDuration, setAudioDuration] = useState<number | undefined>(undefined)
|
||||
const [shouldAutoPlay, setShouldAutoPlay] = useState(false)
|
||||
const [audioDuration, setAudioDuration] = useState<number | undefined>(duration)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const objectUrlRef = useRef<string | null>(null)
|
||||
|
||||
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
|
||||
if (globalCurrentAudio && globalCurrentAudio !== audio) {
|
||||
@@ -35,126 +36,113 @@ export function VoicePlayer({
|
||||
globalCurrentAudio = audio
|
||||
}, [])
|
||||
|
||||
const handlePlayPause = useCallback(async () => {
|
||||
// 如果还没有音频数据,先获取
|
||||
if (!audioUrl && !loading) {
|
||||
setLoading(true)
|
||||
setShouldAutoPlay(true)
|
||||
console.log('[VoicePlayer] fetching voice data:', { sessionId, localId, createTime })
|
||||
try {
|
||||
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
|
||||
console.log('[VoicePlayer] got result:', result)
|
||||
if (result.success && result.data) {
|
||||
console.log('[VoicePlayer] setting audioUrl, data length:', result.data.length)
|
||||
// 使用 Blob URL 替代 data URL,绕过 CSP 限制
|
||||
const byteCharacters = atob(result.data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: 'audio/wav' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
console.log('[VoicePlayer] created blob URL:', blobUrl)
|
||||
setAudioUrl(blobUrl)
|
||||
} else {
|
||||
console.log('[VoicePlayer] getVoiceData failed:', result.error)
|
||||
setError(result.error || '获取语音数据失败')
|
||||
setShouldAutoPlay(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[VoicePlayer] exception:', e)
|
||||
setError('加载语音失败')
|
||||
setShouldAutoPlay(false)
|
||||
}
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!audioRef.current) {
|
||||
console.log('[VoicePlayer] no audioRef')
|
||||
return
|
||||
}
|
||||
|
||||
const audio = audioRef.current
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
globalStopCallback = null
|
||||
} else {
|
||||
const playAudio = useCallback(
|
||||
async (audio: HTMLAudioElement): Promise<void> => {
|
||||
stopCurrentAndPlay(audio)
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
console.log('[VoicePlayer] play() succeeded')
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log('[VoicePlayer] play() failed:', e)
|
||||
})
|
||||
setIsPlaying(true)
|
||||
globalStopCallback = () => {
|
||||
setIsPlaying(false)
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}
|
||||
}, [audioUrl, loading, isPlaying, sessionId, localId, createTime, svrId, stopCurrentAndPlay])
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl) return
|
||||
|
||||
let audio = audioRef.current
|
||||
if (!audio) {
|
||||
audio = new Audio(audioUrl)
|
||||
audioRef.current = audio
|
||||
}
|
||||
|
||||
const audioEl = audio!
|
||||
|
||||
audioEl.addEventListener('loadedmetadata', () => {
|
||||
setAudioDuration(audioEl.duration)
|
||||
console.log('[VoicePlayer] loadedmetadata, duration:', audioEl.duration)
|
||||
})
|
||||
|
||||
audioEl.addEventListener('ended', () => {
|
||||
setIsPlaying(false)
|
||||
globalStopCallback = null
|
||||
})
|
||||
|
||||
audioEl.addEventListener('timeupdate', () => {
|
||||
if (audioEl.duration && isFinite(audioEl.duration)) {
|
||||
setAudioDuration(audioEl.duration)
|
||||
}
|
||||
})
|
||||
|
||||
audioEl.addEventListener('canplay', () => {
|
||||
console.log('[VoicePlayer] canplay event, shouldAutoPlay:', shouldAutoPlay)
|
||||
if (shouldAutoPlay && audioRef.current) {
|
||||
setShouldAutoPlay(false)
|
||||
stopCurrentAndPlay(audioRef.current)
|
||||
audioRef.current.play()
|
||||
try {
|
||||
await audio.play()
|
||||
setError(null)
|
||||
setIsPlaying(true)
|
||||
globalStopCallback = () => {
|
||||
setIsPlaying(false)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = 0
|
||||
}
|
||||
audio.currentTime = 0
|
||||
}
|
||||
} catch (playError) {
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
setIsPlaying(false)
|
||||
setError('语音播放失败,请重试')
|
||||
console.warn('[VoicePlayer] play failed:', playError)
|
||||
}
|
||||
})
|
||||
},
|
||||
[stopCurrentAndPlay]
|
||||
)
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ''
|
||||
audioRef.current = null
|
||||
}
|
||||
if (globalCurrentAudio === audioRef.current) {
|
||||
const createAudio = useCallback((blobUrl: string): HTMLAudioElement => {
|
||||
const audio = new Audio()
|
||||
audio.preload = 'auto'
|
||||
audio.src = blobUrl
|
||||
audio.onloadedmetadata = () => {
|
||||
if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration)
|
||||
}
|
||||
audio.ontimeupdate = () => {
|
||||
if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration)
|
||||
}
|
||||
audio.onended = () => {
|
||||
setIsPlaying(false)
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
}
|
||||
}, [audioUrl, shouldAutoPlay, stopCurrentAndPlay])
|
||||
audioRef.current = audio
|
||||
objectUrlRef.current = blobUrl
|
||||
return audio
|
||||
}, [])
|
||||
|
||||
const handlePlayPause = useCallback(async () => {
|
||||
if (loading) return
|
||||
|
||||
let audio = audioRef.current
|
||||
if (!audio) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
|
||||
if (result.success && result.data) {
|
||||
const byteCharacters = atob(result.data)
|
||||
const byteArray = new Uint8Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteArray[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: 'audio/wav' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
setAudioUrl(blobUrl)
|
||||
audio = createAudio(blobUrl)
|
||||
await playAudio(audio)
|
||||
} else {
|
||||
setError(result.error || '获取语音数据失败')
|
||||
}
|
||||
} catch (loadError) {
|
||||
console.warn('[VoicePlayer] load failed:', loadError)
|
||||
setError('加载语音失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
} else {
|
||||
await playAudio(audio)
|
||||
}
|
||||
}, [createAudio, createTime, isPlaying, loading, localId, playAudio, sessionId, svrId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const audio = audioRef.current
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
}
|
||||
if (globalCurrentAudio === audio) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current)
|
||||
objectUrlRef.current = null
|
||||
audioRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const formatDuration = (seconds: number | undefined): string => {
|
||||
if (!seconds || !isFinite(seconds)) return '0:00'
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SelfInfo {
|
||||
interface AccountSummaryProps {
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
compact?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
@@ -17,13 +18,20 @@ interface AccountSummaryProps {
|
||||
export function AccountSummary({
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
compact = false,
|
||||
onClick
|
||||
}: AccountSummaryProps): React.ReactElement {
|
||||
const showAccount = Boolean(selfInfo && (dbReady || dbConnecting))
|
||||
const displayName =
|
||||
dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
|
||||
const subtitle = dbReady && selfInfo ? selfInfo.wxid : '打开设置'
|
||||
const statusText = dbReady ? '数据库已连接' : '数据库未连接'
|
||||
showAccount && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
|
||||
const subtitle = showAccount && selfInfo ? selfInfo.wxid : '打开设置'
|
||||
const statusText = dbReady
|
||||
? '数据库已连接'
|
||||
: dbConnecting
|
||||
? '正在连接数据库'
|
||||
: '数据库未连接'
|
||||
const statusClass = dbReady ? 'ready' : dbConnecting ? 'connecting' : ''
|
||||
const initial = (displayName || '?').charAt(0)
|
||||
const title = `${displayName}\n${subtitle}`
|
||||
const avatar = (
|
||||
@@ -33,7 +41,7 @@ export function AccountSummary({
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
<span className={`account-summary-status ${dbReady ? 'ready' : ''}`} aria-hidden />
|
||||
<span className={`account-summary-status ${statusClass}`} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -52,7 +60,7 @@ export function AccountSummary({
|
||||
<span className="account-summary-name">{displayName}</span>
|
||||
<span className="account-summary-meta">{subtitle}</span>
|
||||
<span className="account-summary-state">
|
||||
<span className={`account-summary-state-dot ${dbReady ? 'ready' : ''}`} aria-hidden />
|
||||
<span className={`account-summary-state-dot ${statusClass}`} aria-hidden />
|
||||
{statusText}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -31,7 +31,9 @@ const RICH_MESSAGE_TYPES = [
|
||||
'引用消息',
|
||||
'通话',
|
||||
'表情包',
|
||||
'系统消息'
|
||||
'系统消息',
|
||||
'合并转发',
|
||||
'不支持的消息'
|
||||
]
|
||||
|
||||
export function MessageBubble({
|
||||
@@ -46,7 +48,8 @@ export function MessageBubble({
|
||||
const isVoice = 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) || message.contentData?.type === 'unknown'
|
||||
const hoverTime = formatMessageTime(message)
|
||||
|
||||
return (
|
||||
@@ -63,6 +66,8 @@ export function MessageBubble({
|
||||
sessionId={message.sessionId}
|
||||
localId={message.localId || 0}
|
||||
createTime={message.createTime || 0}
|
||||
svrId={message.serverId}
|
||||
duration={message.voiceDuration}
|
||||
/>
|
||||
) : isImage && message.contentData && message.contentData.type === 'image' ? (
|
||||
<ImageBubble
|
||||
|
||||
@@ -9,6 +9,7 @@ interface MessageListProps {
|
||||
messages: Message[]
|
||||
hiddenMessageCount: number
|
||||
isLoadingMessages?: boolean
|
||||
messageHistoryStatus?: 'idle' | 'end' | 'error'
|
||||
isGroupChat: boolean
|
||||
showAvatar: boolean
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
@@ -24,6 +25,7 @@ export function MessageList({
|
||||
messages,
|
||||
hiddenMessageCount,
|
||||
isLoadingMessages,
|
||||
messageHistoryStatus,
|
||||
isGroupChat,
|
||||
showAvatar,
|
||||
listRef,
|
||||
@@ -119,6 +121,18 @@ export function MessageList({
|
||||
return (
|
||||
<div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}>
|
||||
{isLoadingMessages && <div className="message-loading-pill">正在加载聊天记录...</div>}
|
||||
{messageHistoryStatus === 'end' && (
|
||||
<div className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">已显示本地数据库中的最早记录</div>
|
||||
</div>
|
||||
)}
|
||||
{messageHistoryStatus === 'error' && (
|
||||
<div className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">
|
||||
无法读取更早记录,请检查数据目录或当前微信数据版本
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hiddenMessageCount > 0 && (
|
||||
<div className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
|
||||
interface ConversationItemProps {
|
||||
@@ -16,6 +16,26 @@ export function ConversationItem({
|
||||
const wxid = contact.m_nsUsrName
|
||||
const displayName = nickname || wxid || '未命名会话'
|
||||
const initial = (displayName || wxid || '?').charAt(0)
|
||||
const [repairedAvatar, setRepairedAvatar] = useState<{ username: string; source: string }>()
|
||||
const [failedAvatar, setFailedAvatar] = useState<{ username: string; source: string }>()
|
||||
const repairedSource = repairedAvatar?.username === wxid ? repairedAvatar.source : undefined
|
||||
const avatar = repairedSource || contact.avatar
|
||||
const avatarFailed = failedAvatar?.username === wxid && failedAvatar.source === avatar
|
||||
|
||||
const handleAvatarError = (): void => {
|
||||
if (!avatar || avatarFailed) return
|
||||
setFailedAvatar({ username: wxid, source: avatar })
|
||||
if (contact.type !== 'group' || avatar.startsWith('data:')) return
|
||||
void window.api
|
||||
.getContactAvatars([wxid])
|
||||
.then((avatars) => {
|
||||
const fallback = avatars[wxid]
|
||||
if (!fallback || fallback === avatar) return
|
||||
setRepairedAvatar({ username: wxid, source: fallback })
|
||||
setFailedAvatar(undefined)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -26,13 +46,14 @@ export function ConversationItem({
|
||||
>
|
||||
<span className="conversation-item-active-mark" aria-hidden />
|
||||
<span className="conversation-item-avatar">
|
||||
{contact.avatar ? (
|
||||
{avatar && !avatarFailed ? (
|
||||
<img
|
||||
src={contact.avatar}
|
||||
src={avatar}
|
||||
alt={displayName}
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={handleAvatarError}
|
||||
/>
|
||||
) : (
|
||||
initial
|
||||
|
||||
@@ -21,10 +21,11 @@ export interface ConversationSidebarProps {
|
||||
width: number
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
type SectionName = 'groups' | 'contacts'
|
||||
type SectionName = 'groups' | 'folded' | 'contacts'
|
||||
type ConversationRow =
|
||||
| { kind: 'header'; id: string; title: string; count: number; section: SectionName }
|
||||
| { kind: 'contact'; id: string; contact: Contact }
|
||||
@@ -37,29 +38,73 @@ export function ConversationSidebar({
|
||||
width,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
onOpenSettings
|
||||
}: ConversationSidebarProps): React.ReactElement {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
||||
groups: true,
|
||||
folded: false,
|
||||
contacts: false
|
||||
})
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const groups = contacts.filter((contact) => contact.type === 'group')
|
||||
const groups = contacts.filter((contact) => contact.type === 'group' && !contact.isFolded)
|
||||
const foldedGroups = contacts.filter((contact) => contact.type === 'group' && contact.isFolded)
|
||||
const users = contacts.filter((contact) => contact.type === 'user')
|
||||
const rows = useMemo<ConversationRow[]>(
|
||||
() => [
|
||||
{ kind: 'header', id: 'groups-header', title: '群聊', count: groups.length, section: 'groups' },
|
||||
{
|
||||
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 }))
|
||||
? groups.map((contact) => ({
|
||||
kind: 'contact' as const,
|
||||
id: `group-${contact.md5}`,
|
||||
contact
|
||||
}))
|
||||
: []),
|
||||
{ kind: 'header', id: 'contacts-header', title: '联系人', count: users.length, section: 'contacts' },
|
||||
...(foldedGroups.length
|
||||
? [
|
||||
{
|
||||
kind: 'header' as const,
|
||||
id: 'folded-header',
|
||||
title: '折叠群聊',
|
||||
count: foldedGroups.length,
|
||||
section: 'folded' as const
|
||||
},
|
||||
...(expandedSections.folded
|
||||
? foldedGroups.map((contact) => ({
|
||||
kind: 'contact' as const,
|
||||
id: `folded-${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]
|
||||
[
|
||||
expandedSections.contacts,
|
||||
expandedSections.folded,
|
||||
expandedSections.groups,
|
||||
foldedGroups,
|
||||
groups,
|
||||
users
|
||||
]
|
||||
)
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
@@ -82,7 +127,10 @@ export function ConversationSidebar({
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
||||
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
<div
|
||||
className="conversation-virtual-content"
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const row = rows[virtualItem.index]
|
||||
if (!row) return null
|
||||
@@ -93,9 +141,15 @@ export function ConversationSidebar({
|
||||
key={virtualItem.key}
|
||||
type="button"
|
||||
className="conversation-section-header conversation-virtual-row"
|
||||
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||
style={{
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
height: `${virtualItem.size}px`
|
||||
}}
|
||||
onClick={() =>
|
||||
setExpandedSections((current) => ({ ...current, [row.section]: !current[row.section] }))
|
||||
setExpandedSections((current) => ({
|
||||
...current,
|
||||
[row.section]: !current[row.section]
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="conversation-section-chevron" aria-hidden="true">
|
||||
@@ -103,7 +157,9 @@ export function ConversationSidebar({
|
||||
<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>
|
||||
<span className="conversation-section-title">
|
||||
{row.title} ({row.count})
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -111,7 +167,10 @@ export function ConversationSidebar({
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
className="conversation-virtual-row"
|
||||
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||
style={{
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
height: `${virtualItem.size}px`
|
||||
}}
|
||||
>
|
||||
<ConversationItem
|
||||
contact={row.contact}
|
||||
@@ -124,7 +183,12 @@ export function ConversationSidebar({
|
||||
</div>
|
||||
</div>
|
||||
<div className="conversation-sidebar-account">
|
||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||
<AccountSummary
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +1,21 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import type { Contact, Message } from '../../../../shared/types'
|
||||
import type { Message } from '../../../../shared/types'
|
||||
import type {
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
ExportNameMode
|
||||
} from '../../../../shared/export'
|
||||
import type { ExportRequest, ExportResult, ExportTaskRecord } from '../../../../shared/export'
|
||||
|
||||
type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
|
||||
type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
|
||||
type ExportStatus = 'idle' | 'running' | 'completed'
|
||||
|
||||
interface GroupMemberName {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
interface SelfInfo {
|
||||
wxid: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
const messageKinds = [
|
||||
['text', '文字'],
|
||||
['image', '图片'],
|
||||
['video', '视频'],
|
||||
['voice', '语音'],
|
||||
['sticker', '表情包'],
|
||||
['share', '链接与分享'],
|
||||
['location', '位置'],
|
||||
['system', '系统消息']
|
||||
] as const
|
||||
|
||||
const formatLabels: Record<ExportFormat, { label: string; hint?: string }> = {
|
||||
html: { label: 'HTML', hint: '推荐' },
|
||||
csv: { label: 'CSV' },
|
||||
json: { label: 'JSON' },
|
||||
markdown: { label: 'Markdown' }
|
||||
}
|
||||
const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
|
||||
|
||||
function displayName(contact: Contact | null): string {
|
||||
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
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,
|
||||
@@ -95,7 +41,7 @@ export function ExportWorkspace({
|
||||
const [includeAvatars, setIncludeAvatars] = useState(true)
|
||||
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
||||
const [keepMissing, setKeepMissing] = useState(false)
|
||||
const [keepMissing, setKeepMissing] = useState(true)
|
||||
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||
const [zip, setZip] = useState(false)
|
||||
const [fileName, setFileName] = useState('')
|
||||
@@ -212,6 +158,8 @@ export function ExportWorkspace({
|
||||
|
||||
const handleStart = async (): Promise<void> => {
|
||||
if (!activeContact || status === 'running') return
|
||||
// Runs only from the export button event; a fresh id is required for each job.
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const nextJobId = `export-${Date.now()}`
|
||||
setJobId(nextJobId)
|
||||
setProgress(null)
|
||||
@@ -258,6 +206,9 @@ export function ExportWorkspace({
|
||||
: undefined,
|
||||
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
||||
includeMedia,
|
||||
preferOriginal,
|
||||
fallbackThumbnail,
|
||||
keepMissing,
|
||||
includeAvatars,
|
||||
avatarUrls: exportAvatarUrls,
|
||||
nameMode,
|
||||
@@ -313,97 +264,29 @@ export function ExportWorkspace({
|
||||
|
||||
return (
|
||||
<div className="export-workspace">
|
||||
<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) => setContactFilter(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={() => setContactType(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>
|
||||
<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">
|
||||
<button type="button" className="export-task-center-button" onClick={() => setTaskCenterOpen((open) => !open)}>
|
||||
任务中心{taskCount > 0 ? ` (${taskCount})` : ''}
|
||||
</button>
|
||||
{taskCenterOpen && (
|
||||
<section className="export-task-center">
|
||||
<div className="export-section-heading"><h3>导出任务</h3><span>{exportTasks.length} 条记录</span></div>
|
||||
{exportTasks.length === 0 ? <p>暂无导出记录</p> : exportTasks.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={() => void onCancelExport(task.jobId)}>取消</button>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
<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 ? (
|
||||
@@ -425,20 +308,39 @@ export function ExportWorkspace({
|
||||
<h3>导出格式</h3>
|
||||
<div className="export-format-grid">
|
||||
{formatOrder.map((value) => (
|
||||
<button key={value} type="button" className={format === value ? 'active' : ''} onClick={() => setFormat(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>
|
||||
<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 资源包
|
||||
<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
|
||||
<input
|
||||
type="radio"
|
||||
name="html-package-top"
|
||||
checked={zip}
|
||||
onChange={() => setZip(true)}
|
||||
/>{' '}
|
||||
HTML 资源包并压缩为 ZIP
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
@@ -505,19 +407,13 @@ export function ExportWorkspace({
|
||||
<h3>消息内容</h3>
|
||||
<div className="export-kind-grid">
|
||||
{messageKinds.map(([value, label]) => (
|
||||
<label key={value} className={`export-check-row ${value === 'video' ? 'unsupported' : ''}`}>
|
||||
<label key={value} className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value !== 'video' && selectedKinds.has(value)}
|
||||
disabled={value === 'video'}
|
||||
checked={selectedKinds.has(value)}
|
||||
onChange={() => toggleKind(value)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
{value === 'video' && (
|
||||
<span className="export-unsupported-hint" title="当前版本暂不支持视频导出" aria-label="当前版本暂不支持视频导出">
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -546,12 +442,14 @@ export function ExportWorkspace({
|
||||
<span>包含图片、视频、语音及动态表情</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeMedia}
|
||||
disabled={format !== 'html'}
|
||||
checked={includeMedia}
|
||||
disabled={format !== 'html'}
|
||||
onChange={(event) => setIncludeMedia(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<div className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}>
|
||||
<div
|
||||
className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}
|
||||
>
|
||||
<label className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -580,7 +478,9 @@ export function ExportWorkspace({
|
||||
<span>媒体缺失时保留占位说明</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="export-helper-text">资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。</p>
|
||||
<p className="export-helper-text">
|
||||
资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。
|
||||
</p>
|
||||
<div className="export-resource-statuses">
|
||||
<span>图片解密:已就绪</span>
|
||||
<span>视频资源:可用</span>
|
||||
@@ -678,145 +578,20 @@ export function ExportWorkspace({
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<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={() => {
|
||||
void window.api.cancelExport(jobId)
|
||||
setStatus('idle')
|
||||
}}
|
||||
>
|
||||
取消导出
|
||||
</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 && void window.api.revealExport(progress.outputPath)
|
||||
}
|
||||
>
|
||||
打开档案
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="export-open-folder-button"
|
||||
onClick={() =>
|
||||
progress?.outputPath && void window.api.revealExport(progress.outputPath)
|
||||
}
|
||||
>
|
||||
在文件夹中显示
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<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
|
||||
}
|
||||
@@ -9,13 +9,17 @@ export type ImageLoadOptions = {
|
||||
}
|
||||
|
||||
type QueueItem = {
|
||||
requestKey: string
|
||||
priority: number
|
||||
run: () => Promise<LoadedImage>
|
||||
resolve: (value: LoadedImage) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_IMAGE_LOADS = 3
|
||||
// Cache probes are cheap asynchronous IPC calls. Cold decrypts are serialized
|
||||
// in the main process, so renderer concurrency only controls how quickly disk
|
||||
// cache hits can become visible after a restart.
|
||||
const MAX_CONCURRENT_IMAGE_LOADS = 32
|
||||
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||
const imageCache = new Map<string, LoadedImage>()
|
||||
const imageCacheSizes = new Map<string, number>()
|
||||
@@ -55,6 +59,12 @@ function getCachedImage(
|
||||
for (const key of keys) {
|
||||
const cached = imageCache.get(key)
|
||||
if (!cached) continue
|
||||
if (options.force && cached.isThumbnail) {
|
||||
imageCache.delete(key)
|
||||
imageCacheBytes -= imageCacheSizes.get(key) || 0
|
||||
imageCacheSizes.delete(key)
|
||||
continue
|
||||
}
|
||||
imageCache.delete(key)
|
||||
imageCache.set(key, cached)
|
||||
return cached
|
||||
@@ -77,6 +87,10 @@ function cacheImage(
|
||||
options: ImageLoadOptions,
|
||||
image: LoadedImage
|
||||
): void {
|
||||
// A forced original request may temporarily fall back to a thumbnail. Do not
|
||||
// let that fallback prevent a later retry after WeChat downloads the original.
|
||||
if (options.force && image.isThumbnail) return
|
||||
|
||||
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||
const size = image.data.length * 2
|
||||
for (const key of keys) {
|
||||
@@ -99,6 +113,8 @@ function cacheImage(
|
||||
}
|
||||
|
||||
function pumpImageQueue(): void {
|
||||
if (activeImageLoads >= MAX_CONCURRENT_IMAGE_LOADS || imageQueue.length === 0) return
|
||||
|
||||
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
|
||||
imageQueue.sort((left, right) => left.priority - right.priority)
|
||||
const item = imageQueue.shift()
|
||||
@@ -114,6 +130,10 @@ function pumpImageQueue(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isSupportedImageUrl(value: string | undefined): value is string {
|
||||
return Boolean(value?.startsWith('data:image/') || value?.startsWith('wxe-media://'))
|
||||
}
|
||||
|
||||
export function getCachedLoadedImage(
|
||||
imageMd5?: string,
|
||||
imageDatName?: string,
|
||||
@@ -136,16 +156,24 @@ export function requestImage(
|
||||
if (!identity) return Promise.reject(new Error('缺少图片标识'))
|
||||
const requestKey = `${identity}:${cacheMode(options)}`
|
||||
const existingRequest = imageRequests.get(requestKey)
|
||||
if (existingRequest) return existingRequest
|
||||
if (existingRequest) {
|
||||
const queuedItem = imageQueue.find((item) => item.requestKey === requestKey)
|
||||
if (queuedItem && priority < queuedItem.priority) queuedItem.priority = priority
|
||||
return existingRequest
|
||||
}
|
||||
|
||||
const request = new Promise<LoadedImage>((resolve, reject) => {
|
||||
imageQueue.push({
|
||||
requestKey,
|
||||
priority,
|
||||
resolve,
|
||||
reject,
|
||||
run: async () => {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options)
|
||||
if (!result.success || !result.data?.startsWith('data:image/')) {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, {
|
||||
...options,
|
||||
priority
|
||||
})
|
||||
if (!result.success || !isSupportedImageUrl(result.data)) {
|
||||
throw new Error(result.error || '加载图片失败')
|
||||
}
|
||||
const loadedImage = {
|
||||
|
||||
@@ -15,8 +15,12 @@ interface AppShellProps {
|
||||
activePage: AppPage
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
onPageChange: (page: AppPage) => void
|
||||
onOpenSettings: () => void
|
||||
onOpenGuide: () => void
|
||||
appearanceTheme?: 'system' | 'light' | 'dark'
|
||||
compactMode?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -32,19 +36,42 @@ export function AppShell({
|
||||
activePage,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
onPageChange,
|
||||
onOpenSettings,
|
||||
onOpenGuide,
|
||||
appearanceTheme = 'system',
|
||||
compactMode = false,
|
||||
children
|
||||
}: AppShellProps): React.ReactElement {
|
||||
const activeItem = PRIMARY_NAV_ITEMS.find((item) => item.id === activePage)
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className={`app-shell theme-${appearanceTheme} ${compactMode ? 'is-compact' : ''}`}>
|
||||
<aside className="app-primary-rail">
|
||||
<BrandLogo />
|
||||
<PrimaryNavigation activePage={activePage} onPageChange={onPageChange} />
|
||||
<button
|
||||
type="button"
|
||||
className="app-guide-launcher"
|
||||
onClick={onOpenGuide}
|
||||
title="重新打开新手引导"
|
||||
>
|
||||
<span className="app-guide-launcher-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M12 3 14.2 9.8 21 12l-6.8 2.2L12 21l-2.2-6.8L3 12l6.8-2.2L12 3Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="app-guide-launcher-label">新手引导</span>
|
||||
</button>
|
||||
<div className="app-rail-account">
|
||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} compact onClick={onOpenSettings} />
|
||||
<AccountSummary
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
compact
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="app-shell-main" aria-label={activeItem?.label || '工作区'}>
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface NavigationItem {
|
||||
|
||||
export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
|
||||
{ id: 'archive', label: '档案' },
|
||||
{ id: 'search', label: '检索' },
|
||||
{ id: 'search', label: '问问微信' },
|
||||
{ id: 'report', label: '日报' },
|
||||
{ id: 'agent-hub', label: 'Agent' },
|
||||
{ id: 'export', label: '导出' },
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ReportHistorySidebarProps {
|
||||
selectedReportId: string | null
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
onSelectReport: (reportId: string) => void
|
||||
onCreateReport: () => void
|
||||
onDeleteReport: (reportId: string) => Promise<{ success: boolean; error?: string }>
|
||||
@@ -80,6 +81,7 @@ export function ReportHistorySidebar({
|
||||
selectedReportId,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
onSelectReport,
|
||||
onCreateReport,
|
||||
onDeleteReport,
|
||||
@@ -202,7 +204,12 @@ export function ReportHistorySidebar({
|
||||
)}
|
||||
</div>
|
||||
<div className="report-history-account">
|
||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||
<AccountSummary
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
{pendingDelete && (
|
||||
<div className="report-delete-confirm" role="dialog" aria-modal="true">
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ReportSourceSidebarProps {
|
||||
selectedContact: Contact | null
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
onSelectContact: (contact: Contact) => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export function ReportSourceSidebar({
|
||||
selectedContact,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
onSelectContact,
|
||||
onOpenSettings
|
||||
}: ReportSourceSidebarProps): React.ReactElement {
|
||||
@@ -95,6 +97,7 @@ export function ReportSourceSidebar({
|
||||
<AccountSummary
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,479 +1,47 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||
import type { Contact, Message } from '../../../../shared/types'
|
||||
import { SearchIcon } from '../chat/icons'
|
||||
|
||||
type SearchStage = 'idle' | 'loading' | 'result' | 'insufficient'
|
||||
type SearchScope = 'global' | 'conversation'
|
||||
type SearchRange = 'today' | '7d' | '30d' | 'all'
|
||||
type SearchIntent = 'general' | 'topic' | 'participants' | 'mixed'
|
||||
|
||||
interface EvidenceItem {
|
||||
contact: Contact
|
||||
message: Message
|
||||
}
|
||||
|
||||
interface AISearchCacheRecord {
|
||||
version: 1
|
||||
key: string
|
||||
createdAt: number
|
||||
answer: string
|
||||
evidence: EvidenceItem[]
|
||||
senderNames: Record<string, string>
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
interface GroupMemberName {
|
||||
wxid: string
|
||||
nickname?: string
|
||||
groupNickname?: string
|
||||
remark?: string
|
||||
wechatNickname?: string
|
||||
}
|
||||
|
||||
interface SenderDirectory {
|
||||
displayNames: Record<string, string>
|
||||
aliases: Record<string, string[]>
|
||||
}
|
||||
|
||||
interface SearchQueryPlan {
|
||||
intent: SearchIntent
|
||||
keywords: string[]
|
||||
variants: string[]
|
||||
source: 'local' | 'ai' | 'hybrid'
|
||||
}
|
||||
|
||||
interface SearchPassSummary {
|
||||
label: string
|
||||
keywords: string[]
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const RANGE_LABELS: Record<SearchRange, string> = {
|
||||
today: '今天',
|
||||
'7d': '近 7 天',
|
||||
'30d': '近 30 天',
|
||||
all: '全部历史'
|
||||
}
|
||||
|
||||
const SEARCH_CACHE_KEY = 'wxe_ai_search_cache_v8'
|
||||
const SEARCH_HISTORY_KEY = 'wxe_ai_search_history_v1'
|
||||
const SEARCH_CACHE_LIMIT = 20
|
||||
const currentTimestamp = (): number => Date.now()
|
||||
|
||||
const SEARCH_INTENT_PHRASES = [
|
||||
'全局搜一下',
|
||||
'全局搜索',
|
||||
'搜索一下',
|
||||
'搜一下',
|
||||
'查询一下',
|
||||
'查一下',
|
||||
'找一下',
|
||||
'我和谁聊过',
|
||||
'谁和我聊过',
|
||||
'哪些人和我聊过',
|
||||
'最近讨论了什么',
|
||||
'最近聊了什么',
|
||||
'最近说了什么',
|
||||
'讨论了什么',
|
||||
'讨论什么',
|
||||
'聊了什么',
|
||||
'聊些什么',
|
||||
'说了什么',
|
||||
'说些什么',
|
||||
'最近讨论',
|
||||
'最近聊天',
|
||||
'这个话题',
|
||||
'相关话题',
|
||||
'的聊天',
|
||||
'的内容',
|
||||
'的记录',
|
||||
'关于',
|
||||
'聊天',
|
||||
'记录',
|
||||
'聊天记录',
|
||||
'帮我',
|
||||
'请问'
|
||||
].sort((left, right) => right.length - left.length)
|
||||
|
||||
const SEARCH_STOP_WORDS = new Set([
|
||||
'我',
|
||||
'谁',
|
||||
'什么',
|
||||
'哪些',
|
||||
'哪个',
|
||||
'人',
|
||||
'和',
|
||||
'聊过',
|
||||
'说过',
|
||||
'提到',
|
||||
'讨论',
|
||||
'聊天',
|
||||
'记录',
|
||||
'说',
|
||||
'聊',
|
||||
'话题',
|
||||
'内容',
|
||||
'相关',
|
||||
'最近',
|
||||
'一下'
|
||||
])
|
||||
|
||||
const messageText = (message: Message): string =>
|
||||
String(message.content || '').trim() || `[${message.type || '消息'}]`
|
||||
|
||||
const normalizeSearchText = (value: string): string => value.toLowerCase().replace(/\s+/g, '')
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
const messageIdentity = (message: Message): string =>
|
||||
message.localId
|
||||
? `local:${message.localId}`
|
||||
: message.id || `${message.createTime}:${message.content}`
|
||||
|
||||
const evidenceIdentity = ({ contact, message }: EvidenceItem): string =>
|
||||
`${contact.md5}:${messageIdentity(message)}`
|
||||
|
||||
const formatMessageTime = (message: Message): string => {
|
||||
if (message.datetime) return message.datetime
|
||||
if (message.createTime) return new Date(message.createTime * 1000).toLocaleString('zh-CN')
|
||||
return '未知时间'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
const formatMessageDate = (dateKey: string): string => {
|
||||
if (dateKey === 'unknown') return '--/--'
|
||||
const [, month, day] = dateKey.split('-')
|
||||
return `${month}/${day}`
|
||||
}
|
||||
|
||||
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]
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const formatMemberName = (member: GroupMemberName): string =>
|
||||
member.groupNickname || member.wechatNickname || member.nickname || member.remark || member.wxid
|
||||
|
||||
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 || '联系人' : '群成员'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
const buildSearchCacheKey = (
|
||||
scope: SearchScope,
|
||||
contactMd5: string,
|
||||
range: SearchRange,
|
||||
query: string
|
||||
): string => JSON.stringify([scope, contactMd5, range, query.trim().toLowerCase()])
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
})
|
||||
|
||||
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>
|
||||
})
|
||||
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,
|
||||
@@ -1073,7 +641,7 @@ export function AISearchWorkspace({
|
||||
<header className="ai-search-header">
|
||||
<div>
|
||||
<span className="ai-search-kicker">WechatExplorer · LOCAL INTELLIGENCE</span>
|
||||
<h1>AI 智能检索</h1>
|
||||
<h1>问问你的微信</h1>
|
||||
<p>在本地聊天记录中提炼主题、结论和可追溯证据</p>
|
||||
</div>
|
||||
<div className="ai-search-header-actions">
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
||||
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 { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||
|
||||
@@ -16,6 +19,7 @@ export function SettingsWorkspace({
|
||||
onCategoryChange,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
dbKey,
|
||||
onDbKeyChange,
|
||||
onDatabaseConnectionChange,
|
||||
@@ -25,12 +29,15 @@ export function SettingsWorkspace({
|
||||
onReturnToLogin,
|
||||
onAIRuntimeChange,
|
||||
onNotice,
|
||||
onOpenSettings
|
||||
onOpenSettings,
|
||||
onAppearanceChange,
|
||||
onSwitchAccount
|
||||
}: {
|
||||
selectedCategory: SettingsCategoryId
|
||||
onCategoryChange: (id: SettingsCategoryId) => void
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
dbKey: string
|
||||
onDbKeyChange: (key: string) => void
|
||||
onDatabaseConnectionChange: (connected: boolean) => void
|
||||
@@ -41,7 +48,61 @@ export function SettingsWorkspace({
|
||||
onAIRuntimeChange: (config: AIRuntimeModelConfig) => void
|
||||
onNotice: (message: string) => void
|
||||
onOpenSettings: () => void
|
||||
onAppearanceChange: (settings: {
|
||||
theme: 'system' | 'light' | 'dark'
|
||||
compactMode: boolean
|
||||
}) => void
|
||||
onSwitchAccount: (
|
||||
account: import('../../../../shared/database-key').WechatAccountCandidate
|
||||
) => Promise<void>
|
||||
}): React.ReactElement {
|
||||
const renderSelectedPage = (): React.ReactElement => {
|
||||
switch (selectedCategory) {
|
||||
case 'account-database':
|
||||
return (
|
||||
<AccountDatabasePage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
selfInfo={selfInfo}
|
||||
onNotice={onNotice}
|
||||
onSwitchAccount={onSwitchAccount}
|
||||
/>
|
||||
)
|
||||
case 'database-key':
|
||||
return (
|
||||
<DatabaseKeyPage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
selfInfo={selfInfo}
|
||||
onDbKeyChange={onDbKeyChange}
|
||||
onDatabaseConnectionChange={onDatabaseConnectionChange}
|
||||
onSelfInfoChange={onSelfInfoChange}
|
||||
onContactsChange={onContactsChange}
|
||||
onFilteredContactsChange={onFilteredContactsChange}
|
||||
onReturnToLogin={onReturnToLogin}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
)
|
||||
case 'image-key':
|
||||
return <ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
||||
case 'ai-model':
|
||||
return <AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||
case 'recall-protection':
|
||||
return <RecallProtectionPage onNotice={onNotice} />
|
||||
case 'advanced':
|
||||
return <AdvancedPage onNotice={onNotice} />
|
||||
case 'cache-cleanup':
|
||||
return <CacheCleanupPage onNotice={onNotice} />
|
||||
case 'appearance':
|
||||
return <AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
|
||||
case 'about':
|
||||
return <AboutPage onNotice={onNotice} />
|
||||
default:
|
||||
return <SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-workspace">
|
||||
<SettingsSidebar
|
||||
@@ -49,58 +110,10 @@ export function SettingsWorkspace({
|
||||
onSelect={onCategoryChange}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
<div
|
||||
className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}
|
||||
>
|
||||
<AccountDatabasePage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
selfInfo={selfInfo}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
</div>
|
||||
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
|
||||
<DatabaseKeyPage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
selfInfo={selfInfo}
|
||||
onDbKeyChange={onDbKeyChange}
|
||||
onDatabaseConnectionChange={onDatabaseConnectionChange}
|
||||
onSelfInfoChange={onSelfInfoChange}
|
||||
onContactsChange={onContactsChange}
|
||||
onFilteredContactsChange={onFilteredContactsChange}
|
||||
onReturnToLogin={onReturnToLogin}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
</div>
|
||||
<div className={`settings-page-panel ${selectedCategory === 'image-key' ? 'active' : ''}`}>
|
||||
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
||||
</div>
|
||||
<div className={`settings-page-panel ${selectedCategory === 'ai-model' ? 'active' : ''}`}>
|
||||
<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>
|
||||
{![
|
||||
'account-database',
|
||||
'database-key',
|
||||
'image-key',
|
||||
'ai-model',
|
||||
'recall-protection',
|
||||
'advanced'
|
||||
].includes(selectedCategory) && (
|
||||
<div className="settings-page-panel active">
|
||||
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-page-panel active">{renderSelectedPage()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export function AccountOverview({
|
||||
isChecking,
|
||||
onCheck,
|
||||
onOpenDirectory,
|
||||
onCopyDirectory
|
||||
onCopyDirectory,
|
||||
onSwitchAccount
|
||||
}: {
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
connectionStatus: ConnectionOverviewStatus
|
||||
@@ -34,6 +35,7 @@ export function AccountOverview({
|
||||
onCheck: () => void
|
||||
onOpenDirectory: () => void
|
||||
onCopyDirectory: () => void
|
||||
onSwitchAccount: () => void
|
||||
}): React.ReactElement {
|
||||
const accountRoot = selfInfo?.accountRoot || ''
|
||||
return (
|
||||
@@ -83,6 +85,9 @@ export function AccountOverview({
|
||||
>
|
||||
打开账号目录
|
||||
</button>
|
||||
<button type="button" className="api-secondary-button" onClick={onSwitchAccount}>
|
||||
切换账号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-account-root">
|
||||
|
||||
@@ -13,11 +13,13 @@ import type { ConnectionCheckState } from './types'
|
||||
export function useAccountDatabaseController({
|
||||
dbKey,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
selfInfo,
|
||||
onNotice
|
||||
}: {
|
||||
dbKey: string
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
onNotice: (message: string) => void
|
||||
}) {
|
||||
@@ -28,7 +30,7 @@ export function useAccountDatabaseController({
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api
|
||||
.getImageDecryptionStatus()
|
||||
.getImageKeyConfig()
|
||||
.then((result) => {
|
||||
if (active) setHasImageKey(result.configured)
|
||||
})
|
||||
@@ -58,8 +60,11 @@ export function useAccountDatabaseController({
|
||||
[checkState, dbKey, dbReady, hasImageKey, selfInfo]
|
||||
)
|
||||
const connectionStatus = useMemo(
|
||||
() => getConnectionOverviewStatus({ dbReady, checkState, diagnostics }),
|
||||
[checkState, dbReady, diagnostics]
|
||||
() =>
|
||||
dbConnecting
|
||||
? ('checking' as const)
|
||||
: getConnectionOverviewStatus({ dbReady, checkState, diagnostics }),
|
||||
[checkState, dbConnecting, dbReady, diagnostics]
|
||||
)
|
||||
const lastCheckedLabel = formatConnectionCheckedAt(checkState, clock)
|
||||
|
||||
@@ -121,7 +126,7 @@ export function useAccountDatabaseController({
|
||||
diagnostics,
|
||||
connectionStatus,
|
||||
checkState,
|
||||
isChecking: checkState.status === 'checking',
|
||||
isChecking: dbConnecting || checkState.status === 'checking',
|
||||
lastCheckedLabel,
|
||||
testConnection,
|
||||
openAccountDirectory,
|
||||
|
||||
@@ -8,12 +8,14 @@ export function SettingsSidebar({
|
||||
onSelect,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
onOpenSettings
|
||||
}: {
|
||||
selectedId: SettingsCategoryId
|
||||
onSelect: (id: SettingsCategoryId) => void
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
onOpenSettings: () => void
|
||||
}): React.ReactElement {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
@@ -57,7 +59,12 @@ export function SettingsSidebar({
|
||||
))}
|
||||
</div>
|
||||
<div className="settings-sidebar-account">
|
||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||
<AccountSummary
|
||||
selfInfo={selfInfo}
|
||||
dbReady={dbReady}
|
||||
dbConnecting={dbConnecting}
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -36,14 +36,14 @@ export function useDatabaseKeyController({
|
||||
}, [])
|
||||
|
||||
const refreshStorage = useCallback(async (): Promise<void> => {
|
||||
const result = await window.api.getSavedDbKey()
|
||||
const result = await window.api.getSavedDbKey(selfInfo?.accountRoot || '')
|
||||
dispatch({
|
||||
type: 'STORAGE_LOADED',
|
||||
saved: result.saved,
|
||||
encryptionAvailable: result.encryptionAvailable,
|
||||
error: result.success ? undefined : result.error
|
||||
})
|
||||
}, [])
|
||||
}, [selfInfo?.accountRoot])
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([refreshStorage(), refreshEnvironment()])
|
||||
@@ -109,7 +109,8 @@ export function useDatabaseKeyController({
|
||||
const saveKey = useCallback(async (): Promise<void> => {
|
||||
if (state.status !== 'valid') return
|
||||
dispatch({ type: 'SAVE_START' })
|
||||
const saved = await window.api.saveDbKey(dbKey)
|
||||
const accountRoot = selfInfo?.accountRoot || ''
|
||||
const saved = await window.api.saveDbKey(accountRoot, dbKey)
|
||||
if (!saved.success || !saved.key) {
|
||||
dispatch({
|
||||
type: 'SAVE_ERROR',
|
||||
@@ -117,12 +118,12 @@ export function useDatabaseKeyController({
|
||||
})
|
||||
return
|
||||
}
|
||||
const stored = await window.api.getSavedDbKey()
|
||||
const stored = await window.api.getSavedDbKey(accountRoot)
|
||||
if (!stored.success || !stored.saved || !stored.key) {
|
||||
dispatch({ type: 'SAVE_ERROR', error: '无法确认密钥保存状态' })
|
||||
return
|
||||
}
|
||||
const initialized = await window.api.initDb(stored.key)
|
||||
const initialized = await window.api.initDb(stored.key, accountRoot)
|
||||
const connected = typeof initialized === 'boolean' ? initialized : initialized.success
|
||||
onDbKeyChange(stored.key)
|
||||
onDatabaseConnectionChange(connected)
|
||||
@@ -148,13 +149,14 @@ export function useDatabaseKeyController({
|
||||
onNotice,
|
||||
onSelfInfoChange,
|
||||
refreshEnvironment,
|
||||
selfInfo?.accountRoot,
|
||||
state.status
|
||||
])
|
||||
|
||||
const autoDetectKey = useCallback(async (): Promise<void> => {
|
||||
dispatch({ type: 'AUTO_START' })
|
||||
await refreshEnvironment()
|
||||
const result = await window.api.autoGetDbKey({ save: false })
|
||||
const result = await window.api.autoGetDbKey(selfInfo?.accountRoot || '', { save: false })
|
||||
if (!result.success || !result.key) {
|
||||
dispatch({ type: 'AUTO_ERROR', error: result.error || '暂未找到有效密钥' })
|
||||
return
|
||||
@@ -162,11 +164,11 @@ export function useDatabaseKeyController({
|
||||
onDbKeyChange(result.key)
|
||||
dispatch({ type: 'AUTO_SUCCESS' })
|
||||
await runValidation(result.key)
|
||||
}, [onDbKeyChange, refreshEnvironment, runValidation])
|
||||
}, [onDbKeyChange, refreshEnvironment, runValidation, selfInfo?.accountRoot])
|
||||
|
||||
const clearSavedKey = useCallback(async (): Promise<void> => {
|
||||
dispatch({ type: 'CLEAR_START' })
|
||||
const result = await window.api.clearSavedDbKey()
|
||||
const result = await window.api.clearSavedDbKey(selfInfo?.accountRoot || '')
|
||||
if (!result.success) {
|
||||
dispatch({ type: 'CLEAR_ERROR', error: '清除密钥失败' })
|
||||
return
|
||||
@@ -187,7 +189,8 @@ export function useDatabaseKeyController({
|
||||
onFilteredContactsChange,
|
||||
onNotice,
|
||||
onSelfInfoChange,
|
||||
refreshEnvironment
|
||||
refreshEnvironment,
|
||||
selfInfo?.accountRoot
|
||||
])
|
||||
|
||||
const returnToLogin = useCallback(async (): Promise<void> => {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ImageDecoderStatus } from '../../../../../shared/image-decryption'
|
||||
|
||||
interface ImageDecoderRequirementNoticeProps {
|
||||
status?: ImageDecoderStatus
|
||||
onNotice: (message: string) => void
|
||||
}
|
||||
|
||||
export function ImageDecoderRequirementNotice({
|
||||
status,
|
||||
onNotice
|
||||
}: ImageDecoderRequirementNoticeProps): React.ReactElement {
|
||||
const platform = window.electron.process.platform
|
||||
const [currentStatus, setCurrentStatus] = useState(status)
|
||||
const [selecting, setSelecting] = useState(false)
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
useEffect(() => setCurrentStatus(status), [status])
|
||||
|
||||
const selectDirectory = async (): Promise<void> => {
|
||||
setSelecting(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const result = await window.api.selectImageDecoder()
|
||||
if (result.canceled) return
|
||||
if (!result.success || !result.status) {
|
||||
setError(result.error || '没有在所选文件夹中找到 FFmpeg,请重新选择。')
|
||||
return
|
||||
}
|
||||
setCurrentStatus(result.status)
|
||||
onNotice(
|
||||
result.status.available
|
||||
? 'FFmpeg 安装目录已保存,原图支持可以使用'
|
||||
: 'FFmpeg 安装目录已保存,但原图支持未通过检测'
|
||||
)
|
||||
} catch {
|
||||
setError('无法打开目录选择窗口,请稍后重试。')
|
||||
} finally {
|
||||
setSelecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkOriginalSupport = async (): Promise<void> => {
|
||||
setChecking(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const nextStatus = await window.api.getImageDecoderStatus()
|
||||
setCurrentStatus(nextStatus)
|
||||
onNotice(nextStatus.available ? '原图支持检测通过' : '原图支持检测未通过')
|
||||
} catch {
|
||||
setError('原图支持检测失败,请稍后重试。')
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDownload = async (): Promise<void> => {
|
||||
setError(undefined)
|
||||
try {
|
||||
const result = await window.api.openImageDecoderDownload()
|
||||
if (!result.success) setError(result.error || '无法打开 FFmpeg 下载页面')
|
||||
} catch {
|
||||
setError('无法打开 FFmpeg 下载页面,请检查系统默认浏览器设置。')
|
||||
}
|
||||
}
|
||||
|
||||
const installed = currentStatus?.installed === true
|
||||
const supported = currentStatus?.available === true
|
||||
const downloadLabel = platform === 'darwin' ? '打开 Homebrew 官网' : '下载 FFmpeg'
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`image-decoder-requirement ${supported ? 'ready' : ''}`}
|
||||
aria-label="FFmpeg 原图支持"
|
||||
>
|
||||
<span className="image-decoder-requirement-icon" aria-hidden>
|
||||
{supported ? '✓' : '!'}
|
||||
</span>
|
||||
<div className="image-decoder-requirement-body">
|
||||
<div className="image-decoder-requirement-heading">
|
||||
<strong>FFmpeg 原图支持</strong>
|
||||
<span>
|
||||
{supported ? '原图支持可用' : installed ? 'FFmpeg 已安装' : '需要安装 FFmpeg'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>如需打开少量 wxgf/HEVC 特殊原图,必须安装 FFmpeg。未安装不会影响聊天记录和普通图片。</p>
|
||||
|
||||
<div className="image-decoder-checks">
|
||||
<div>
|
||||
<span className={`image-decoder-check-index ${installed ? 'complete' : ''}`}>1</span>
|
||||
<div>
|
||||
<strong>FFmpeg 安装目录</strong>
|
||||
<small title={currentStatus?.directory}>
|
||||
{currentStatus?.directory || '尚未检测到 FFmpeg 安装目录'}
|
||||
</small>
|
||||
</div>
|
||||
<b className={installed ? 'success' : ''}>{installed ? '已检测' : '未检测'}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`image-decoder-check-index ${supported ? 'complete' : ''}`}>2</span>
|
||||
<div>
|
||||
<strong>原图支持检测</strong>
|
||||
<small>
|
||||
{supported
|
||||
? '已支持 wxgf/HEVC 特殊原图转换'
|
||||
: installed
|
||||
? '尚未检测到 HEVC 原图转换能力'
|
||||
: '请先安装并检测 FFmpeg 目录'}
|
||||
</small>
|
||||
</div>
|
||||
<b className={supported ? 'success' : ''}>{supported ? '已通过' : '未通过'}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="image-decoder-requirement-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-header-action"
|
||||
onClick={() => void openDownload()}
|
||||
>
|
||||
{downloadLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-primary-button"
|
||||
disabled={selecting}
|
||||
onClick={() => void selectDirectory()}
|
||||
>
|
||||
{selecting ? '正在保存…' : '填写 FFmpeg 安装目录'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-header-action"
|
||||
disabled={!installed || checking}
|
||||
onClick={() => void checkOriginalSupport()}
|
||||
>
|
||||
{checking ? '正在检测…' : '检测原图支持'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="image-decoder-platform-help">
|
||||
{platform === 'win32' ? (
|
||||
<>
|
||||
<strong>Windows 安装与定位</strong>
|
||||
<p>
|
||||
下载后右键压缩包选择“全部解压”,再填写解压后的文件夹。如果已经安装但没有自动找到,可在
|
||||
PowerShell 输入 <code>(Get-Command ffmpeg).Source</code>,或在命令提示符(CMD)输入{' '}
|
||||
<code>where ffmpeg</code>,然后选择返回路径所在的 bin 文件夹。
|
||||
</p>
|
||||
</>
|
||||
) : platform === 'darwin' ? (
|
||||
<>
|
||||
<strong>macOS 安装与定位</strong>
|
||||
<p>
|
||||
打开“终端”并输入 <code>brew install ffmpeg</code>。如果提示没有
|
||||
brew,请先通过上方按钮打开 Homebrew 官网完成安装。安装后输入{' '}
|
||||
<code>which ffmpeg</code>,再选择返回路径所在的文件夹, 通常是{' '}
|
||||
<code>/opt/homebrew/bin</code> 或 <code>/usr/local/bin</code>。
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Linux 安装与定位</strong>
|
||||
<p>
|
||||
通过系统包管理器安装 FFmpeg,执行 <code>which ffmpeg</code>{' '}
|
||||
查看位置,再选择返回路径所在的文件夹。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="image-decoder-requirement-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { LocalPrivacyNotice } from '../account-database/LocalPrivacyNotice'
|
||||
import { useAccountDatabaseController } from '../account-database/useAccountDatabaseController'
|
||||
import type { ConnectionOverviewStatus } from '../account-database/types'
|
||||
import type { SettingsSelfInfo } from '../model/types'
|
||||
import type { WechatAccountCandidate } from '../../../../../shared/database-key'
|
||||
|
||||
const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = {
|
||||
checking: '正在检测',
|
||||
@@ -17,16 +18,28 @@ const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = {
|
||||
export function AccountDatabasePage({
|
||||
dbKey,
|
||||
dbReady,
|
||||
dbConnecting = false,
|
||||
selfInfo,
|
||||
onNotice
|
||||
onNotice,
|
||||
onSwitchAccount
|
||||
}: {
|
||||
dbKey: string
|
||||
dbReady: boolean
|
||||
dbConnecting?: boolean
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
onNotice: (message: string) => void
|
||||
onSwitchAccount: (account: WechatAccountCandidate) => Promise<void>
|
||||
}): React.ReactElement {
|
||||
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
|
||||
const controller = useAccountDatabaseController({
|
||||
dbKey,
|
||||
dbReady,
|
||||
dbConnecting,
|
||||
selfInfo,
|
||||
onNotice
|
||||
})
|
||||
const [autoLogin, setAutoLogin] = useState(false)
|
||||
const [switching, setSwitching] = useState(false)
|
||||
const [accounts, setAccounts] = useState<WechatAccountCandidate[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
@@ -48,6 +61,18 @@ export function AccountDatabasePage({
|
||||
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
|
||||
}
|
||||
|
||||
const openAccountSwitcher = async (): Promise<void> => {
|
||||
if (!selfInfo?.accountRoot) return
|
||||
const parentRoot = selfInfo.accountRoot.replace(/[\\/][^\\/]+[\\/]?$/, '')
|
||||
const result = await window.api.discoverAccounts(parentRoot)
|
||||
if (!result.success) {
|
||||
onNotice(result.error || '无法读取账号列表')
|
||||
return
|
||||
}
|
||||
setAccounts(result.accounts)
|
||||
setSwitching(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<header className="settings-page-header">
|
||||
@@ -71,7 +96,45 @@ export function AccountDatabasePage({
|
||||
onCheck={() => void controller.testConnection()}
|
||||
onOpenDirectory={() => void controller.openAccountDirectory()}
|
||||
onCopyDirectory={() => void controller.copyAccountDirectory()}
|
||||
onSwitchAccount={() => void openAccountSwitcher()}
|
||||
/>
|
||||
{switching && (
|
||||
<section className="settings-card database-account-list" aria-label="切换微信账号">
|
||||
<h2>选择要切换的账号</h2>
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
type="button"
|
||||
key={account.id}
|
||||
className="database-account-card"
|
||||
disabled={account.accountRoot === selfInfo?.accountRoot}
|
||||
onClick={() => void onSwitchAccount(account).then(() => setSwitching(false))}
|
||||
>
|
||||
<span className="database-account-avatar">
|
||||
{account.avatar ? (
|
||||
<img src={account.avatar} alt="" />
|
||||
) : (
|
||||
(account.nickname || '?').charAt(0)
|
||||
)}
|
||||
</span>
|
||||
<span className="database-account-identity">
|
||||
<strong>{account.nickname || '昵称未识别'}</strong>
|
||||
<small>{account.wxid || 'wxid 未识别'}</small>
|
||||
<code>{account.accountRoot}</code>
|
||||
</span>
|
||||
<span className="database-account-status">
|
||||
{account.hasSavedDbKey ? '已有可用密钥' : '需要获取密钥'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="api-secondary-button"
|
||||
onClick={() => setSwitching(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
<h2 className="settings-section-heading">连接健康检查</h2>
|
||||
<ConnectionHealthSection
|
||||
diagnostics={controller.diagnostics}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SettingsSelfInfo } from '../model/types'
|
||||
import { AutoDetectImageKeySection } from '../image-decryption/AutoDetectImageKeySection'
|
||||
import { DangerZone } from '../image-decryption/DangerZone'
|
||||
import { ImageDecoderRequirementNotice } from '../image-decryption/ImageDecoderRequirementNotice'
|
||||
import { ImageDecryptStatus } from '../image-decryption/ImageDecryptStatus'
|
||||
import { ImageKeyConfiguration } from '../image-decryption/ImageKeyConfiguration'
|
||||
import { ImageTestSection } from '../image-decryption/ImageTestSection'
|
||||
@@ -55,6 +56,11 @@ export function ImageDecryptionPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ImageDecoderRequirementNotice
|
||||
status={controller.state.status?.decoder}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
|
||||
<h2 className="settings-section-heading">图片解密状态</h2>
|
||||
<ImageDecryptStatus
|
||||
state={controller.state}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/tokens.css'
|
||||
import './assets/main.css'
|
||||
import './styles/search.css'
|
||||
import './styles/archive.css'
|
||||
import './styles/settings-advanced.css'
|
||||
import './styles/index.scss'
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
.archive-jump-target-group {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.archive-jump-message {
|
||||
z-index: 1;
|
||||
animation: archive-jump-flash 0.72s ease-in-out 2;
|
||||
.archive-jump-message {
|
||||
z-index: 1;
|
||||
animation: archive-jump-flash 0.72s ease-in-out 2;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes archive-jump-flash {
|
||||
@@ -20,9 +20,11 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.archive-jump-message {
|
||||
outline: 3px solid rgba(38, 128, 103, 0.58);
|
||||
outline-offset: 5px;
|
||||
animation: none;
|
||||
.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,406 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
.app-guide-launcher {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
margin: 0 0 8px;
|
||||
padding: 5px 0;
|
||||
border: 0;
|
||||
border-radius: var(--wxex-radius-md);
|
||||
color: var(--wxex-text-secondary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: var(--wxex-font);
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&:hover {
|
||||
color: var(--wxex-brand);
|
||||
background: var(--wxex-brand-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.app-guide-launcher-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
}
|
||||
|
||||
.app-guide-launcher-label {
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
&.connecting {
|
||||
background: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
&.connecting {
|
||||
background: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.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,422 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.forward-bundle-message {
|
||||
width: min(320px, 56vw);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.forward-bundle-header,
|
||||
.forward-bundle-toggle {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.forward-bundle-header {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 0 0 9px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.forward-bundle-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 9px 0;
|
||||
}
|
||||
|
||||
.forward-bundle-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 3px 6px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
|
||||
b {
|
||||
color: var(--wxex-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
small {
|
||||
grid-column: 2;
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.forward-bundle-empty,
|
||||
.unsupported-message span {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.forward-bundle-toggle {
|
||||
padding: 8px 0 0;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
color: var(--wxex-brand);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unsupported-message {
|
||||
display: grid;
|
||||
min-width: 150px;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -21,24 +21,21 @@
|
||||
padding: 14px 22px 12px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
background: rgba(250, 251, 250, 0.94);
|
||||
}
|
||||
|
||||
.ai-search-header h1,
|
||||
.ai-search-header p {
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.ai-search-header h1 {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.ai-search-header p {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-kicker {
|
||||
@@ -57,28 +54,36 @@
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.ai-search-model-status > span:first-child {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--wxex-text-muted);
|
||||
}
|
||||
> span:first-child {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--wxex-text-muted);
|
||||
}
|
||||
|
||||
.ai-search-model-status > span.ready {
|
||||
background: var(--wxex-success);
|
||||
}
|
||||
> span {
|
||||
&.ready {
|
||||
background: var(--wxex-success);
|
||||
}
|
||||
|
||||
.ai-search-model-status > span.warning {
|
||||
background: var(--wxex-warning);
|
||||
}
|
||||
&.warning {
|
||||
background: var(--wxex-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-model-status > span:nth-child(2) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
> span:nth-child(2) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 5px 8px;
|
||||
color: var(--wxex-brand);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-model-status button,
|
||||
@@ -93,12 +98,6 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ai-search-model-status button {
|
||||
padding: 5px 8px;
|
||||
color: var(--wxex-brand);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-search-grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
@@ -134,25 +133,26 @@
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ai-search-panel-heading div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ai-search-panel-heading span:first-child {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
}
|
||||
span:first-child {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.ai-search-panel-heading strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.ai-search-local-badge,
|
||||
@@ -179,33 +179,17 @@
|
||||
padding: 3px;
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-elevated);
|
||||
}
|
||||
|
||||
.ai-search-scope-toggle button,
|
||||
.ai-search-range-grid button,
|
||||
.ai-search-history button {
|
||||
border: 0;
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--wxex-text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
padding: 7px 4px;
|
||||
font-size: 11px;
|
||||
|
||||
.ai-search-scope-toggle button {
|
||||
padding: 7px 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ai-search-scope-toggle button.active,
|
||||
.ai-search-range-grid button.active {
|
||||
background: var(--wxex-brand);
|
||||
color: var(--wxex-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ai-search-scope-toggle button.active {
|
||||
color: #fff;
|
||||
&.active {
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-scope-help {
|
||||
@@ -235,24 +219,24 @@
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-elevated);
|
||||
}
|
||||
|
||||
.ai-search-filter-input svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
|
||||
.ai-search-filter-input input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--wxex-text-primary);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--wxex-text-primary);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-contact-list {
|
||||
@@ -277,11 +261,11 @@
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ai-search-contact:hover,
|
||||
.ai-search-contact.active {
|
||||
background: var(--wxex-brand-soft);
|
||||
&:hover,
|
||||
&.active {
|
||||
background: var(--wxex-brand-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-contact-avatar {
|
||||
@@ -296,12 +280,12 @@
|
||||
color: var(--wxex-brand);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ai-search-contact-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-contact > span:last-child {
|
||||
@@ -337,6 +321,31 @@
|
||||
background: var(--wxex-bg-elevated);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-bottom: 16px;
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--wxex-text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
min-height: 34px;
|
||||
padding: 7px 4px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--wxex-brand);
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(34, 89, 74, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-field-heading {
|
||||
@@ -356,26 +365,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ai-search-range-grid button {
|
||||
min-height: 34px;
|
||||
padding: 7px 4px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ai-search-range-grid button:hover {
|
||||
border-color: var(--wxex-brand);
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
.ai-search-range-grid button.active,
|
||||
.ai-search-range-grid button.active:hover {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(34, 89, 74, 0.2);
|
||||
}
|
||||
|
||||
.ai-search-filter-note {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -386,28 +375,34 @@
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.ai-search-filter-note span {
|
||||
color: var(--wxex-success);
|
||||
span {
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-history {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.ai-search-history button {
|
||||
min-width: 0;
|
||||
padding: 5px 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
}
|
||||
button {
|
||||
min-width: 0;
|
||||
padding: 5px 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--wxex-text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
|
||||
.ai-search-history button:hover {
|
||||
color: var(--wxex-brand);
|
||||
&:hover {
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-history-item {
|
||||
@@ -415,10 +410,10 @@
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-search-history-item > button:first-child {
|
||||
flex: 1;
|
||||
> button:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-history-delete {
|
||||
@@ -434,11 +429,11 @@
|
||||
font-size: 16px !important;
|
||||
line-height: 18px;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.ai-search-history-delete:hover {
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-danger, #b34d46) !important;
|
||||
&:hover {
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-danger, #b34d46) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-main {
|
||||
@@ -468,6 +463,22 @@
|
||||
padding: 48px 24px;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
margin: 7px 0 8px;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 440px;
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-empty-mark {
|
||||
@@ -482,24 +493,18 @@
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.ai-search-empty h2,
|
||||
.ai-search-loading h2,
|
||||
.ai-search-insufficient h2 {
|
||||
margin: 7px 0 8px;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 26px;
|
||||
}
|
||||
.ai-search-insufficient {
|
||||
button {
|
||||
margin-top: 20px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
|
||||
.ai-search-empty p,
|
||||
.ai-search-loading p,
|
||||
.ai-search-insufficient p {
|
||||
max-width: 440px;
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
&.primary {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-prompts {
|
||||
@@ -508,22 +513,22 @@
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.ai-search-prompts button {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
button {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-secondary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
|
||||
.ai-search-prompts button:hover {
|
||||
border-color: var(--wxex-brand);
|
||||
color: var(--wxex-brand);
|
||||
&:hover {
|
||||
border-color: var(--wxex-brand);
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-spinner {
|
||||
@@ -550,14 +555,15 @@
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ai-search-loading-steps .done {
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
.ai-search-loading-steps .active {
|
||||
color: var(--wxex-brand);
|
||||
font-weight: 700;
|
||||
.done {
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--wxex-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-result {
|
||||
@@ -573,21 +579,21 @@
|
||||
gap: 18px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
}
|
||||
|
||||
.ai-search-result-header h2 {
|
||||
margin: 6px 0 5px;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 26px;
|
||||
}
|
||||
h2 {
|
||||
margin: 6px 0 5px;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.ai-search-result-header p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-result-actions {
|
||||
@@ -596,19 +602,17 @@
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ai-search-result-actions button {
|
||||
padding: 7px 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
button {
|
||||
padding: 7px 9px;
|
||||
font-size: 11px;
|
||||
|
||||
.ai-search-result-actions button.primary,
|
||||
.ai-search-composer button.primary,
|
||||
.ai-search-insufficient button.primary {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
&.primary {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-summary-block {
|
||||
@@ -656,12 +660,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ai-search-insufficient button {
|
||||
margin-top: 20px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ai-search-composer {
|
||||
padding: 12px 18px 14px;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
@@ -678,13 +676,16 @@
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.ai-search-composer-meta strong {
|
||||
color: var(--wxex-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
.ai-search-composer-meta em {
|
||||
font-style: normal;
|
||||
color: var(--wxex-text-secondary);
|
||||
.ai-search-composer-meta {
|
||||
strong {
|
||||
color: var(--wxex-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-search-composer-row {
|
||||
@@ -692,41 +693,50 @@
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.ai-search-composer textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
resize: none;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
outline: 0;
|
||||
background: var(--wxex-bg-main);
|
||||
color: var(--wxex-text-primary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
resize: none;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
outline: 0;
|
||||
background: var(--wxex-bg-main);
|
||||
color: var(--wxex-text-primary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
|
||||
.ai-search-composer textarea:focus {
|
||||
border-color: var(--wxex-brand);
|
||||
}
|
||||
.ai-search-composer button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 13px;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.ai-search-composer button span {
|
||||
font-size: 16px;
|
||||
line-height: 12px;
|
||||
}
|
||||
.ai-search-composer button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
&:focus {
|
||||
border-color: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 13px;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ai-search-composer-foot {
|
||||
justify-content: space-between;
|
||||
@@ -1,46 +0,0 @@
|
||||
.settings-debug-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-debug-card > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-debug-card > label > span {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.settings-debug-card b {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-debug-card small {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.settings-debug-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
}
|
||||
|
||||
.settings-debug-actions button {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
.settings-debug-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
|
||||
> label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
|
||||
> span {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
b {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-debug-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
|
||||
button {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
.settings-header-action,
|
||||
.settings-primary-button,
|
||||
.settings-danger-button,
|
||||
.settings-cache-item > button,
|
||||
.about-links-card button {
|
||||
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: 12px/18px var(--wxex-font);
|
||||
padding: 8px 12px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--wxex-brand);
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-primary-button {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--wxex-brand-hover);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-danger-button {
|
||||
color: var(--wxex-danger);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--wxex-danger);
|
||||
color: var(--wxex-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-card-kicker {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cache-overview-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-cache-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-cache-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 5px 0 4px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-inline-note,
|
||||
.settings-footnote {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.settings-inline-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-option-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.settings-choice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-choice {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-bg-main);
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
b {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-toggle-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.settings-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
b {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 auto;
|
||||
accent-color: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.about-identity-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--wxex-brand);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.update-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
|
||||
&.status-error {
|
||||
border-color: rgba(200, 90, 90, 0.42);
|
||||
}
|
||||
|
||||
&.status-downloaded {
|
||||
border-color: rgba(46, 139, 104, 0.42);
|
||||
}
|
||||
}
|
||||
|
||||
.update-card-copy {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.update-progress {
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-border);
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--wxex-brand);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.about-links-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
||||
a {
|
||||
color: var(--wxex-brand);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
&.is-compact {
|
||||
--wxex-nav-width: 68px;
|
||||
--wxex-shell-content-top: 8px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.boot-splash.is-quiet {
|
||||
.boot-splash-title,
|
||||
.boot-splash-subtitle,
|
||||
.boot-splash-detail,
|
||||
.boot-splash-progress {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settings-choice-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cache-overview-card,
|
||||
.settings-cache-item,
|
||||
.update-card {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
/* Sidebar 自助卡片 + 入口 */
|
||||
.sidebar-footer {
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
background-color: #f3f4f5;
|
||||
transition: background-color 0.15s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: #e6e9eb;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-self-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
background-color: #07c160;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-self-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-self-nickname {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1f2429;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-self-wxid {
|
||||
font-size: 11px;
|
||||
color: #8a9298;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-self-arrow {
|
||||
color: #8a9298;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 启动自动连接 Splash */
|
||||
.boot-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
background: linear-gradient(180deg, #f5f7f8 0%, #eceff1 100%);
|
||||
z-index: 2000;
|
||||
animation: boot-splash-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes boot-splash-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.boot-splash-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(7, 193, 96, 0.18);
|
||||
border-top-color: #07c160;
|
||||
animation: boot-splash-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes boot-splash-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.boot-splash-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #1f2429;
|
||||
}
|
||||
|
||||
.boot-splash-subtitle {
|
||||
font-size: 12px;
|
||||
color: #6f767c;
|
||||
}
|
||||
|
||||
.boot-splash-detail {
|
||||
min-height: 18px;
|
||||
font-size: 12px;
|
||||
color: #8a9298;
|
||||
}
|
||||
|
||||
.boot-splash-progress {
|
||||
width: min(320px, 72vw);
|
||||
height: 6px;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(7, 193, 96, 0.12);
|
||||
}
|
||||
|
||||
.boot-splash-progress-bar {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #07c160;
|
||||
transition: width 0.18s ease-out;
|
||||
}
|
||||
|
||||
/* 首次连接成功后的下一步引导 */
|
||||
.first-use-welcome-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(16, 27, 23, 0.42);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: settings-fade-in 0.16s ease-out;
|
||||
}
|
||||
|
||||
.first-use-welcome {
|
||||
position: relative;
|
||||
width: min(520px, 100%);
|
||||
padding: 34px;
|
||||
border: 1px solid rgba(203, 222, 214, 0.9);
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 70px rgba(16, 35, 28, 0.24);
|
||||
animation: settings-pop-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
.first-use-welcome-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 16px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #7c8882;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 23px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.first-use-welcome-close:hover {
|
||||
color: #26342d;
|
||||
background: #f0f4f1;
|
||||
}
|
||||
|
||||
.first-use-welcome-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 13px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #176b57, #42a27f);
|
||||
box-shadow: 0 7px 16px rgba(23, 107, 87, 0.22);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.first-use-welcome-eyebrow {
|
||||
margin: 0 0 5px;
|
||||
color: #247a63;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.first-use-welcome h2 {
|
||||
margin: 0;
|
||||
color: #1f2c26;
|
||||
font-size: 25px;
|
||||
line-height: 34px;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.first-use-welcome-lead {
|
||||
margin: 8px 0 24px;
|
||||
color: #66756d;
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.first-use-welcome-actions {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.first-use-welcome-feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
padding: 17px 16px;
|
||||
border: 1px solid #247a63;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #247a63, #176b57);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #00604c;
|
||||
background: linear-gradient(135deg, #176b57, #00604c);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.first-use-welcome-feature-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: #176b57;
|
||||
background: #fff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.first-use-welcome-feature-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.first-use-welcome-feature-arrow {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.first-use-welcome-feature-copy strong,
|
||||
.first-use-welcome-feature-copy small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.first-use-welcome-feature-copy strong {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.first-use-welcome-feature-copy small {
|
||||
margin-top: 2px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.first-use-welcome-secondary-actions {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
margin: 16px 0 22px;
|
||||
}
|
||||
|
||||
.first-use-welcome-secondary-actions button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: #53645b;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.first-use-welcome-secondary-actions button:hover {
|
||||
color: #247a63;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.first-use-welcome-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 23px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #edf1ef;
|
||||
|
||||
span,
|
||||
button,
|
||||
a {
|
||||
color: #68766f;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
a:hover {
|
||||
color: #247a63;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px), (max-height: 560px) {
|
||||
.first-use-welcome-overlay {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.first-use-welcome {
|
||||
width: auto;
|
||||
margin: 0 auto;
|
||||
padding: 26px 22px 22px;
|
||||
}
|
||||
|
||||
.first-use-welcome h2 {
|
||||
font-size: 22px;
|
||||
line-height: 29px;
|
||||
}
|
||||
|
||||
.first-use-welcome-feature {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.first-use-welcome-feature-arrow {
|
||||
width: 100%;
|
||||
margin-left: 44px;
|
||||
}
|
||||
|
||||
.first-use-welcome-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 设置面板 */
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 21, 26, 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: settings-fade-in 0.16s ease-out;
|
||||
}
|
||||
|
||||
@keyframes settings-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-modal {
|
||||
width: min(560px, 92vw);
|
||||
max-height: 84vh;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 60px rgba(15, 21, 26, 0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: settings-pop-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes settings-pop-in {
|
||||
from {
|
||||
transform: translateY(8px) scale(0.98);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid #ececec;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #1f2429;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #7d858a;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f0f2f4;
|
||||
color: #1f2429;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
padding: 12px 22px 22px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
background: #fafbfc;
|
||||
border: 1px solid #ececec;
|
||||
}
|
||||
|
||||
.settings-section:first-child {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6f767c;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.settings-row:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid #d4d9dc;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #1f2429;
|
||||
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-input:focus {
|
||||
border-color: #07c160;
|
||||
box-shadow: 0 0 0 3px rgba(7, 193, 96, 0.12);
|
||||
}
|
||||
|
||||
.settings-input-half {
|
||||
flex: 0 1 140px;
|
||||
}
|
||||
|
||||
.settings-input-quarter {
|
||||
flex: 0 1 90px;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
padding: 7px 14px;
|
||||
border: 1px solid #d4d9dc;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #30383d;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #07c160;
|
||||
color: #078f49;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-btn-primary {
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
border-color: #07c160;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #06ad56;
|
||||
color: #fff;
|
||||
border-color: #06ad56;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
color: #8a9298;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.settings-hint code {
|
||||
background: #eef0f2;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.settings-status {
|
||||
font-size: 12px;
|
||||
color: #59636a;
|
||||
}
|
||||
|
||||
.settings-status.ok {
|
||||
color: #078f49;
|
||||
}
|
||||
|
||||
.settings-status.fail {
|
||||
color: #c73737;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #30383d;
|
||||
|
||||
input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #07c160;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-self {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-self-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 10px;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-self-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-self-nickname {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2429;
|
||||
}
|
||||
|
||||
.settings-self-wxid {
|
||||
font-size: 12px;
|
||||
color: #6f767c;
|
||||
margin-top: 2px;
|
||||
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.settings-self-account {
|
||||
font-size: 11px;
|
||||
color: #8a9298;
|
||||
margin-top: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.settings-self-empty {
|
||||
font-size: 13px;
|
||||
color: #8a9298;
|
||||
}
|
||||
|
||||
.settings-path {
|
||||
display: inline-block;
|
||||
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
background: #eef0f2;
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
color: #30383d;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
left: calc(var(--wxex-nav-width) + 50%);
|
||||
z-index: 40;
|
||||
transform: translateX(-50%);
|
||||
padding: 9px 14px;
|
||||
border: 1px solid rgba(198, 134, 53, 0.35);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: #fff9ef;
|
||||
color: #7b4b14;
|
||||
font: 13px/18px var(--wxex-font);
|
||||
box-shadow: var(--wxex-shadow-popover);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
@mixin dark-theme {
|
||||
--wxex-bg-app: #171b1a;
|
||||
--wxex-bg-main: #1e2422;
|
||||
--wxex-bg-sidebar: #202925;
|
||||
--wxex-bg-elevated: #27302d;
|
||||
--wxex-text-primary: #edf4f0;
|
||||
--wxex-text-secondary: #b2c0b9;
|
||||
--wxex-text-muted: #81918a;
|
||||
--wxex-border: #394640;
|
||||
--wxex-brand-soft: #26483c;
|
||||
|
||||
.conversation-section-header:hover,
|
||||
.conversation-item:hover,
|
||||
.report-source-item:hover,
|
||||
.report-history-item:hover {
|
||||
background: var(--wxex-brand-soft);
|
||||
}
|
||||
|
||||
.chat-window,
|
||||
.chat-archive-header,
|
||||
.chat-status-bar,
|
||||
.ai-report-workspace,
|
||||
.ai-report-footer,
|
||||
.report-viewer,
|
||||
.settings-workspace,
|
||||
.settings-page-header,
|
||||
.api-center-layout,
|
||||
.export-workspace,
|
||||
.ai-search-workspace {
|
||||
background: var(--wxex-bg-main);
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.data-trust-bar {
|
||||
background: var(--wxex-bg-sidebar);
|
||||
}
|
||||
|
||||
.wechat-message-list {
|
||||
background: #161c19;
|
||||
}
|
||||
|
||||
.message-bubble,
|
||||
.wechat-message-row.other .quoted-message,
|
||||
.message-loading-pill {
|
||||
border-color: var(--wxex-border);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.wechat-system-message {
|
||||
background: rgba(39, 48, 45, 0.9);
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
|
||||
.wechat-system-message-meta,
|
||||
.message-sender-name,
|
||||
.message-hover-time,
|
||||
.message-accessible-sender {
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .message-bubble {
|
||||
border-color: rgba(80, 190, 151, 0.3);
|
||||
background: #24513f;
|
||||
color: #f2faf6;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .quoted-message {
|
||||
background: rgba(12, 26, 21, 0.32);
|
||||
color: #d7e7df;
|
||||
}
|
||||
|
||||
.message-bubble a,
|
||||
.message-bubble code,
|
||||
.message-bubble pre {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.settings-sidebar,
|
||||
.settings-sidebar-account,
|
||||
.report-source-sidebar,
|
||||
.report-history-sidebar {
|
||||
background: var(--wxex-bg-sidebar);
|
||||
border-color: var(--wxex-border);
|
||||
}
|
||||
|
||||
.settings-sidebar header,
|
||||
.settings-page-header,
|
||||
.report-source-header,
|
||||
.report-history-header,
|
||||
.report-settings-panel header,
|
||||
.report-viewer-header {
|
||||
border-color: var(--wxex-border);
|
||||
}
|
||||
|
||||
.settings-sidebar header h1,
|
||||
.settings-sidebar-list button,
|
||||
.settings-sidebar-list button.active,
|
||||
.settings-page-header h1,
|
||||
.settings-section-heading,
|
||||
.settings-card,
|
||||
.settings-card strong,
|
||||
.settings-cache-item h3,
|
||||
.settings-toggle-row b,
|
||||
.settings-choice b,
|
||||
.settings-workspace h1,
|
||||
.settings-workspace h2,
|
||||
.settings-workspace h3,
|
||||
.settings-workspace h4,
|
||||
.settings-workspace strong,
|
||||
.settings-workspace b,
|
||||
.settings-workspace button,
|
||||
.settings-workspace label,
|
||||
.settings-workspace dt,
|
||||
.settings-workspace dd,
|
||||
.settings-workspace span,
|
||||
.settings-workspace p,
|
||||
.settings-workspace small,
|
||||
.settings-workspace code,
|
||||
.settings-workspace a {
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.settings-workspace p,
|
||||
.settings-workspace small,
|
||||
.settings-workspace span,
|
||||
.settings-workspace code,
|
||||
.settings-workspace .settings-inline-note,
|
||||
.settings-workspace .settings-footnote {
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
|
||||
.settings-workspace .settings-card,
|
||||
.settings-workspace .settings-choice,
|
||||
.settings-workspace .settings-search,
|
||||
.settings-workspace input,
|
||||
.settings-workspace textarea,
|
||||
.settings-workspace select,
|
||||
.settings-workspace .settings-sidebar-list button.active {
|
||||
border-color: var(--wxex-border);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.settings-workspace input::placeholder,
|
||||
.settings-workspace textarea::placeholder {
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
|
||||
.settings-workspace .settings-choice.active,
|
||||
.settings-workspace .settings-status-badge,
|
||||
.settings-workspace .settings-privacy-notice,
|
||||
.settings-workspace .settings-inline-note {
|
||||
background: var(--wxex-brand-soft);
|
||||
}
|
||||
|
||||
.settings-workspace .settings-privacy-notice,
|
||||
.settings-workspace .settings-privacy-notice strong,
|
||||
.settings-workspace .settings-privacy-notice p,
|
||||
.settings-workspace .settings-privacy-notice svg {
|
||||
color: #c5eadb;
|
||||
stroke: #72d0af;
|
||||
}
|
||||
|
||||
.settings-workspace .settings-connection-text.success,
|
||||
.settings-workspace [class*='success'],
|
||||
.settings-workspace [class*='success'] * {
|
||||
color: #72d0af !important;
|
||||
}
|
||||
|
||||
.settings-workspace [class*='error'],
|
||||
.settings-workspace [class*='error'] * {
|
||||
color: #ff9b96 !important;
|
||||
}
|
||||
|
||||
.report-settings-panel {
|
||||
background: var(--wxex-bg-sidebar);
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.report-settings-section,
|
||||
.report-settings-section h3,
|
||||
.report-settings-section p,
|
||||
.report-settings-section code,
|
||||
.report-export-list div,
|
||||
.report-generation-log li,
|
||||
.report-generation-log b,
|
||||
.report-generation-log small,
|
||||
.report-info-panel {
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.report-settings-section p,
|
||||
.report-settings-section code,
|
||||
.report-export-list div,
|
||||
.report-generation-log li,
|
||||
.report-generation-log small {
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
|
||||
.report-settings-section code,
|
||||
.report-timeout-section input,
|
||||
.report-check-row,
|
||||
.report-readonly-modules span,
|
||||
.report-result-preview {
|
||||
border-color: var(--wxex-border);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.report-result-preview {
|
||||
background: #161c19;
|
||||
}
|
||||
|
||||
.report-history-item,
|
||||
.report-source-item,
|
||||
.report-history-text b,
|
||||
.report-history-text small,
|
||||
.report-history-text em {
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.report-history-text small,
|
||||
.report-history-text em,
|
||||
.report-history-list-title,
|
||||
.report-history-group h3 {
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
|
||||
.search-workspace,
|
||||
.ai-search-scope-panel,
|
||||
.ai-search-main,
|
||||
.ai-search-evidence-panel,
|
||||
.export-config-panel,
|
||||
.export-preview-panel,
|
||||
.api-main,
|
||||
.api-runtime-panel {
|
||||
background: var(--wxex-bg-main);
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.ai-search-scope-panel,
|
||||
.ai-search-evidence-panel,
|
||||
.export-config-panel,
|
||||
.export-preview-panel,
|
||||
.api-runtime-panel {
|
||||
border-color: var(--wxex-border);
|
||||
}
|
||||
}
|
||||
|
||||
.app-shell.theme-dark {
|
||||
@include dark-theme;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.app-shell.theme-system {
|
||||
@include dark-theme;
|
||||
}
|
||||
}
|
||||