mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 15:45:51 +00:00
Compare commits
375 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db4fab9130 | ||
|
|
9aee578707 | ||
|
|
6d74eb65ae | ||
|
|
587ee630d7 | ||
|
|
6952a5f680 | ||
|
|
b263ecd45c | ||
|
|
74fc0e4e88 | ||
|
|
a873366342 | ||
|
|
c4dc266f93 | ||
|
|
96ff783bbd | ||
|
|
804a65f52b | ||
|
|
e88c859f4f | ||
|
|
c1a393eaf6 | ||
|
|
15e08dc529 | ||
|
|
e55bcaf7eb | ||
|
|
4e64c6ad6e | ||
|
|
5a15e1a1d6 | ||
|
|
ba07d47496 | ||
|
|
25325e80ee | ||
|
|
89783b4d45 | ||
|
|
d5f0094025 | ||
|
|
b4f37451be | ||
|
|
84ea378815 | ||
|
|
72d4db1f27 | ||
|
|
21ea879d97 | ||
|
|
a5baef2240 | ||
|
|
bbecf54aba | ||
|
|
5f868d193c | ||
|
|
62b035ab39 | ||
|
|
ff5ee33e08 | ||
|
|
8e28016e5e | ||
|
|
f17a18cb6d | ||
|
|
999f45e5f5 | ||
|
|
3e303fadd7 | ||
|
|
3b7590d8ce | ||
|
|
fabbada580 | ||
|
|
6e434d37dc | ||
|
|
904da80f81 | ||
|
|
2a4bd52f0a | ||
|
|
b4248d4a12 | ||
|
|
75b056d5ba | ||
|
|
e87e12c939 | ||
|
|
5cb7e3bc73 | ||
|
|
1930b91a5b | ||
|
|
ea0dad132c | ||
|
|
5b7b94f507 | ||
|
|
28e38f73f8 | ||
|
|
d43c0ef209 | ||
|
|
6394384be0 | ||
|
|
4f0af3d0cb | ||
|
|
2a6f833718 | ||
|
|
c8835f4d4c | ||
|
|
fff1a1c177 | ||
|
|
8fee96d0e1 | ||
|
|
fdb3d63006 | ||
|
|
071d239892 | ||
|
|
94eb9abe9d | ||
|
|
1031c4013e | ||
|
|
2b5bb34392 | ||
|
|
e28ef9b783 | ||
|
|
e3c17010c1 | ||
|
|
2389aaf314 | ||
|
|
4f1dd7a5fb | ||
|
|
4b203a93b6 | ||
|
|
f219b1a580 | ||
|
|
004ee5bbf0 | ||
|
|
5640db9cbd | ||
|
|
52b26533a2 | ||
|
|
d334a214a4 | ||
|
|
1aab8dfc4e | ||
|
|
e56ee1ff4a | ||
|
|
0393e7aff7 | ||
|
|
c988e4accf | ||
|
|
63ac715792 | ||
|
|
fe0e2e6592 | ||
|
|
ca1a386146 | ||
|
|
7c9d0a39c3 | ||
|
|
a5777027b1 | ||
|
|
c3e911e6fa | ||
|
|
4d03110df2 | ||
|
|
8cb640f565 | ||
|
|
494bd4f539 | ||
|
|
38169691cd | ||
|
|
bd995bc736 | ||
|
|
6e05e74d5e | ||
|
|
d3a1db4efe | ||
|
|
a19f2a57c3 | ||
|
|
666a53f6ba | ||
|
|
b156a08f0d | ||
|
|
9c76aa2189 | ||
|
|
a54c95b6ac | ||
|
|
9cb0ada1b7 | ||
|
|
54378a132f | ||
|
|
4d1632a9b9 | ||
|
|
1eab835458 | ||
|
|
fcbc7fead8 | ||
|
|
ec783e4ccc | ||
|
|
b6f97b102c | ||
|
|
e4ce9a3bd7 | ||
|
|
64d5e721af | ||
|
|
d7419669d6 | ||
|
|
ff2f6799c8 | ||
|
|
2d573896f9 | ||
|
|
ab15190c44 | ||
|
|
551995df68 | ||
|
|
8483babd10 | ||
|
|
79648cd9d5 | ||
|
|
04d690dcf1 | ||
|
|
0b308803bf | ||
|
|
419d5aace3 | ||
|
|
84005f2d43 | ||
|
|
a166079084 | ||
|
|
a70d8fe6c8 | ||
|
|
34cd337146 | ||
|
|
c9216aabad | ||
|
|
79d6aef480 | ||
|
|
8134d62056 | ||
|
|
8664ebf6f5 | ||
|
|
7b832ac2ef | ||
|
|
5934fc33ce | ||
|
|
b6d10f79de | ||
|
|
f90822694f | ||
|
|
123a088a39 | ||
|
|
9283594dd0 | ||
|
|
638246e74d | ||
|
|
f506407f67 | ||
|
|
216f201327 | ||
|
|
a557f2ada3 | ||
|
|
e15e4cc3c8 | ||
|
|
2555c46b6d | ||
|
|
fdfd59fbdf | ||
|
|
0e1c3f9364 | ||
|
|
f9bb18d97f | ||
|
|
b7339b6a35 | ||
|
|
26abc30695 | ||
|
|
1f0f824b01 | ||
|
|
cb37f534ac | ||
|
|
50903b35cf | ||
|
|
c07ef66324 | ||
|
|
6bc802e77b | ||
|
|
898c86c23f | ||
|
|
7612353389 | ||
|
|
8b37f20b0f | ||
|
|
0054509ef2 | ||
|
|
e0f22f58c8 | ||
|
|
6f41cb34ed | ||
|
|
ddbb0c3b26 | ||
|
|
f40f885af3 | ||
|
|
5413d7e2c8 | ||
|
|
53f0e299e0 | ||
|
|
65365107f5 | ||
|
|
cffeeb26ec | ||
|
|
26d4751e80 | ||
|
|
b8120a5119 | ||
|
|
68a13cefc3 | ||
|
|
cd4b8f3702 | ||
|
|
c5956ba203 | ||
|
|
f456357e01 | ||
|
|
4ef821f45f | ||
|
|
912c78e9e9 | ||
|
|
bfcd154a25 | ||
|
|
a1c8ba48b0 | ||
|
|
f93369489d | ||
|
|
014f57f152 | ||
|
|
3f1eb58af4 | ||
|
|
97f0077e95 | ||
|
|
3d9b1b0f8c | ||
|
|
cf292ca9e2 | ||
|
|
97f14030de | ||
|
|
2cfe0d8ee8 | ||
|
|
a760f45823 | ||
|
|
baa949a301 | ||
|
|
c29bbab25f | ||
|
|
29981e1232 | ||
|
|
2d043cd929 | ||
|
|
d6dca0e5f7 | ||
|
|
d47166e6f9 | ||
|
|
6e3bb9e361 | ||
|
|
b8dbc3caf1 | ||
|
|
c1145c8f89 | ||
|
|
0cba8e6d89 | ||
|
|
f6f468dff3 | ||
|
|
04fc5f9104 | ||
|
|
3c9ab6763c | ||
|
|
f360333ab4 | ||
|
|
834aa6eecb | ||
|
|
2400cc8b55 | ||
|
|
e4ed7faca9 | ||
|
|
8012aa49ee | ||
|
|
7225358b91 | ||
|
|
39688e8e0c | ||
|
|
592ca6128f | ||
|
|
7cd27d8905 | ||
|
|
bca387c54b | ||
|
|
e7e4ffd53f | ||
|
|
04e0bf6b29 | ||
|
|
dadd9d799c | ||
|
|
b3aaea16f2 | ||
|
|
f3994a1a72 | ||
|
|
26fbfd2c98 | ||
|
|
3c51dee9a6 | ||
|
|
b9fa0cc215 | ||
|
|
21f748a2dc | ||
|
|
87fe130791 | ||
|
|
ff1bc279f2 | ||
|
|
77689ec528 | ||
|
|
5ea0b65905 | ||
|
|
eac6b053ee | ||
|
|
d52abfddbf | ||
|
|
8f2e403837 | ||
|
|
17c9436c30 | ||
|
|
9969c073e5 | ||
|
|
dc83297854 | ||
|
|
b6c9f2b32b | ||
|
|
e63f901478 | ||
|
|
893cdb4d92 | ||
|
|
d99ec05e81 | ||
|
|
c8f726eddc | ||
|
|
4e57a30c90 | ||
|
|
0a88275669 | ||
|
|
2a45cf1276 | ||
|
|
d63f1e0d79 | ||
|
|
f55507cd99 | ||
|
|
836b0f9df4 | ||
|
|
b09068f1f7 | ||
|
|
714a9400d5 | ||
|
|
13dd2fca21 | ||
|
|
5d1f834b61 | ||
|
|
3ca86224eb | ||
|
|
f10e974f36 | ||
|
|
76c40e4118 | ||
|
|
5307f55840 | ||
|
|
3405f26d10 | ||
|
|
85d82bfd09 | ||
|
|
e557ee224e | ||
|
|
88544c4a5d | ||
|
|
b66fc32068 | ||
|
|
7ac3c281a3 | ||
|
|
28616493ce | ||
|
|
d68e4fe880 | ||
|
|
1fd676d63e | ||
|
|
9f31ac0529 | ||
|
|
3c32ad5ca8 | ||
|
|
879d84b597 | ||
|
|
ab3551fb91 | ||
|
|
b9d1ea316f | ||
|
|
7762bd37c9 | ||
|
|
2e61902556 | ||
|
|
9e8072c337 | ||
|
|
827e77c9a3 | ||
|
|
3956989b67 | ||
|
|
33d7c243a7 | ||
|
|
a215886015 | ||
|
|
1d9e8aded0 | ||
|
|
b7e31c9cff | ||
|
|
4e9c81a93d | ||
|
|
9181ac5d34 | ||
|
|
3a10aeb23e | ||
|
|
178f9c4fdc | ||
|
|
4d647a9467 | ||
|
|
16cbc6adb1 | ||
|
|
7afb872bff | ||
|
|
7df6182e70 | ||
|
|
40efb04a36 | ||
|
|
1f03d35253 | ||
|
|
3efaed488a | ||
|
|
decdbf95f7 | ||
|
|
cccc712814 | ||
|
|
135f4819fb | ||
|
|
388923257b | ||
|
|
6918e359e8 | ||
|
|
d5b33c7e77 | ||
|
|
d37f53e120 | ||
|
|
26478217e7 | ||
|
|
a100f4ef97 | ||
|
|
91b746dc59 | ||
|
|
1817a847de | ||
|
|
7e99feae1e | ||
|
|
2977c45365 | ||
|
|
3b363a3efa | ||
|
|
e2b0bd44d9 | ||
|
|
cc26860504 | ||
|
|
54f3e0481f | ||
|
|
a61371c8ad | ||
|
|
fd6d5e4296 | ||
|
|
514a617c55 | ||
|
|
b47007ea0c | ||
|
|
6436c39c90 | ||
|
|
eb2f90e605 | ||
|
|
bdbb85175a | ||
|
|
a5e1bfe49a | ||
|
|
b3adb54651 | ||
|
|
07e7bce6a9 | ||
|
|
baa90242a6 | ||
|
|
787db0cec2 | ||
|
|
6359118132 | ||
|
|
49614bf6d8 | ||
|
|
0901e08c5c | ||
|
|
503a77c7cf | ||
|
|
0e3ab8e4d6 | ||
|
|
4452e4921c | ||
|
|
97c1aa582d | ||
|
|
076c008329 | ||
|
|
21d785dd3c | ||
|
|
348f6c81bf | ||
|
|
d5a2e2bb62 | ||
|
|
2b51e0659e | ||
|
|
3efca5e60c | ||
|
|
2f7b917f1c | ||
|
|
8623f86505 | ||
|
|
dc74641c19 | ||
|
|
db7817cc22 | ||
|
|
ada0f68182 | ||
|
|
fe806895f0 | ||
|
|
da137d0a8f | ||
|
|
93ebc3bce3 | ||
|
|
9f6e9eb9bc | ||
|
|
996b133a4f | ||
|
|
dd2602ea35 | ||
|
|
e5cf71b7c5 | ||
|
|
f2e4e21010 | ||
|
|
240514f1e5 | ||
|
|
d4c7e86e05 | ||
|
|
2876c7a539 | ||
|
|
32cdbece2c | ||
|
|
6e7e994cc6 | ||
|
|
d95040ffaf | ||
|
|
129dfbe1b6 | ||
|
|
f8afce6bfa | ||
|
|
0423f23b9c | ||
|
|
e3655631bb | ||
|
|
945802f772 | ||
|
|
be4d9b510d | ||
|
|
0853e049c8 | ||
|
|
dc12df0fcf | ||
|
|
82ba0344b9 | ||
|
|
e8babd48b6 | ||
|
|
7c0ed66dad | ||
|
|
9402483d87 | ||
|
|
650de55202 | ||
|
|
af99ab2029 | ||
|
|
87a2675236 | ||
|
|
25f1256baa | ||
|
|
f83a37e714 | ||
|
|
1aecfb369b | ||
|
|
436b090e26 | ||
|
|
c0f2620542 | ||
|
|
72e2d82158 | ||
|
|
095c8f0db6 | ||
|
|
afa3e089b1 | ||
|
|
11969ea2d4 | ||
|
|
6707be2200 | ||
|
|
f97e102dbd | ||
|
|
3637864f9a | ||
|
|
6eabd707f8 | ||
|
|
b96a47fe29 | ||
|
|
b7eb19aad6 | ||
|
|
2e41a03c96 | ||
|
|
3151f79ee7 | ||
|
|
e7c93ea2f7 | ||
|
|
f09ab1bbcc | ||
|
|
e6a0726b8d | ||
|
|
cada002587 | ||
|
|
38e87b8cbf | ||
|
|
bd94ba7b1a | ||
|
|
756ee03aa0 | ||
|
|
76aa875085 | ||
|
|
16fa8510e6 | ||
|
|
b587e6bd6f | ||
|
|
13cc3751b5 | ||
|
|
ba65c5f3ad | ||
|
|
cfd7635323 | ||
|
|
895249940c | ||
|
|
6b85d8a5f1 | ||
|
|
5c1773efac |
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -21,48 +21,41 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22.12
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v4
|
||||
with:
|
||||
outputFile: "release-notes.md"
|
||||
configurationJson: |
|
||||
{
|
||||
"template": "# v${{ github.ref_name }} 版本发布\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
|
||||
"categories": [
|
||||
{
|
||||
"title": "## 新功能",
|
||||
"filter": { "pattern": "^feat:.*", "flags": "i" }
|
||||
},
|
||||
{
|
||||
"title": "## 修复",
|
||||
"filter": { "pattern": "^fix:.*", "flags": "i" }
|
||||
},
|
||||
{
|
||||
"title": "## 性能与维护",
|
||||
"filter": { "pattern": "^(chore|docs|perf|refactor):.*", "flags": "i" }
|
||||
}
|
||||
],
|
||||
"ignore_labels": [],
|
||||
"commitMode": true,
|
||||
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
|
||||
}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Package and Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md"
|
||||
npx electron-builder --publish always
|
||||
|
||||
- name: Update Release Notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<EOF > release_notes.md
|
||||
## 更新日志
|
||||
修复了一些已知问题
|
||||
|
||||
## 查看更多日志/获取最新动态
|
||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||
EOF
|
||||
|
||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -13,6 +13,7 @@ dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
test/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -42,6 +43,10 @@ release
|
||||
# OS
|
||||
Thumbs.db
|
||||
|
||||
# Electron dev cache
|
||||
.electron/
|
||||
.cache/
|
||||
|
||||
|
||||
|
||||
# 忽略 Visual Studio 临时文件夹
|
||||
@@ -51,3 +56,9 @@ Thumbs.db
|
||||
*.aps
|
||||
|
||||
wcdb/
|
||||
*info
|
||||
概述.md
|
||||
chatlab-format.md
|
||||
*.bak
|
||||
AGENTS.md
|
||||
.claude/
|
||||
66
README.md
66
README.md
@@ -20,21 +20,42 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/hicccc77/WeFlow?style=flat-square" alt="License">
|
||||
<a href="https://t.me/weflow_cc">
|
||||
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||
|
||||
> [!NOTE]
|
||||
> 仅支持微信 **4.0 及以上**版本,确保你的微信版本符合要求
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 本地实时查看聊天记录
|
||||
- 朋友圈图片、视频、**实况**的预览和解密
|
||||
- 统计分析与群聊画像
|
||||
- 年度报告与可视化概览
|
||||
- 导出聊天记录为 HTML 等格式
|
||||
- 本地解密与数据库管理
|
||||
- HTTP API 接口(供开发者集成)
|
||||
|
||||
|
||||
## HTTP API
|
||||
|
||||
> [!WARNING]
|
||||
> 此功能目前处于早期阶段,接口可能会有变动,请等待后续更新完善。
|
||||
|
||||
WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可用于与其他工具集成或二次开发。
|
||||
|
||||
- **启用方式**:设置 → API 服务 → 启动服务
|
||||
- **默认端口**:5031
|
||||
- **访问地址**:`http://127.0.0.1:5031`
|
||||
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
||||
|
||||
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -61,38 +82,19 @@ npm run build
|
||||
|
||||
打包产物在 `release` 目录下。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端**: React 19 + TypeScript + Zustand
|
||||
- **桌面**: Electron 39
|
||||
- **构建**: Vite + electron-builder
|
||||
- **数据库**: better-sqlite3 + WCDB DLL
|
||||
- **样式**: SCSS + CSS Variables
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
WeFlow/
|
||||
├── electron/ # Electron 主进程
|
||||
│ ├── main.ts # 主进程入口
|
||||
│ ├── preload.ts # 预加载脚本
|
||||
│ └── services/ # 后端服务
|
||||
│ ├── chatService.ts # 聊天数据服务
|
||||
│ ├── wcdbService.ts # 数据库服务
|
||||
│ └── ...
|
||||
├── src/ # React 前端
|
||||
│ ├── components/ # 通用组件
|
||||
│ ├── pages/ # 页面组件
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ ├── services/ # 前端服务
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
├── public/ # 静态资源
|
||||
└── resources/ # 打包资源
|
||||
```
|
||||
|
||||
## 致谢
|
||||
|
||||
- [miyu](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) 提供了视频解密相关的技术参考
|
||||
|
||||
## 支持我们
|
||||
|
||||
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
||||
|
||||
|
||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||
|
||||
|
||||
## Star History
|
||||
@@ -111,6 +113,4 @@ WeFlow/
|
||||
|
||||
**请负责任地使用本工具,遵守相关法律法规**
|
||||
|
||||
我们总是在向前走,却很少有机会回头看看
|
||||
|
||||
</div>
|
||||
|
||||
338
docs/HTTP-API.md
Normal file
338
docs/HTTP-API.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# WeFlow HTTP API 接口文档
|
||||
|
||||
WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。
|
||||
|
||||
## 启用 API 服务
|
||||
|
||||
在设置页面 → API 服务 → 点击「启动服务」按钮。
|
||||
|
||||
默认端口:`5031`
|
||||
|
||||
## 基础地址
|
||||
|
||||
```
|
||||
http://127.0.0.1:5031
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 健康检查
|
||||
|
||||
检查 API 服务是否正常运行。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取消息列表
|
||||
|
||||
获取指定会话的消息,支持 ChatLab 格式输出。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/messages
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` |
|
||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
||||
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
|
||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
||||
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 |
|
||||
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) |
|
||||
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) |
|
||||
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
|
||||
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
|
||||
|
||||
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
|
||||
|
||||
**示例请求**
|
||||
|
||||
```bash
|
||||
# 获取消息(原始格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
|
||||
|
||||
# 获取消息(ChatLab 格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
|
||||
|
||||
# 带时间范围查询
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
||||
|
||||
# 开启媒体导出(只导出图片和语音)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0
|
||||
|
||||
# 关键词过滤
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
|
||||
```
|
||||
|
||||
**响应(原始格式)**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"talker": "wxid_xxx",
|
||||
"count": 50,
|
||||
"hasMore": true,
|
||||
"media": {
|
||||
"enabled": true,
|
||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||
"count": 12
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"localId": 123,
|
||||
"localType": 3,
|
||||
"content": "[图片]",
|
||||
"createTime": 1738713600000,
|
||||
"senderUsername": "wxid_sender",
|
||||
"mediaType": "image",
|
||||
"mediaFileName": "image_123.jpg",
|
||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**响应(ChatLab 格式)**
|
||||
```json
|
||||
{
|
||||
"chatlab": {
|
||||
"version": "0.0.2",
|
||||
"exportedAt": 1738713600000,
|
||||
"generator": "WeFlow",
|
||||
"description": "Exported from WeFlow"
|
||||
},
|
||||
"meta": {
|
||||
"name": "会话名称",
|
||||
"platform": "wechat",
|
||||
"type": "private",
|
||||
"ownerId": "wxid_me"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
"platformId": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"groupNickname": "群昵称"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"sender": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"timestamp": 1738713600000,
|
||||
"type": 0,
|
||||
"content": "消息内容",
|
||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||
}
|
||||
],
|
||||
"media": {
|
||||
"enabled": true,
|
||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||
"count": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取会话列表
|
||||
|
||||
获取所有会话列表。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/sessions
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
GET http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"total": 100,
|
||||
"sessions": [
|
||||
{
|
||||
"username": "wxid_xxx",
|
||||
"displayName": "用户名",
|
||||
"lastMessage": "最后一条消息",
|
||||
"lastTime": 1738713600000,
|
||||
"unreadCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 获取联系人列表
|
||||
|
||||
获取所有联系人信息。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/contacts
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `keyword` | string | ❌ | 搜索关键词 |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
GET http://127.0.0.1:5031/api/v1/contacts
|
||||
|
||||
GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"contacts": [
|
||||
{
|
||||
"userName": "wxid_xxx",
|
||||
"alias": "微信号",
|
||||
"nickName": "昵称",
|
||||
"remark": "备注名"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ChatLab 格式说明
|
||||
|
||||
ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。
|
||||
|
||||
### 消息类型映射
|
||||
|
||||
| ChatLab Type | 值 | 说明 |
|
||||
|--------------|-----|------|
|
||||
| TEXT | 0 | 文本消息 |
|
||||
| IMAGE | 1 | 图片 |
|
||||
| VOICE | 2 | 语音 |
|
||||
| VIDEO | 3 | 视频 |
|
||||
| FILE | 4 | 文件 |
|
||||
| EMOJI | 5 | 表情 |
|
||||
| LINK | 7 | 链接 |
|
||||
| LOCATION | 8 | 位置 |
|
||||
| RED_PACKET | 20 | 红包 |
|
||||
| TRANSFER | 21 | 转账 |
|
||||
| CALL | 23 | 通话 |
|
||||
| SYSTEM | 80 | 系统消息 |
|
||||
| RECALL | 81 | 撤回消息 |
|
||||
| OTHER | 99 | 其他 |
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
# 健康检查
|
||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
||||
|
||||
# 获取会话列表
|
||||
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
# 获取消息
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
||||
|
||||
# 获取 ChatLab 格式
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:5031/health
|
||||
|
||||
# 获取会话列表
|
||||
curl http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
# 获取消息(ChatLab 格式)
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5031"
|
||||
|
||||
# 获取会话列表
|
||||
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
|
||||
print(sessions)
|
||||
|
||||
# 获取消息
|
||||
messages = requests.get(f"{BASE_URL}/api/v1/messages", params={
|
||||
"talker": "wxid_xxx",
|
||||
"limit": 100,
|
||||
"chatlab": 1
|
||||
}).json()
|
||||
print(messages)
|
||||
```
|
||||
|
||||
### JavaScript / Node.js
|
||||
|
||||
```javascript
|
||||
const BASE_URL = "http://127.0.0.1:5031";
|
||||
|
||||
// 获取会话列表
|
||||
const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json());
|
||||
console.log(sessions);
|
||||
|
||||
// 获取消息(ChatLab 格式)
|
||||
const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`)
|
||||
.then(r => r.json());
|
||||
console.log(messages);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
|
||||
2. 需要先连接数据库才能查询数据
|
||||
3. 时间参数格式为 `YYYYMMDD`(如 20260205)
|
||||
4. 支持 CORS,可从浏览器前端直接调用
|
||||
4707
electron/assets/wasm/wasm_video_decode.js
Normal file
4707
electron/assets/wasm/wasm_video_decode.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
electron/assets/wasm/wasm_video_decode.wasm
Normal file
BIN
electron/assets/wasm/wasm_video_decode.wasm
Normal file
Binary file not shown.
47
electron/dualReportWorker.ts
Normal file
47
electron/dualReportWorker.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import { wcdbService } from './services/wcdbService'
|
||||
import { dualReportService } from './services/dualReportService'
|
||||
|
||||
interface WorkerConfig {
|
||||
year: number
|
||||
friendUsername: string
|
||||
dbPath: string
|
||||
decryptKey: string
|
||||
myWxid: string
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
excludeWords?: string[]
|
||||
}
|
||||
|
||||
const config = workerData as WorkerConfig
|
||||
process.env.WEFLOW_WORKER = '1'
|
||||
if (config.resourcesPath) {
|
||||
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||
}
|
||||
|
||||
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||
|
||||
async function run() {
|
||||
const result = await dualReportService.generateReportWithConfig({
|
||||
year: config.year,
|
||||
friendUsername: config.friendUsername,
|
||||
dbPath: config.dbPath,
|
||||
decryptKey: config.decryptKey,
|
||||
wxid: config.myWxid,
|
||||
excludeWords: config.excludeWords,
|
||||
onProgress: (status: string, progress: number) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'dualReport:progress',
|
||||
data: { status, progress }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
parentPort?.postMessage({ type: 'dualReport:result', data: result })
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
parentPort?.postMessage({ type: 'dualReport:error', error: String(err) })
|
||||
})
|
||||
@@ -62,6 +62,12 @@ function isThumbnailDat(fileName: string): boolean {
|
||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
||||
}
|
||||
|
||||
function isHdDat(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
return base.endsWith('_hd') || base.endsWith('_h')
|
||||
}
|
||||
|
||||
function walkForDat(
|
||||
root: string,
|
||||
datName: string,
|
||||
@@ -101,6 +107,8 @@ function walkForDat(
|
||||
if (!isLikelyImageDatBase(baseLower)) continue
|
||||
if (!hasXVariant(baseLower)) continue
|
||||
if (!matchesDatName(lower, datName)) continue
|
||||
// 排除高清图片格式 (_hd, _h)
|
||||
if (isHdDat(lower)) continue
|
||||
matchedBases.add(baseLower)
|
||||
const isThumb = isThumbnailDat(lower)
|
||||
if (!allowThumbnail && isThumb) continue
|
||||
|
||||
819
electron/main.ts
819
electron/main.ts
@@ -1,6 +1,7 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
|
||||
import './preload-env'
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { join } from 'path'
|
||||
import { join, dirname } from 'path'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
@@ -13,8 +14,17 @@ import { imagePreloadService } from './services/imagePreloadService'
|
||||
import { analyticsService } from './services/analyticsService'
|
||||
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||
import { annualReportService } from './services/annualReportService'
|
||||
import { exportService, ExportOptions } from './services/exportService'
|
||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||
import { KeyService } from './services/keyService'
|
||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
import { videoService } from './services/videoService'
|
||||
import { snsService, isVideoUrl } from './services/snsService'
|
||||
import { contactExportService } from './services/contactExportService'
|
||||
import { windowsHelloService } from './services/windowsHelloService'
|
||||
import { llamaService } from './services/llamaService'
|
||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { httpService } from './services/httpService'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
autoUpdater.autoDownload = false
|
||||
@@ -25,6 +35,47 @@ const AUTO_UPDATE_ENABLED =
|
||||
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
||||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
|
||||
|
||||
// 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。
|
||||
// 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。
|
||||
function sanitizePathEnv() {
|
||||
// 开发模式不做裁剪,避免影响本地工具链
|
||||
if (process.env.VITE_DEV_SERVER_URL) return
|
||||
|
||||
const rawPath = process.env.PATH || process.env.Path
|
||||
if (!rawPath) return
|
||||
|
||||
const sep = process.platform === 'win32' ? ';' : ':'
|
||||
const parts = rawPath.split(sep).filter(Boolean)
|
||||
|
||||
const systemRoot = process.env.SystemRoot || process.env.WINDIR || ''
|
||||
const safePrefixes = [
|
||||
systemRoot,
|
||||
systemRoot ? join(systemRoot, 'System32') : '',
|
||||
systemRoot ? join(systemRoot, 'SysWOW64') : '',
|
||||
dirname(process.execPath),
|
||||
process.resourcesPath,
|
||||
join(process.resourcesPath || '', 'resources')
|
||||
].filter(Boolean)
|
||||
|
||||
const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase()
|
||||
const isSafe = (p: string) => {
|
||||
const np = normalize(p)
|
||||
return safePrefixes.some((prefix) => np.startsWith(normalize(prefix)))
|
||||
}
|
||||
|
||||
const filtered = parts.filter(isSafe)
|
||||
if (filtered.length !== parts.length) {
|
||||
const removed = parts.filter((p) => !isSafe(p))
|
||||
console.warn('[WeFlow] 使用白名单裁剪 PATH,移除目录:', removed)
|
||||
const nextPath = filtered.join(sep)
|
||||
process.env.PATH = nextPath
|
||||
process.env.Path = nextPath
|
||||
}
|
||||
}
|
||||
|
||||
// 启动时立即清理 PATH,后续创建的 worker 也能继承安全的环境
|
||||
sanitizePathEnv()
|
||||
|
||||
// 单例服务
|
||||
let configService: ConfigService | null = null
|
||||
|
||||
@@ -53,7 +104,8 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // Allow loading local files (video playback)
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
@@ -91,6 +143,36 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
win.loadFile(join(__dirname, '../dist/index.html'))
|
||||
}
|
||||
|
||||
// Handle notification click navigation
|
||||
ipcMain.on('notification-clicked', (_, sessionId) => {
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.show()
|
||||
win.focus()
|
||||
win.webContents.send('navigate-to-session', sessionId)
|
||||
})
|
||||
|
||||
// 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||
{
|
||||
urls: [
|
||||
'*://*.qpic.cn/*',
|
||||
'*://*.qlogo.cn/*',
|
||||
'*://*.wechat.com/*',
|
||||
'*://*.weixin.qq.com/*'
|
||||
]
|
||||
},
|
||||
(details, callback) => {
|
||||
details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351"
|
||||
details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
|
||||
details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br"
|
||||
details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9"
|
||||
details.requestHeaders['Referer'] = "https://servicewechat.com/"
|
||||
details.requestHeaders['Connection'] = "keep-alive"
|
||||
details.requestHeaders['Range'] = "bytes=0-"
|
||||
callback({ cancel: false, requestHeaders: details.requestHeaders })
|
||||
}
|
||||
)
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
@@ -164,10 +246,11 @@ function createOnboardingWindow() {
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
|
||||
onboardingWindow = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 720,
|
||||
width: 960,
|
||||
height: 680,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
minHeight: 620,
|
||||
resizable: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
backgroundColor: '#00000000',
|
||||
@@ -198,6 +281,225 @@ function createOnboardingWindow() {
|
||||
return onboardingWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建独立的视频播放窗口
|
||||
* 窗口大小会根据视频比例自动调整
|
||||
*/
|
||||
function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHeight?: number) {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
|
||||
// 获取屏幕尺寸
|
||||
const { screen } = require('electron')
|
||||
const primaryDisplay = screen.getPrimaryDisplay()
|
||||
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
|
||||
|
||||
// 计算窗口尺寸,只有标题栏 40px,控制栏悬浮
|
||||
let winWidth = 854
|
||||
let winHeight = 520
|
||||
const titleBarHeight = 40
|
||||
|
||||
if (videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0) {
|
||||
const aspectRatio = videoWidth / videoHeight
|
||||
|
||||
const maxWidth = Math.floor(screenWidth * 0.85)
|
||||
const maxHeight = Math.floor(screenHeight * 0.85)
|
||||
|
||||
if (aspectRatio >= 1) {
|
||||
// 横向视频
|
||||
winWidth = Math.min(videoWidth, maxWidth)
|
||||
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||
|
||||
if (winHeight > maxHeight) {
|
||||
winHeight = maxHeight
|
||||
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
|
||||
}
|
||||
} else {
|
||||
// 竖向视频
|
||||
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
|
||||
winHeight = videoDisplayHeight + titleBarHeight
|
||||
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
|
||||
|
||||
if (winWidth < 300) {
|
||||
winWidth = 300
|
||||
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||
}
|
||||
}
|
||||
|
||||
winWidth = Math.max(winWidth, 360)
|
||||
winHeight = Math.max(winHeight, 280)
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: winWidth,
|
||||
height: winHeight,
|
||||
minWidth: 360,
|
||||
minHeight: 280,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
color: '#1a1a1a',
|
||||
symbolColor: '#ffffff',
|
||||
height: 40
|
||||
},
|
||||
show: false,
|
||||
backgroundColor: '#000000',
|
||||
autoHideMenuBar: true
|
||||
})
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
const videoParam = `videoPath=${encodeURIComponent(videoPath)}`
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/video-player-window?${videoParam}`)
|
||||
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||
if (win.webContents.isDevToolsOpened()) {
|
||||
win.webContents.closeDevTools()
|
||||
} else {
|
||||
win.webContents.openDevTools()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/video-player-window?${videoParam}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建独立的图片查看窗口
|
||||
*/
|
||||
function createImageViewerWindow(imagePath: string) {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 700,
|
||||
minWidth: 400,
|
||||
minHeight: 300,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
color: '#00000000',
|
||||
symbolColor: '#ffffff',
|
||||
height: 40
|
||||
},
|
||||
show: false,
|
||||
backgroundColor: '#000000',
|
||||
autoHideMenuBar: true
|
||||
})
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
const imageParam = `imagePath=${encodeURIComponent(imagePath)}`
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
|
||||
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||
if (win.webContents.isDevToolsOpened()) {
|
||||
win.webContents.closeDevTools()
|
||||
} else {
|
||||
win.webContents.openDevTools()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/image-viewer-window?${imageParam}`
|
||||
})
|
||||
}
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建独立的聊天记录窗口
|
||||
*/
|
||||
function createChatHistoryWindow(sessionId: string, messageId: number) {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
|
||||
// 根据系统主题设置窗口背景色
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 800,
|
||||
minWidth: 400,
|
||||
minHeight: 500,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
color: '#00000000',
|
||||
symbolColor: isDark ? '#ffffff' : '#1a1a1a',
|
||||
height: 32
|
||||
},
|
||||
show: false,
|
||||
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
|
||||
autoHideMenuBar: true
|
||||
})
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-history/${sessionId}/${messageId}`)
|
||||
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||
if (win.webContents.isDevToolsOpened()) {
|
||||
win.webContents.closeDevTools()
|
||||
} else {
|
||||
win.webContents.openDevTools()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/chat-history/${sessionId}/${messageId}`
|
||||
})
|
||||
}
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
shouldShowMain = true
|
||||
if (mainWindowReady) {
|
||||
@@ -207,6 +509,7 @@ function showMainWindow() {
|
||||
|
||||
// 注册 IPC 处理器
|
||||
function registerIpcHandlers() {
|
||||
registerNotificationHandlers()
|
||||
// 配置相关
|
||||
ipcMain.handle('config:get', async (_, key: string) => {
|
||||
return configService?.get(key as any)
|
||||
@@ -304,7 +607,7 @@ function registerIpcHandlers() {
|
||||
|
||||
// 监听下载进度
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
win?.webContents.send('app:downloadProgress', progress.percent)
|
||||
win?.webContents.send('app:downloadProgress', progress)
|
||||
})
|
||||
|
||||
// 下载完成后自动安装
|
||||
@@ -320,6 +623,11 @@ function registerIpcHandlers() {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('app:ignoreUpdate', async (_, version: string) => {
|
||||
configService?.set('ignoredUpdateVersion', version)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
// 窗口控制
|
||||
ipcMain.on('window:minimize', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||
@@ -354,6 +662,85 @@ function registerIpcHandlers() {
|
||||
}
|
||||
})
|
||||
|
||||
// 打开视频播放窗口
|
||||
ipcMain.handle('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => {
|
||||
createVideoPlayerWindow(videoPath, videoWidth, videoHeight)
|
||||
})
|
||||
|
||||
// 打开聊天记录窗口
|
||||
ipcMain.handle('window:openChatHistoryWindow', (_, sessionId: string, messageId: number) => {
|
||||
createChatHistoryWindow(sessionId, messageId)
|
||||
return true
|
||||
})
|
||||
|
||||
// 根据视频尺寸调整窗口大小
|
||||
ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win || !videoWidth || !videoHeight) return
|
||||
|
||||
const { screen } = require('electron')
|
||||
const primaryDisplay = screen.getPrimaryDisplay()
|
||||
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
|
||||
|
||||
// 只有标题栏 40px,控制栏悬浮在视频上
|
||||
const titleBarHeight = 40
|
||||
const aspectRatio = videoWidth / videoHeight
|
||||
|
||||
const maxWidth = Math.floor(screenWidth * 0.85)
|
||||
const maxHeight = Math.floor(screenHeight * 0.85)
|
||||
|
||||
let winWidth: number
|
||||
let winHeight: number
|
||||
|
||||
if (aspectRatio >= 1) {
|
||||
// 横向视频 - 以宽度为基准
|
||||
winWidth = Math.min(videoWidth, maxWidth)
|
||||
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||
|
||||
if (winHeight > maxHeight) {
|
||||
winHeight = maxHeight
|
||||
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
|
||||
}
|
||||
} else {
|
||||
// 竖向视频 - 以高度为基准
|
||||
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
|
||||
winHeight = videoDisplayHeight + titleBarHeight
|
||||
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
|
||||
|
||||
// 确保宽度不会太窄
|
||||
if (winWidth < 300) {
|
||||
winWidth = 300
|
||||
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||
}
|
||||
}
|
||||
|
||||
winWidth = Math.max(winWidth, 360)
|
||||
winHeight = Math.max(winHeight, 280)
|
||||
|
||||
// 调整窗口大小并居中
|
||||
win.setSize(winWidth, winHeight)
|
||||
win.center()
|
||||
})
|
||||
|
||||
// 视频相关
|
||||
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => {
|
||||
try {
|
||||
const result = await videoService.getVideoInfo(videoMd5)
|
||||
return { success: true, ...result }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e), exists: false }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('video:parseVideoMd5', async (_, content: string) => {
|
||||
try {
|
||||
const md5 = videoService.parseVideoMd5(content)
|
||||
return { success: true, md5 }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
// 数据库路径相关
|
||||
ipcMain.handle('dbpath:autoDetect', async () => {
|
||||
return dbPathService.autoDetect()
|
||||
@@ -363,6 +750,10 @@ function registerIpcHandlers() {
|
||||
return dbPathService.scanWxids(rootPath)
|
||||
})
|
||||
|
||||
ipcMain.handle('dbpath:scanWxidCandidates', async (_, rootPath: string) => {
|
||||
return dbPathService.scanWxidCandidates(rootPath)
|
||||
})
|
||||
|
||||
ipcMain.handle('dbpath:getDefault', async () => {
|
||||
return dbPathService.getDefaultPath()
|
||||
})
|
||||
@@ -381,6 +772,8 @@ function registerIpcHandlers() {
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 聊天相关
|
||||
ipcMain.handle('chat:connect', async () => {
|
||||
return chatService.connect()
|
||||
@@ -394,20 +787,102 @@ function registerIpcHandlers() {
|
||||
return chatService.enrichSessionsContactInfo(usernames)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
|
||||
return chatService.getMessages(sessionId, offset, limit)
|
||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
|
||||
return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
|
||||
return chatService.getLatestMessages(sessionId, limit)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getContact', async (_, username: string) => {
|
||||
return chatService.getContact(username)
|
||||
ipcMain.handle('chat:getNewMessages', async (_, sessionId: string, minTime: number, limit?: number) => {
|
||||
return chatService.getNewMessages(sessionId, minTime, limit)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:updateMessage', async (_, sessionId: string, localId: number, createTime: number, newContent: string) => {
|
||||
return chatService.updateMessage(sessionId, localId, createTime, newContent)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:deleteMessage', async (_, sessionId: string, localId: number, createTime: number, dbPathHint?: string) => {
|
||||
return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getContact', async (_, username: string) => {
|
||||
return await chatService.getContact(username)
|
||||
})
|
||||
|
||||
// Llama AI
|
||||
ipcMain.handle('llama:init', async () => {
|
||||
return await llamaService.init()
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:loadModel', async (_, modelPath: string) => {
|
||||
return llamaService.loadModel(modelPath)
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:createSession', async (_, systemPrompt?: string) => {
|
||||
return llamaService.createSession(systemPrompt)
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:chat', async (event, message: string, options?: { thinking?: boolean }) => {
|
||||
// We use a callback to stream back to the renderer
|
||||
const webContents = event.sender
|
||||
try {
|
||||
if (!webContents) return { success: false, error: 'No sender' }
|
||||
|
||||
const response = await llamaService.chat(message, options, (token) => {
|
||||
if (!webContents.isDestroyed()) {
|
||||
webContents.send('llama:token', token)
|
||||
}
|
||||
})
|
||||
return { success: true, response }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:downloadModel', async (event, url: string, savePath: string) => {
|
||||
const webContents = event.sender
|
||||
try {
|
||||
await llamaService.downloadModel(url, savePath, (payload) => {
|
||||
if (!webContents.isDestroyed()) {
|
||||
webContents.send('llama:downloadProgress', payload)
|
||||
}
|
||||
})
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:getModelsPath', async () => {
|
||||
return llamaService.getModelsPath()
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:checkFileExists', async (_, filePath: string) => {
|
||||
const { existsSync } = await import('fs')
|
||||
return existsSync(filePath)
|
||||
})
|
||||
|
||||
ipcMain.handle('llama:getModelStatus', async (_, modelPath: string) => {
|
||||
return llamaService.getModelStatus(modelPath)
|
||||
})
|
||||
|
||||
|
||||
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
|
||||
return chatService.getContactAvatar(username)
|
||||
return await chatService.getContactAvatar(username)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:resolveTransferDisplayNames', async (_, chatroomId: string, payerUsername: string, receiverUsername: string) => {
|
||||
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getContacts', async () => {
|
||||
return await chatService.getContacts()
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
|
||||
return chatService.getCachedSessionMessages(sessionId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMyAvatarUrl', async () => {
|
||||
@@ -431,14 +906,106 @@ function registerIpcHandlers() {
|
||||
return chatService.getImageData(sessionId, msgId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string) => {
|
||||
return chatService.getVoiceData(sessionId, msgId)
|
||||
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
|
||||
return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
|
||||
})
|
||||
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
|
||||
return chatService.getAllVoiceMessages(sessionId)
|
||||
})
|
||||
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
|
||||
return chatService.getMessageDates(sessionId)
|
||||
})
|
||||
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
|
||||
return chatService.resolveVoiceCache(sessionId, msgId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
|
||||
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
|
||||
return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
|
||||
event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMessage', async (_, sessionId: string, localId: number) => {
|
||||
return chatService.getMessageById(sessionId, localId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
|
||||
return chatService.execQuery(kind, path, sql)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
|
||||
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
||||
return snsService.debugResource(url)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:proxyImage', async (_, payload: string | { url: string; key?: string | number }) => {
|
||||
const url = typeof payload === 'string' ? payload : payload?.url
|
||||
const key = typeof payload === 'string' ? undefined : payload?.key
|
||||
return snsService.proxyImage(url, key)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:downloadImage', async (_, payload: { url: string; key?: string | number }) => {
|
||||
try {
|
||||
const { url, key } = payload
|
||||
const result = await snsService.downloadImage(url, key)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.error || '下载图片失败' }
|
||||
}
|
||||
|
||||
const { dialog } = await import('electron')
|
||||
const ext = (result.contentType || '').split('/')[1] || 'jpg'
|
||||
const defaultPath = `SNS_${Date.now()}.${ext}`
|
||||
|
||||
|
||||
const filters = isVideoUrl(url)
|
||||
? [{ name: 'Videos', extensions: ['mp4', 'mov', 'avi', 'mkv'] }]
|
||||
: [{ name: 'Images', extensions: [ext, 'jpg', 'jpeg', 'png', 'webp', 'gif'] }]
|
||||
|
||||
const { filePath, canceled } = await dialog.showSaveDialog({
|
||||
defaultPath,
|
||||
filters
|
||||
})
|
||||
|
||||
if (canceled || !filePath) {
|
||||
return { success: false, error: '用户已取消' }
|
||||
}
|
||||
|
||||
const fs = await import('fs/promises')
|
||||
await fs.writeFile(filePath, result.data)
|
||||
|
||||
return { success: true, filePath }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
|
||||
return snsService.exportTimeline(options, (progress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('sns:exportProgress', progress)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:selectExportDir', async () => {
|
||||
const { dialog } = await import('electron')
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
title: '选择导出目录'
|
||||
})
|
||||
if (result.canceled || !result.filePaths?.[0]) {
|
||||
return { canceled: true }
|
||||
}
|
||||
return { canceled: false, filePath: result.filePaths[0] }
|
||||
})
|
||||
|
||||
// 私聊克隆
|
||||
|
||||
|
||||
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
|
||||
return imageDecryptService.decryptImage(payload)
|
||||
})
|
||||
@@ -450,28 +1017,108 @@ function registerIpcHandlers() {
|
||||
return true
|
||||
})
|
||||
|
||||
// Windows Hello
|
||||
ipcMain.handle('auth:hello', async (event, message?: string) => {
|
||||
// 无论哪个窗口调用,都尝试强制附着到主窗口,确保体验一致
|
||||
// 如果主窗口不存在(极其罕见),则回退到调用者窗口
|
||||
const targetWin = (mainWindow && !mainWindow.isDestroyed())
|
||||
? mainWindow
|
||||
: (BrowserWindow.fromWebContents(event.sender) || undefined)
|
||||
|
||||
return windowsHelloService.verify(message, targetWin)
|
||||
})
|
||||
|
||||
// 导出相关
|
||||
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||
return exportService.exportSessions(sessionIds, outputDir, options)
|
||||
ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => {
|
||||
return exportService.getExportStats(sessionIds, options)
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||
const onProgress = (progress: ExportProgress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('export:progress', progress)
|
||||
}
|
||||
}
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
|
||||
})
|
||||
|
||||
// 数据分析相关
|
||||
ipcMain.handle('analytics:getOverallStatistics', async () => {
|
||||
return analyticsService.getOverallStatistics()
|
||||
ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
|
||||
return contactExportService.exportContacts(outputDir, options)
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => {
|
||||
return analyticsService.getContactRankings(limit)
|
||||
// 数据分析相关
|
||||
ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
|
||||
return analyticsService.getOverallStatistics(force)
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number, beginTimestamp?: number, endTimestamp?: number) => {
|
||||
return analyticsService.getContactRankings(limit, beginTimestamp, endTimestamp)
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:getTimeDistribution', async () => {
|
||||
return analyticsService.getTimeDistribution()
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:getExcludedUsernames', async () => {
|
||||
return analyticsService.getExcludedUsernames()
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:setExcludedUsernames', async (_, usernames: string[]) => {
|
||||
return analyticsService.setExcludedUsernames(usernames)
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:getExcludeCandidates', async () => {
|
||||
return analyticsService.getExcludeCandidates()
|
||||
})
|
||||
|
||||
// 缓存管理
|
||||
ipcMain.handle('cache:clearAnalytics', async () => {
|
||||
return analyticsService.clearCache()
|
||||
})
|
||||
|
||||
ipcMain.handle('cache:clearImages', async () => {
|
||||
const imageResult = await imageDecryptService.clearCache()
|
||||
const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
|
||||
const errors = [imageResult, emojiResult]
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => result.error)
|
||||
.filter(Boolean) as string[]
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: errors.join('; ') }
|
||||
}
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('cache:clearAll', async () => {
|
||||
const [analyticsResult, imageResult] = await Promise.all([
|
||||
analyticsService.clearCache(),
|
||||
imageDecryptService.clearCache()
|
||||
])
|
||||
const chatResult = chatService.clearCaches()
|
||||
const errors = [analyticsResult, imageResult, chatResult]
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => result.error)
|
||||
.filter(Boolean) as string[]
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: errors.join('; ') }
|
||||
}
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('whisper:downloadModel', async (event) => {
|
||||
return voiceTranscribeService.downloadModel((progress) => {
|
||||
event.sender.send('whisper:downloadProgress', progress)
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('whisper:getModelStatus', async () => {
|
||||
return voiceTranscribeService.getModelStatus()
|
||||
})
|
||||
|
||||
// 群聊分析相关
|
||||
ipcMain.handle('groupAnalytics:getGroupChats', async () => {
|
||||
return groupAnalyticsService.getGroupChats()
|
||||
@@ -493,12 +1140,28 @@ function registerIpcHandlers() {
|
||||
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
||||
})
|
||||
|
||||
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
|
||||
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'groupAnalytics:exportGroupMemberMessages',
|
||||
async (_, chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => {
|
||||
return groupAnalyticsService.exportGroupMemberMessages(chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||
}
|
||||
)
|
||||
|
||||
// 打开协议窗口
|
||||
ipcMain.handle('window:openAgreementWindow', async () => {
|
||||
createAgreementWindow()
|
||||
return true
|
||||
})
|
||||
|
||||
// 打开图片查看窗口
|
||||
ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => {
|
||||
createImageViewerWindow(imagePath)
|
||||
})
|
||||
|
||||
// 完成引导,关闭引导窗口并显示主窗口
|
||||
ipcMain.handle('window:completeOnboarding', async () => {
|
||||
try {
|
||||
@@ -596,6 +1259,74 @@ function registerIpcHandlers() {
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('dualReport:generateReport', async (_, payload: { friendUsername: string; year: number }) => {
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
|
||||
const dbPath = cfg.get('dbPath')
|
||||
const decryptKey = cfg.get('decryptKey')
|
||||
const wxid = cfg.get('myWxid')
|
||||
const logEnabled = cfg.get('logEnabled')
|
||||
const friendUsername = payload?.friendUsername
|
||||
const year = payload?.year ?? 0
|
||||
const excludeWords = cfg.get('wordCloudExcludeWords') || []
|
||||
|
||||
if (!friendUsername) {
|
||||
return { success: false, error: '缺少好友用户名' }
|
||||
}
|
||||
|
||||
const resourcesPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
const userDataPath = app.getPath('userData')
|
||||
|
||||
const workerPath = join(__dirname, 'dualReportWorker.js')
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: { year, friendUsername, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled, excludeWords }
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
worker.removeAllListeners()
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'dualReport:progress') {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('dualReport:progress', msg.data)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msg && (msg.type === 'dualReport:result' || msg.type === 'done')) {
|
||||
cleanup()
|
||||
void worker.terminate()
|
||||
resolve(msg.data ?? msg.result)
|
||||
return
|
||||
}
|
||||
if (msg && (msg.type === 'dualReport:error' || msg.type === 'error')) {
|
||||
cleanup()
|
||||
void worker.terminate()
|
||||
resolve({ success: false, error: msg.error || '双人报告生成失败' })
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
cleanup()
|
||||
resolve({ success: false, error: String(err) })
|
||||
})
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
cleanup()
|
||||
resolve({ success: false, error: `双人报告线程异常退出: ${code}` })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => {
|
||||
try {
|
||||
const { baseDir, folderName, images } = payload
|
||||
@@ -641,6 +1372,24 @@ function registerIpcHandlers() {
|
||||
})
|
||||
})
|
||||
|
||||
// HTTP API 服务
|
||||
ipcMain.handle('http:start', async (_, port?: number) => {
|
||||
return httpService.start(port || 5031)
|
||||
})
|
||||
|
||||
ipcMain.handle('http:stop', async () => {
|
||||
await httpService.stop()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('http:status', async () => {
|
||||
return {
|
||||
running: httpService.isRunning(),
|
||||
port: httpService.getPort(),
|
||||
mediaExportPath: httpService.getDefaultMediaExportPath()
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// 主窗口引用
|
||||
@@ -659,7 +1408,16 @@ function checkForUpdatesOnStartup() {
|
||||
if (result && result.updateInfo) {
|
||||
const currentVersion = app.getVersion()
|
||||
const latestVersion = result.updateInfo.version
|
||||
|
||||
// 检查是否有新版本
|
||||
if (latestVersion !== currentVersion && mainWindow) {
|
||||
// 检查该版本是否被用户忽略
|
||||
const ignoredVersion = configService?.get('ignoredUpdateVersion')
|
||||
if (ignoredVersion === latestVersion) {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 通知渲染进程有新版本
|
||||
mainWindow.webContents.send('app:updateAvailable', {
|
||||
version: latestVersion,
|
||||
@@ -675,9 +1433,11 @@ function checkForUpdatesOnStartup() {
|
||||
|
||||
app.whenReady().then(() => {
|
||||
configService = new ConfigService()
|
||||
const resourcesPath = app.isPackaged
|
||||
const candidateResources = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
const fallbackResources = join(process.cwd(), 'resources')
|
||||
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
|
||||
const userDataPath = app.getPath('userData')
|
||||
wcdbService.setPaths(resourcesPath, userDataPath)
|
||||
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
||||
@@ -690,6 +1450,17 @@ app.whenReady().then(() => {
|
||||
createOnboardingWindow()
|
||||
}
|
||||
|
||||
// 解决朋友圈图片无法加载问题(添加 Referer)
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||
{
|
||||
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
||||
},
|
||||
(details, callback) => {
|
||||
details.requestHeaders['Referer'] = 'https://wx.qq.com/'
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
}
|
||||
)
|
||||
|
||||
// 启动时检测更新
|
||||
checkForUpdatesOnStartup()
|
||||
|
||||
|
||||
24
electron/nodert.d.ts
vendored
Normal file
24
electron/nodert.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
declare module '@nodert-win10-rs4/windows.security.credentials.ui' {
|
||||
export enum UserConsentVerificationResult {
|
||||
Verified = 0,
|
||||
DeviceNotPresent = 1,
|
||||
NotConfiguredForUser = 2,
|
||||
DisabledByPolicy = 3,
|
||||
DeviceBusy = 4,
|
||||
RetriesExhausted = 5,
|
||||
Canceled = 6
|
||||
}
|
||||
|
||||
export enum UserConsentVerifierAvailability {
|
||||
Available = 0,
|
||||
DeviceNotPresent = 1,
|
||||
NotConfiguredForUser = 2,
|
||||
DisabledByPolicy = 3,
|
||||
DeviceBusy = 4
|
||||
}
|
||||
|
||||
export class UserConsentVerifier {
|
||||
static checkAvailabilityAsync(callback: (err: Error | null, availability: UserConsentVerifierAvailability) => void): void;
|
||||
static requestVerificationAsync(message: string, callback: (err: Error | null, result: UserConsentVerificationResult) => void): void;
|
||||
}
|
||||
}
|
||||
39
electron/preload-env.ts
Normal file
39
electron/preload-env.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { join, dirname } from 'path'
|
||||
|
||||
/**
|
||||
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
|
||||
* 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题
|
||||
*/
|
||||
function enforceLocalDllPriority() {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const sep = process.platform === 'win32' ? ';' : ':'
|
||||
|
||||
let possiblePaths: string[] = []
|
||||
|
||||
if (isDev) {
|
||||
// 开发环境
|
||||
possiblePaths.push(join(process.cwd(), 'resources'))
|
||||
} else {
|
||||
// 生产环境
|
||||
possiblePaths.push(dirname(process.execPath))
|
||||
if (process.resourcesPath) {
|
||||
possiblePaths.push(process.resourcesPath)
|
||||
}
|
||||
}
|
||||
|
||||
const dllPaths = possiblePaths.join(sep)
|
||||
|
||||
if (process.env.PATH) {
|
||||
process.env.PATH = dllPaths + sep + process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = dllPaths
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
enforceLocalDllPriority()
|
||||
} catch (e) {
|
||||
console.error('[WeFlow] Failed to enforce local DLL priority:', e)
|
||||
}
|
||||
@@ -9,6 +9,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
clear: () => ipcRenderer.invoke('config:clear')
|
||||
},
|
||||
|
||||
// 通知
|
||||
notification: {
|
||||
show: (data: any) => ipcRenderer.invoke('notification:show', data),
|
||||
close: () => ipcRenderer.invoke('notification:close'),
|
||||
click: (sessionId: string) => ipcRenderer.send('notification-clicked', sessionId),
|
||||
ready: () => ipcRenderer.send('notification:ready'),
|
||||
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
|
||||
onShow: (callback: (event: any, data: any) => void) => {
|
||||
ipcRenderer.on('notification:show', callback)
|
||||
return () => ipcRenderer.removeAllListeners('notification:show')
|
||||
}
|
||||
},
|
||||
|
||||
// 认证
|
||||
auth: {
|
||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
|
||||
},
|
||||
|
||||
|
||||
// 对话框
|
||||
dialog: {
|
||||
@@ -29,7 +47,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
||||
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
||||
onDownloadProgress: (callback: (progress: number) => void) => {
|
||||
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
|
||||
onDownloadProgress: (callback: (progress: any) => void) => {
|
||||
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
|
||||
return () => ipcRenderer.removeAllListeners('app:downloadProgress')
|
||||
},
|
||||
@@ -42,7 +61,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 日志
|
||||
log: {
|
||||
getPath: () => ipcRenderer.invoke('log:getPath'),
|
||||
read: () => ipcRenderer.invoke('log:read')
|
||||
read: () => ipcRenderer.invoke('log:read'),
|
||||
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
||||
},
|
||||
|
||||
// 窗口控制
|
||||
@@ -53,13 +73,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options)
|
||||
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
|
||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
||||
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
|
||||
openImageViewerWindow: (imagePath: string) =>
|
||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
|
||||
},
|
||||
|
||||
// 数据库路径
|
||||
dbPath: {
|
||||
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
|
||||
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
|
||||
scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', rootPath),
|
||||
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
|
||||
},
|
||||
|
||||
@@ -69,7 +98,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid),
|
||||
open: (dbPath: string, hexKey: string, wxid: string) =>
|
||||
ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
|
||||
close: () => ipcRenderer.invoke('wcdb:close')
|
||||
close: () => ipcRenderer.invoke('wcdb:close'),
|
||||
|
||||
},
|
||||
|
||||
// 密钥获取
|
||||
@@ -93,19 +123,49 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number) =>
|
||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
|
||||
getNewMessages: (sessionId: string, minTime: number, limit?: number) =>
|
||||
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
|
||||
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
||||
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
||||
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) =>
|
||||
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
||||
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
||||
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
|
||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
||||
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||
close: () => ipcRenderer.invoke('chat:close'),
|
||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||
getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId)
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||
},
|
||||
execQuery: (kind: string, path: string | null, sql: string) =>
|
||||
ipcRenderer.invoke('chat:execQuery', kind, path, sql),
|
||||
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
||||
getMessage: (sessionId: string, localId: number) =>
|
||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
||||
ipcRenderer.on('wcdb-change', callback)
|
||||
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
// 图片解密
|
||||
image: {
|
||||
@@ -125,24 +185,44 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
}
|
||||
},
|
||||
|
||||
// 视频
|
||||
video: {
|
||||
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||
},
|
||||
|
||||
// 数据分析
|
||||
analytics: {
|
||||
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
|
||||
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
|
||||
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
||||
getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||
ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp),
|
||||
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
||||
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
|
||||
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
|
||||
getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('analytics:progress')
|
||||
}
|
||||
},
|
||||
|
||||
// 缓存管理
|
||||
cache: {
|
||||
clearAnalytics: () => ipcRenderer.invoke('cache:clearAnalytics'),
|
||||
clearImages: () => ipcRenderer.invoke('cache:clearImages'),
|
||||
clearAll: () => ipcRenderer.invoke('cache:clearAll')
|
||||
},
|
||||
|
||||
// 群聊分析
|
||||
groupAnalytics: {
|
||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime)
|
||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||
},
|
||||
|
||||
// 年度报告
|
||||
@@ -156,12 +236,82 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
||||
}
|
||||
},
|
||||
dualReport: {
|
||||
generateReport: (payload: { friendUsername: string; year: number }) =>
|
||||
ipcRenderer.invoke('dualReport:generateReport', payload),
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||
ipcRenderer.on('dualReport:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('dualReport:progress')
|
||||
}
|
||||
},
|
||||
|
||||
// 导出
|
||||
export: {
|
||||
getExportStats: (sessionIds: string[], options: any) =>
|
||||
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options)
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||
exportContacts: (outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
|
||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||
}
|
||||
},
|
||||
|
||||
whisper: {
|
||||
downloadModel: () =>
|
||||
ipcRenderer.invoke('whisper:downloadModel'),
|
||||
getModelStatus: () =>
|
||||
ipcRenderer.invoke('whisper:getModelStatus'),
|
||||
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => {
|
||||
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
|
||||
}
|
||||
},
|
||||
|
||||
// 朋友圈
|
||||
sns: {
|
||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
||||
exportTimeline: (options: any) => ipcRenderer.invoke('sns:exportTimeline', options),
|
||||
onExportProgress: (callback: (payload: any) => void) => {
|
||||
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
|
||||
},
|
||||
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir')
|
||||
},
|
||||
|
||||
// Llama AI
|
||||
llama: {
|
||||
loadModel: (modelPath: string) => ipcRenderer.invoke('llama:loadModel', modelPath),
|
||||
createSession: (systemPrompt?: string) => ipcRenderer.invoke('llama:createSession', systemPrompt),
|
||||
chat: (message: string, options?: any) => ipcRenderer.invoke('llama:chat', message, options),
|
||||
downloadModel: (url: string, savePath: string) => ipcRenderer.invoke('llama:downloadModel', url, savePath),
|
||||
getModelsPath: () => ipcRenderer.invoke('llama:getModelsPath'),
|
||||
checkFileExists: (filePath: string) => ipcRenderer.invoke('llama:checkFileExists', filePath),
|
||||
getModelStatus: (modelPath: string) => ipcRenderer.invoke('llama:getModelStatus', modelPath),
|
||||
onToken: (callback: (token: string) => void) => {
|
||||
const listener = (_: any, token: string) => callback(token)
|
||||
ipcRenderer.on('llama:token', listener)
|
||||
return () => ipcRenderer.removeListener('llama:token', listener)
|
||||
},
|
||||
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => {
|
||||
const listener = (_: any, payload: { downloaded: number; total: number; speed: number }) => callback(payload)
|
||||
ipcRenderer.on('llama:downloadProgress', listener)
|
||||
return () => ipcRenderer.removeListener('llama:downloadProgress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
// HTTP API 服务
|
||||
http: {
|
||||
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
||||
stop: () => ipcRenderer.invoke('http:stop'),
|
||||
status: () => ipcRenderer.invoke('http:status')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { join } from 'path'
|
||||
import { readFile, writeFile, rm } from 'fs/promises'
|
||||
import { app } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export interface ChatStatistics {
|
||||
totalMessages: number
|
||||
@@ -27,6 +31,7 @@ export interface ContactRanking {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
wechatId?: string
|
||||
messageCount: number
|
||||
sentCount: number
|
||||
receivedCount: number
|
||||
@@ -43,6 +48,58 @@ class AnalyticsService {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
private normalizeUsername(username: string): string {
|
||||
return username.trim().toLowerCase()
|
||||
}
|
||||
|
||||
private normalizeExcludedUsernames(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const normalized = value
|
||||
.map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '')
|
||||
.filter((item) => item.length > 0)
|
||||
return Array.from(new Set(normalized))
|
||||
}
|
||||
|
||||
private getExcludedUsernamesList(): string[] {
|
||||
return this.normalizeExcludedUsernames(this.configService.get('analyticsExcludedUsernames'))
|
||||
}
|
||||
|
||||
private getExcludedUsernamesSet(): Set<string> {
|
||||
return new Set(this.getExcludedUsernamesList())
|
||||
}
|
||||
|
||||
private escapeSqlValue(value: string): string {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {}
|
||||
if (usernames.length === 0) return map
|
||||
|
||||
const chunkSize = 200
|
||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
||||
const chunk = usernames.slice(i, i + chunkSize)
|
||||
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
||||
if (!inList) continue
|
||||
const sql = `
|
||||
SELECT username, alias
|
||||
FROM contact
|
||||
WHERE username IN (${inList})
|
||||
`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
if (!result.success || !result.rows) continue
|
||||
for (const row of result.rows as Record<string, any>[]) {
|
||||
const username = row.username || ''
|
||||
const alias = row.alias || ''
|
||||
if (username && alias) {
|
||||
map[username] = alias
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
private cleanAccountDirName(name: string): string {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return trimmed
|
||||
@@ -51,7 +108,11 @@ class AnalyticsService {
|
||||
if (match) return match[1]
|
||||
return trimmed
|
||||
}
|
||||
return trimmed
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private isPrivateSession(username: string, cleanedWxid: string): boolean {
|
||||
@@ -94,13 +155,15 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
private async getPrivateSessions(
|
||||
cleanedWxid: string
|
||||
cleanedWxid: string,
|
||||
excludedUsernames?: Set<string>
|
||||
): Promise<{ usernames: string[]; numericIds: string[] }> {
|
||||
const sessionResult = await wcdbService.getSessions()
|
||||
if (!sessionResult.success || !sessionResult.sessions) {
|
||||
return { usernames: [], numericIds: [] }
|
||||
}
|
||||
const rows = sessionResult.sessions as Record<string, any>[]
|
||||
const excluded = excludedUsernames ?? this.getExcludedUsernamesSet()
|
||||
|
||||
const sample = rows[0]
|
||||
void sample
|
||||
@@ -121,7 +184,11 @@ class AnalyticsService {
|
||||
return { username, idValue }
|
||||
})
|
||||
const usernames = sessions.map((s) => s.username)
|
||||
const privateSessions = sessions.filter((s) => this.isPrivateSession(s.username, cleanedWxid))
|
||||
const privateSessions = sessions.filter((s) => {
|
||||
if (!this.isPrivateSession(s.username, cleanedWxid)) return false
|
||||
if (excluded.size === 0) return true
|
||||
return !excluded.has(this.normalizeUsername(s.username))
|
||||
})
|
||||
const privateUsernames = privateSessions.map((s) => s.username)
|
||||
const numericIds = privateSessions
|
||||
.map((s) => s.idValue)
|
||||
@@ -174,11 +241,18 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
|
||||
const sample = sessionIds.slice(0, 5).join(',')
|
||||
return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}`
|
||||
if (sessionIds.length === 0) {
|
||||
return `${beginTimestamp}-${endTimestamp}-0-empty`
|
||||
}
|
||||
const normalized = Array.from(new Set(sessionIds.map((id) => String(id)))).sort()
|
||||
const hash = createHash('sha1').update(normalized.join('|')).digest('hex').slice(0, 12)
|
||||
return `${beginTimestamp}-${endTimestamp}-${normalized.length}-${hash}`
|
||||
}
|
||||
|
||||
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const cleanedWxid = wxid ? this.cleanAccountDirName(wxid) : ''
|
||||
|
||||
const aggregate = {
|
||||
total: 0,
|
||||
sent: 0,
|
||||
@@ -203,8 +277,22 @@ class AnalyticsService {
|
||||
if (endTimestamp > 0 && createTime > endTimestamp) return
|
||||
|
||||
const localType = parseInt(row.local_type || row.type || '1', 10)
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? 0
|
||||
const isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
|
||||
let isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
|
||||
|
||||
// 如果底层没有提供 is_send,则根据发送者用户名推断
|
||||
const senderUsername = row.sender_username || row.senderUsername || row.sender
|
||||
if (isSendRaw === undefined || isSendRaw === null) {
|
||||
if (senderUsername && (cleanedWxid)) {
|
||||
const senderLower = String(senderUsername).toLowerCase()
|
||||
const myWxidLower = cleanedWxid.toLowerCase()
|
||||
isSend = (
|
||||
senderLower === myWxidLower ||
|
||||
// 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup,而 sender 是 custom)
|
||||
(myWxidLower.startsWith(senderLower + '_'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
aggregate.total += 1
|
||||
sessionStat.total += 1
|
||||
@@ -253,15 +341,31 @@ class AnalyticsService {
|
||||
sessionIds: string[],
|
||||
beginTimestamp = 0,
|
||||
endTimestamp = 0,
|
||||
window?: any
|
||||
window?: any,
|
||||
force = false
|
||||
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
|
||||
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
|
||||
if (this.aggregateCache && this.aggregateCache.key === cacheKey) {
|
||||
|
||||
if (force) {
|
||||
if (this.aggregateCache) this.aggregateCache = null
|
||||
if (this.fallbackAggregateCache) this.fallbackAggregateCache = null
|
||||
}
|
||||
|
||||
if (!force && this.aggregateCache && this.aggregateCache.key === cacheKey) {
|
||||
if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
|
||||
return { success: true, data: this.aggregateCache.data, source: 'cache' }
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从文件加载缓存
|
||||
if (!force) {
|
||||
const fileCache = await this.loadCacheFromFile()
|
||||
if (fileCache && fileCache.key === cacheKey) {
|
||||
this.aggregateCache = fileCache
|
||||
return { success: true, data: fileCache.data, source: 'file-cache' }
|
||||
}
|
||||
}
|
||||
|
||||
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
|
||||
return this.aggregatePromise.promise
|
||||
}
|
||||
@@ -291,7 +395,12 @@ class AnalyticsService {
|
||||
|
||||
this.aggregatePromise = { key: cacheKey, promise }
|
||||
try {
|
||||
return await promise
|
||||
const result = await promise
|
||||
// 如果计算成功,同时写入此文件缓存
|
||||
if (result.success && result.data && result.source !== 'cache') {
|
||||
this.saveCacheToFile({ key: cacheKey, data: this.aggregateCache?.data, updatedAt: Date.now() })
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
|
||||
this.aggregatePromise = null
|
||||
@@ -299,6 +408,25 @@ class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
private getCacheFilePath(): string {
|
||||
return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json')
|
||||
}
|
||||
|
||||
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
|
||||
try {
|
||||
const raw = await readFile(this.getCacheFilePath(), 'utf-8')
|
||||
return JSON.parse(raw)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
private async saveCacheToFile(data: any) {
|
||||
try {
|
||||
await writeFile(this.getCacheFilePath(), JSON.stringify(data))
|
||||
} catch (e) {
|
||||
console.error('保存统计缓存失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeAggregateSessions(
|
||||
sessions: Record<string, any> | undefined,
|
||||
idMap: Record<string, string> | undefined
|
||||
@@ -326,7 +454,66 @@ class AnalyticsService {
|
||||
void results
|
||||
}
|
||||
|
||||
async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||
async getExcludedUsernames(): Promise<{ success: boolean; data?: string[]; error?: string }> {
|
||||
try {
|
||||
return { success: true, data: this.getExcludedUsernamesList() }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async setExcludedUsernames(usernames: string[]): Promise<{ success: boolean; data?: string[]; error?: string }> {
|
||||
try {
|
||||
const normalized = this.normalizeExcludedUsernames(usernames)
|
||||
this.configService.set('analyticsExcludedUsernames', normalized)
|
||||
await this.clearCache()
|
||||
return { success: true, data: normalized }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getExcludeCandidates(): Promise<{ success: boolean; data?: Array<{ username: string; displayName: string; avatarUrl?: string; wechatId?: string }>; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||
|
||||
const excluded = this.getExcludedUsernamesSet()
|
||||
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid, new Set())
|
||||
|
||||
const usernames = new Set<string>(sessionInfo.usernames)
|
||||
for (const name of excluded) usernames.add(name)
|
||||
|
||||
if (usernames.size === 0) {
|
||||
return { success: true, data: [] }
|
||||
}
|
||||
|
||||
const usernameList = Array.from(usernames)
|
||||
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
|
||||
wcdbService.getDisplayNames(usernameList),
|
||||
wcdbService.getAvatarUrls(usernameList),
|
||||
this.getAliasMap(usernameList)
|
||||
])
|
||||
|
||||
const entries = usernameList.map((username) => {
|
||||
const displayName = displayNames.success && displayNames.map
|
||||
? (displayNames.map[username] || username)
|
||||
: username
|
||||
const avatarUrl = avatarUrls.success && avatarUrls.map
|
||||
? avatarUrls.map[username]
|
||||
: undefined
|
||||
const alias = aliasMap[username]
|
||||
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
|
||||
return { username, displayName, avatarUrl, wechatId }
|
||||
})
|
||||
|
||||
return { success: true, data: entries }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||
@@ -340,7 +527,7 @@ class AnalyticsService {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
this.setProgress(win, '正在执行原生数据聚合...', 30)
|
||||
|
||||
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win)
|
||||
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win, force)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.error || '聚合统计失败' }
|
||||
@@ -390,7 +577,11 @@ class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
async getContactRankings(limit: number = 20): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
|
||||
async getContactRankings(
|
||||
limit: number = 20,
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0
|
||||
): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||
@@ -400,7 +591,7 @@ class AnalyticsService {
|
||||
return { success: false, error: '未找到消息会话' }
|
||||
}
|
||||
|
||||
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0)
|
||||
const result = await this.getAggregateWithFallback(sessionInfo.usernames, beginTimestamp, endTimestamp)
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.error || '聚合统计失败' }
|
||||
}
|
||||
@@ -408,9 +599,10 @@ class AnalyticsService {
|
||||
const d = result.data
|
||||
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
|
||||
const usernames = Object.keys(sessions)
|
||||
const [displayNames, avatarUrls] = await Promise.all([
|
||||
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
|
||||
wcdbService.getDisplayNames(usernames),
|
||||
wcdbService.getAvatarUrls(usernames)
|
||||
wcdbService.getAvatarUrls(usernames),
|
||||
this.getAliasMap(usernames)
|
||||
])
|
||||
|
||||
const rankings: ContactRanking[] = usernames
|
||||
@@ -422,10 +614,13 @@ class AnalyticsService {
|
||||
const avatarUrl = avatarUrls.success && avatarUrls.map
|
||||
? avatarUrls.map[username]
|
||||
: undefined
|
||||
const alias = aliasMap[username] || ''
|
||||
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
|
||||
return {
|
||||
username,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
wechatId,
|
||||
messageCount: stat.total,
|
||||
sentCount: stat.sent,
|
||||
receivedCount: stat.received,
|
||||
@@ -458,8 +653,8 @@ class AnalyticsService {
|
||||
|
||||
const d = result.data
|
||||
|
||||
// SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat
|
||||
// 前端期望 1=Mon...7=Sun
|
||||
// SQLite strftime('%w') 返回 0=周日, 1=周一...6=周六
|
||||
// 前端期望 1=周一...7=周日
|
||||
const weekdayDistribution: Record<number, number> = {}
|
||||
for (const [w, count] of Object.entries(d.weekday)) {
|
||||
const sqliteW = parseInt(w, 10)
|
||||
@@ -485,6 +680,18 @@ class AnalyticsService {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||
this.aggregateCache = null
|
||||
this.fallbackAggregateCache = null
|
||||
this.aggregatePromise = null
|
||||
try {
|
||||
await rm(this.getCacheFilePath(), { force: true })
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const analyticsService = new AnalyticsService()
|
||||
|
||||
@@ -69,6 +69,20 @@ export interface AnnualReportData {
|
||||
phrase: string
|
||||
count: number
|
||||
}[]
|
||||
snsStats?: {
|
||||
totalPosts: number
|
||||
typeCounts?: Record<string, number>
|
||||
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
}
|
||||
lostFriend: {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
earlyCount: number
|
||||
lateCount: number
|
||||
periodDesc: string
|
||||
} | null
|
||||
}
|
||||
|
||||
class AnnualReportService {
|
||||
@@ -101,8 +115,9 @@ class AnnualReportService {
|
||||
return trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
if (suffixMatch) return suffixMatch[1]
|
||||
return trimmed
|
||||
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private async ensureConnectedWithConfig(
|
||||
@@ -178,11 +193,15 @@ class AnnualReportService {
|
||||
if (!raw) return ''
|
||||
if (typeof raw === 'string') {
|
||||
if (raw.length === 0) return ''
|
||||
if (this.looksLikeHex(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码
|
||||
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
|
||||
if (raw.length > 16 && this.looksLikeHex(raw)) {
|
||||
const bytes = Buffer.from(raw, 'hex')
|
||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||
}
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||
try {
|
||||
const bytes = Buffer.from(raw, 'base64')
|
||||
return this.decodeBinaryContent(bytes)
|
||||
@@ -397,8 +416,15 @@ class AnnualReportService {
|
||||
|
||||
this.reportProgress('加载会话列表...', 15, onProgress)
|
||||
|
||||
const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000)
|
||||
const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
|
||||
const isAllTime = year <= 0
|
||||
const reportYear = isAllTime ? 0 : year
|
||||
const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000)
|
||||
const endTime = isAllTime ? 0 : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
|
||||
|
||||
const now = new Date()
|
||||
// 全局统计始终使用自然年范围 (Jan 1st - Now/YearEnd)
|
||||
const actualStartTime = startTime
|
||||
const actualEndTime = endTime
|
||||
|
||||
let totalMessages = 0
|
||||
const contactStats = new Map<string, { sent: number; received: number }>()
|
||||
@@ -420,7 +446,7 @@ class AnnualReportService {
|
||||
const CONVERSATION_GAP = 3600
|
||||
|
||||
this.reportProgress('统计会话消息...', 20, onProgress)
|
||||
const result = await wcdbService.getAnnualReportStats(sessionIds, startTime, endTime)
|
||||
const result = await wcdbService.getAnnualReportStats(sessionIds, actualStartTime, actualEndTime)
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
|
||||
}
|
||||
@@ -473,8 +499,8 @@ class AnnualReportService {
|
||||
}
|
||||
}
|
||||
|
||||
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
|
||||
const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd)
|
||||
this.reportProgress('加载扩展统计...', 30, onProgress)
|
||||
const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
|
||||
if (extras.success && extras.data) {
|
||||
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
||||
const extrasData = extras.data as any
|
||||
@@ -554,7 +580,7 @@ class AnnualReportService {
|
||||
// 为保持功能完整,我们进行深度集成的轻量遍历:
|
||||
for (let i = 0; i < sessionIds.length; i++) {
|
||||
const sessionId = sessionIds[i]
|
||||
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, startTime, endTime)
|
||||
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, actualStartTime, actualEndTime)
|
||||
if (!cursor.success || !cursor.cursor) continue
|
||||
|
||||
let lastDayIndex: number | null = null
|
||||
@@ -575,9 +601,22 @@ class AnnualReportService {
|
||||
if (!createTime) continue
|
||||
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
|
||||
const isSent = parseInt(isSendRaw, 10) === 1
|
||||
let isSent = parseInt(isSendRaw, 10) === 1
|
||||
const localType = parseInt(row.local_type || row.type || '1', 10)
|
||||
|
||||
// 兼容逻辑
|
||||
if (isSendRaw === undefined || isSendRaw === null || isSendRaw === '0') {
|
||||
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
|
||||
if (sender) {
|
||||
const rawLower = rawWxid.toLowerCase()
|
||||
const cleanedLower = cleanedWxid.toLowerCase()
|
||||
if (sender === rawLower || sender === cleanedLower ||
|
||||
rawLower.startsWith(sender + '_') || cleanedLower.startsWith(sender + '_')) {
|
||||
isSent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应速度 & 对话发起
|
||||
if (!conversationStarts.has(sessionId)) {
|
||||
conversationStarts.set(sessionId, { initiated: 0, received: 0 })
|
||||
@@ -689,7 +728,7 @@ class AnnualReportService {
|
||||
|
||||
if (!streakComputedInLoop) {
|
||||
this.reportProgress('计算连续聊天...', 45, onProgress)
|
||||
const streakResult = await this.computeLongestStreak(sessionIds, startTime, endTime, onProgress, 45, 75)
|
||||
const streakResult = await this.computeLongestStreak(sessionIds, actualStartTime, actualEndTime, onProgress, 45, 75)
|
||||
if (streakResult.days > longestStreakDays) {
|
||||
longestStreakDays = streakResult.days
|
||||
longestStreakSessionId = streakResult.sessionId
|
||||
@@ -698,6 +737,42 @@ class AnnualReportService {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取朋友圈统计
|
||||
this.reportProgress('分析朋友圈数据...', 75, onProgress)
|
||||
let snsStatsResult: {
|
||||
totalPosts: number
|
||||
typeCounts?: Record<string, number>
|
||||
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
} | undefined
|
||||
|
||||
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime)
|
||||
|
||||
if (snsStats.success && snsStats.data) {
|
||||
const d = snsStats.data
|
||||
const usersToFetch = new Set<string>()
|
||||
d.topLikers?.forEach((u: any) => usersToFetch.add(u.username))
|
||||
d.topLiked?.forEach((u: any) => usersToFetch.add(u.username))
|
||||
|
||||
const snsUserIds = Array.from(usersToFetch)
|
||||
const [snsDisplayNames, snsAvatarUrls] = await Promise.all([
|
||||
wcdbService.getDisplayNames(snsUserIds),
|
||||
wcdbService.getAvatarUrls(snsUserIds)
|
||||
])
|
||||
|
||||
const getSnsUserInfo = (username: string) => ({
|
||||
displayName: snsDisplayNames.success && snsDisplayNames.map ? (snsDisplayNames.map[username] || username) : username,
|
||||
avatarUrl: snsAvatarUrls.success && snsAvatarUrls.map ? snsAvatarUrls.map[username] : undefined
|
||||
})
|
||||
|
||||
snsStatsResult = {
|
||||
totalPosts: d.totalPosts || 0,
|
||||
typeCounts: d.typeCounts,
|
||||
topLikers: (d.topLikers || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })),
|
||||
topLiked: (d.topLiked || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) }))
|
||||
}
|
||||
}
|
||||
|
||||
this.reportProgress('整理联系人信息...', 85, onProgress)
|
||||
|
||||
const contactIds = Array.from(contactStats.keys())
|
||||
@@ -901,8 +976,130 @@ class AnnualReportService {
|
||||
.slice(0, 32)
|
||||
.map(([phrase, count]) => ({ phrase, count }))
|
||||
|
||||
// 曾经的好朋友 (Once Best Friend / Lost Friend)
|
||||
let lostFriend: AnnualReportData['lostFriend'] = null
|
||||
let maxEarlyCount = 80 // 最低门槛
|
||||
let bestEarlyCount = 0
|
||||
let bestLateCount = 0
|
||||
let bestSid = ''
|
||||
let bestPeriodDesc = ''
|
||||
|
||||
const currentMonthIndex = new Date().getMonth() + 1 // 1-12
|
||||
|
||||
const currentYearNum = now.getFullYear()
|
||||
|
||||
if (isAllTime) {
|
||||
const days = Object.keys(d.daily).sort()
|
||||
if (days.length >= 2) {
|
||||
const firstDay = Math.floor(new Date(days[0]).getTime() / 1000)
|
||||
const lastDay = Math.floor(new Date(days[days.length - 1]).getTime() / 1000)
|
||||
const midPoint = Math.floor((firstDay + lastDay) / 2)
|
||||
|
||||
this.reportProgress('分析历史趋势 (1/2)...', 86, onProgress)
|
||||
const earlyRes = await wcdbService.getAggregateStats(sessionIds, 0, midPoint)
|
||||
this.reportProgress('分析历史趋势 (2/2)...', 88, onProgress)
|
||||
const lateRes = await wcdbService.getAggregateStats(sessionIds, midPoint, 0)
|
||||
|
||||
if (earlyRes.success && lateRes.success && earlyRes.data) {
|
||||
const earlyData = earlyRes.data.sessions || {}
|
||||
const lateData = (lateRes.data?.sessions) || {}
|
||||
for (const sid of sessionIds) {
|
||||
const e = earlyData[sid] || { sent: 0, received: 0 }
|
||||
const l = lateData[sid] || { sent: 0, received: 0 }
|
||||
const early = (e.sent || 0) + (e.received || 0)
|
||||
const late = (l.sent || 0) + (l.received || 0)
|
||||
if (early > 100 && early > late * 5) {
|
||||
// 选择前期消息量最多的
|
||||
if (early > maxEarlyCount) {
|
||||
maxEarlyCount = early
|
||||
bestEarlyCount = early
|
||||
bestLateCount = late
|
||||
bestSid = sid
|
||||
bestPeriodDesc = '这段时间以来'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (year === currentYearNum) {
|
||||
// 当前年份:独立获取过去12个月的滚动数据
|
||||
this.reportProgress('分析近期好友趋势...', 86, onProgress)
|
||||
// 往前数12个月的起点、中点、终点
|
||||
const rollingStart = Math.floor(new Date(now.getFullYear(), now.getMonth() - 11, 1).getTime() / 1000)
|
||||
const rollingMid = Math.floor(new Date(now.getFullYear(), now.getMonth() - 5, 1).getTime() / 1000)
|
||||
const rollingEnd = Math.floor(now.getTime() / 1000)
|
||||
|
||||
const earlyRes = await wcdbService.getAggregateStats(sessionIds, rollingStart, rollingMid - 1)
|
||||
const lateRes = await wcdbService.getAggregateStats(sessionIds, rollingMid, rollingEnd)
|
||||
|
||||
if (earlyRes.success && lateRes.success && earlyRes.data) {
|
||||
const earlyData = earlyRes.data.sessions || {}
|
||||
const lateData = lateRes.data?.sessions || {}
|
||||
for (const sid of sessionIds) {
|
||||
const e = earlyData[sid] || { sent: 0, received: 0 }
|
||||
const l = lateData[sid] || { sent: 0, received: 0 }
|
||||
const early = (e.sent || 0) + (e.received || 0)
|
||||
const late = (l.sent || 0) + (l.received || 0)
|
||||
if (early > 80 && early > late * 5) {
|
||||
// 选择前期消息量最多的
|
||||
if (early > maxEarlyCount) {
|
||||
maxEarlyCount = early
|
||||
bestEarlyCount = early
|
||||
bestLateCount = late
|
||||
bestSid = sid
|
||||
bestPeriodDesc = '去年的这个时候'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 指定完整年份 (1-6 vs 7-12)
|
||||
for (const [sid, stat] of Object.entries(d.sessions)) {
|
||||
const s = stat as any
|
||||
const mWeights = s.monthly || {}
|
||||
let early = 0
|
||||
let late = 0
|
||||
for (let m = 1; m <= 6; m++) early += mWeights[m] || 0
|
||||
for (let m = 7; m <= 12; m++) late += mWeights[m] || 0
|
||||
|
||||
if (early > 80 && early > late * 5) {
|
||||
// 选择前期消息量最多的
|
||||
if (early > maxEarlyCount) {
|
||||
maxEarlyCount = early
|
||||
bestEarlyCount = early
|
||||
bestLateCount = late
|
||||
bestSid = sid
|
||||
bestPeriodDesc = `${year}年上半年`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSid) {
|
||||
let info = contactInfoMap.get(bestSid)
|
||||
// 如果 contactInfoMap 中没有该联系人,则单独获取
|
||||
if (!info) {
|
||||
const [displayNameRes, avatarUrlRes] = await Promise.all([
|
||||
wcdbService.getDisplayNames([bestSid]),
|
||||
wcdbService.getAvatarUrls([bestSid])
|
||||
])
|
||||
info = {
|
||||
displayName: displayNameRes.success && displayNameRes.map ? (displayNameRes.map[bestSid] || bestSid) : bestSid,
|
||||
avatarUrl: avatarUrlRes.success && avatarUrlRes.map ? avatarUrlRes.map[bestSid] : undefined
|
||||
}
|
||||
}
|
||||
lostFriend = {
|
||||
username: bestSid,
|
||||
displayName: info?.displayName || bestSid,
|
||||
avatarUrl: info?.avatarUrl,
|
||||
earlyCount: bestEarlyCount,
|
||||
lateCount: bestLateCount,
|
||||
periodDesc: bestPeriodDesc
|
||||
}
|
||||
}
|
||||
|
||||
const reportData: AnnualReportData = {
|
||||
year,
|
||||
year: reportYear,
|
||||
totalMessages,
|
||||
totalFriends: contactStats.size,
|
||||
coreFriends,
|
||||
@@ -915,7 +1112,9 @@ class AnnualReportService {
|
||||
mutualFriend,
|
||||
socialInitiative,
|
||||
responseSpeed,
|
||||
topPhrases
|
||||
topPhrases,
|
||||
snsStats: snsStatsResult,
|
||||
lostFriend
|
||||
}
|
||||
|
||||
return { success: true, data: reportData }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
import { join } from 'path'
|
||||
import { app } from 'electron'
|
||||
import Store from 'electron-store'
|
||||
|
||||
interface ConfigSchema {
|
||||
@@ -8,9 +10,11 @@ interface ConfigSchema {
|
||||
onboardingDone: boolean
|
||||
imageXorKey: number
|
||||
imageAesKey: string
|
||||
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||||
|
||||
// 缓存相关
|
||||
cachePath: string
|
||||
|
||||
lastOpenedDb: string
|
||||
lastSession: string
|
||||
|
||||
@@ -19,12 +23,47 @@ interface ConfigSchema {
|
||||
themeId: string
|
||||
language: string
|
||||
logEnabled: boolean
|
||||
llmModelPath: string
|
||||
whisperModelName: string
|
||||
whisperModelDir: string
|
||||
whisperDownloadSource: string
|
||||
autoTranscribeVoice: boolean
|
||||
transcribeLanguages: string[]
|
||||
exportDefaultConcurrency: number
|
||||
analyticsExcludedUsernames: string[]
|
||||
|
||||
// 安全相关
|
||||
authEnabled: boolean
|
||||
authPassword: string // SHA-256 hash
|
||||
authUseHello: boolean
|
||||
|
||||
// 更新相关
|
||||
ignoredUpdateVersion: string
|
||||
|
||||
// 通知
|
||||
notificationEnabled: boolean
|
||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
wordCloudExcludeWords: string[]
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
private store: Store<ConfigSchema>
|
||||
private static instance: ConfigService
|
||||
private store!: Store<ConfigSchema>
|
||||
|
||||
static getInstance(): ConfigService {
|
||||
if (!ConfigService.instance) {
|
||||
ConfigService.instance = new ConfigService()
|
||||
}
|
||||
return ConfigService.instance
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (ConfigService.instance) {
|
||||
return ConfigService.instance
|
||||
}
|
||||
ConfigService.instance = this
|
||||
this.store = new Store<ConfigSchema>({
|
||||
name: 'WeFlow-config',
|
||||
defaults: {
|
||||
@@ -34,13 +73,34 @@ export class ConfigService {
|
||||
onboardingDone: false,
|
||||
imageXorKey: 0,
|
||||
imageAesKey: '',
|
||||
wxidConfigs: {},
|
||||
cachePath: '',
|
||||
|
||||
lastOpenedDb: '',
|
||||
lastSession: '',
|
||||
theme: 'system',
|
||||
themeId: 'cloud-dancer',
|
||||
language: 'zh-CN',
|
||||
logEnabled: false
|
||||
logEnabled: false,
|
||||
llmModelPath: '',
|
||||
whisperModelName: 'base',
|
||||
whisperModelDir: '',
|
||||
whisperDownloadSource: 'tsinghua',
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 2,
|
||||
analyticsExcludedUsernames: [],
|
||||
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
authUseHello: false,
|
||||
|
||||
ignoredUpdateVersion: '',
|
||||
notificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: [],
|
||||
wordCloudExcludeWords: []
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -53,6 +113,14 @@ export class ConfigService {
|
||||
this.store.set(key, value)
|
||||
}
|
||||
|
||||
getCacheBasePath(): string {
|
||||
const configured = this.get('cachePath')
|
||||
if (configured && configured.trim().length > 0) {
|
||||
return configured
|
||||
}
|
||||
return join(app.getPath('documents'), 'WeFlow')
|
||||
}
|
||||
|
||||
getAll(): ConfigSchema {
|
||||
return this.store.store
|
||||
}
|
||||
|
||||
93
electron/services/contactCacheService.ts
Normal file
93
electron/services/contactCacheService.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
export interface ContactCacheEntry {
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export class ContactCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private cache: Record<string, ContactCacheEntry> = {}
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: ConfigService.getInstance().getCacheBasePath()
|
||||
this.cacheFilePath = join(basePath, 'contacts.json')
|
||||
this.ensureCacheDir()
|
||||
this.loadCache()
|
||||
}
|
||||
|
||||
private ensureCacheDir() {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private loadCache() {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
// 清除无效的头像数据(hex 格式而非正确的 base64)
|
||||
for (const key of Object.keys(parsed)) {
|
||||
const entry = parsed[key]
|
||||
if (entry?.avatarUrl && entry.avatarUrl.includes('base64,ffd8')) {
|
||||
// 这是错误的 hex 格式,清除它
|
||||
entry.avatarUrl = undefined
|
||||
}
|
||||
}
|
||||
this.cache = parsed
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ContactCacheService: 载入缓存失败', error)
|
||||
this.cache = {}
|
||||
}
|
||||
}
|
||||
|
||||
get(username: string): ContactCacheEntry | undefined {
|
||||
return this.cache[username]
|
||||
}
|
||||
|
||||
getAllEntries(): Record<string, ContactCacheEntry> {
|
||||
return { ...this.cache }
|
||||
}
|
||||
|
||||
setEntries(entries: Record<string, ContactCacheEntry>): void {
|
||||
if (Object.keys(entries).length === 0) return
|
||||
let changed = false
|
||||
for (const [username, entry] of Object.entries(entries)) {
|
||||
const existing = this.cache[username]
|
||||
if (!existing || entry.updatedAt >= existing.updatedAt) {
|
||||
this.cache[username] = entry
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('ContactCacheService: 保存缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache = {}
|
||||
try {
|
||||
rmSync(this.cacheFilePath, { force: true })
|
||||
} catch (error) {
|
||||
console.error('ContactCacheService: 清理缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
165
electron/services/contactExportService.ts
Normal file
165
electron/services/contactExportService.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { chatService } from './chatService'
|
||||
|
||||
interface ContactExportOptions {
|
||||
format: 'json' | 'csv' | 'vcf'
|
||||
exportAvatars: boolean
|
||||
contactTypes: {
|
||||
friends: boolean
|
||||
groups: boolean
|
||||
officials: boolean
|
||||
}
|
||||
selectedUsernames?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 联系人导出服务
|
||||
*/
|
||||
class ContactExportService {
|
||||
/**
|
||||
* 导出联系人
|
||||
*/
|
||||
async exportContacts(
|
||||
outputDir: string,
|
||||
options: ContactExportOptions
|
||||
): Promise<{ success: boolean; successCount?: number; error?: string }> {
|
||||
try {
|
||||
// 获取所有联系人
|
||||
const contactsResult = await chatService.getContacts()
|
||||
if (!contactsResult.success || !contactsResult.contacts) {
|
||||
return { success: false, error: contactsResult.error || '获取联系人失败' }
|
||||
}
|
||||
|
||||
let contacts = contactsResult.contacts
|
||||
|
||||
// 根据类型过滤
|
||||
contacts = contacts.filter(c => {
|
||||
if (c.type === 'friend' && !options.contactTypes.friends) return false
|
||||
if (c.type === 'group' && !options.contactTypes.groups) return false
|
||||
if (c.type === 'official' && !options.contactTypes.officials) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (Array.isArray(options.selectedUsernames) && options.selectedUsernames.length > 0) {
|
||||
const selectedSet = new Set(options.selectedUsernames)
|
||||
contacts = contacts.filter(c => selectedSet.has(c.username))
|
||||
}
|
||||
|
||||
if (contacts.length === 0) {
|
||||
return { success: false, error: '没有符合条件的联系人' }
|
||||
}
|
||||
|
||||
// 确保输出目录存在
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
||||
let outputPath: string
|
||||
|
||||
switch (options.format) {
|
||||
case 'json':
|
||||
outputPath = path.join(outputDir, `contacts_${timestamp}.json`)
|
||||
await this.exportToJSON(contacts, outputPath)
|
||||
break
|
||||
case 'csv':
|
||||
outputPath = path.join(outputDir, `contacts_${timestamp}.csv`)
|
||||
await this.exportToCSV(contacts, outputPath)
|
||||
break
|
||||
case 'vcf':
|
||||
outputPath = path.join(outputDir, `contacts_${timestamp}.vcf`)
|
||||
await this.exportToVCF(contacts, outputPath)
|
||||
break
|
||||
default:
|
||||
return { success: false, error: '不支持的导出格式' }
|
||||
}
|
||||
|
||||
return { success: true, successCount: contacts.length }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为JSON格式
|
||||
*/
|
||||
private async exportToJSON(contacts: any[], outputPath: string): Promise<void> {
|
||||
const data = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
count: contacts.length,
|
||||
contacts: contacts.map(c => ({
|
||||
username: c.username,
|
||||
displayName: c.displayName,
|
||||
remark: c.remark,
|
||||
nickname: c.nickname,
|
||||
type: c.type
|
||||
}))
|
||||
}
|
||||
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为CSV格式
|
||||
*/
|
||||
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
|
||||
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
|
||||
const rows = contacts.map(c => [
|
||||
c.username || '',
|
||||
c.displayName || '',
|
||||
c.remark || '',
|
||||
c.nickname || '',
|
||||
this.getTypeLabel(c.type)
|
||||
])
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n')
|
||||
|
||||
fs.writeFileSync(outputPath, '\uFEFF' + csvContent, 'utf-8') // 添加BOM以支持Excel
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为VCF格式(vCard)
|
||||
*/
|
||||
private async exportToVCF(contacts: any[], outputPath: string): Promise<void> {
|
||||
const vcards = contacts
|
||||
.filter(c => c.type === 'friend') // VCF通常只用于个人联系人
|
||||
.map(c => {
|
||||
const lines = ['BEGIN:VCARD', 'VERSION:3.0']
|
||||
|
||||
// 全名
|
||||
lines.push(`FN:${c.displayName || c.username}`)
|
||||
|
||||
// 昵称
|
||||
if (c.nickname) {
|
||||
lines.push(`NICKNAME:${c.nickname}`)
|
||||
}
|
||||
|
||||
// 备注
|
||||
if (c.remark) {
|
||||
lines.push(`NOTE:${c.remark}`)
|
||||
}
|
||||
|
||||
// 微信ID
|
||||
lines.push(`X-WECHAT-ID:${c.username}`)
|
||||
|
||||
lines.push('END:VCARD')
|
||||
return lines.join('\r\n')
|
||||
})
|
||||
|
||||
fs.writeFileSync(outputPath, vcards.join('\r\n\r\n'), 'utf-8')
|
||||
}
|
||||
|
||||
private getTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'friend': return '好友'
|
||||
case 'group': return '群聊'
|
||||
case 'official': return '公众号'
|
||||
default: return '其他'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const contactExportService = new ContactExportService()
|
||||
@@ -18,8 +18,7 @@ export class DbPathService {
|
||||
|
||||
// 微信4.x 数据目录
|
||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||
// 旧版微信数据目录
|
||||
possiblePaths.push(join(home, 'Documents', 'WeChat Files'))
|
||||
|
||||
|
||||
for (const path of possiblePaths) {
|
||||
if (existsSync(path)) {
|
||||
@@ -69,7 +68,7 @@ export class DbPathService {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
return accounts
|
||||
}
|
||||
@@ -119,6 +118,48 @@ export class DbPathService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
||||
*/
|
||||
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
||||
const wxids: WxidInfo[] = []
|
||||
|
||||
try {
|
||||
if (existsSync(rootPath)) {
|
||||
const entries = readdirSync(rootPath)
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try {
|
||||
stat = statSync(entryPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!stat.isDirectory()) continue
|
||||
const lower = entry.toLowerCase()
|
||||
if (lower === 'all_users') continue
|
||||
if (!entry.includes('_')) continue
|
||||
|
||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||
}
|
||||
}
|
||||
|
||||
if (wxids.length === 0) {
|
||||
const rootName = basename(rootPath)
|
||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||
const rootStat = statSync(rootPath)
|
||||
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return wxids.sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 wxid 列表
|
||||
*/
|
||||
@@ -139,7 +180,7 @@ export class DbPathService {
|
||||
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
||||
wxids.push({ wxid: account, modifiedTime })
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
return wxids.sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
|
||||
802
electron/services/dualReportService.ts
Normal file
802
electron/services/dualReportService.ts
Normal file
@@ -0,0 +1,802 @@
|
||||
import { parentPort } from 'worker_threads'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
|
||||
export interface DualReportMessage {
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
localType?: number
|
||||
emojiMd5?: string
|
||||
emojiCdnUrl?: string
|
||||
}
|
||||
|
||||
export interface DualReportFirstChat {
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
senderUsername?: string
|
||||
localType?: number
|
||||
emojiMd5?: string
|
||||
emojiCdnUrl?: string
|
||||
}
|
||||
|
||||
export interface DualReportStats {
|
||||
totalMessages: number
|
||||
totalWords: number
|
||||
imageCount: number
|
||||
voiceCount: number
|
||||
emojiCount: number
|
||||
myTopEmojiMd5?: string
|
||||
friendTopEmojiMd5?: string
|
||||
myTopEmojiUrl?: string
|
||||
friendTopEmojiUrl?: string
|
||||
myTopEmojiCount?: number
|
||||
friendTopEmojiCount?: number
|
||||
}
|
||||
|
||||
export interface DualReportData {
|
||||
year: number
|
||||
selfName: string
|
||||
selfAvatarUrl?: string
|
||||
friendUsername: string
|
||||
friendName: string
|
||||
friendAvatarUrl?: string
|
||||
firstChat: DualReportFirstChat | null
|
||||
firstChatMessages?: DualReportMessage[]
|
||||
yearFirstChat?: {
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
friendName: string
|
||||
firstThreeMessages: DualReportMessage[]
|
||||
localType?: number
|
||||
emojiMd5?: string
|
||||
emojiCdnUrl?: string
|
||||
} | null
|
||||
stats: DualReportStats
|
||||
topPhrases: Array<{ phrase: string; count: number }>
|
||||
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||
heatmap?: number[][]
|
||||
initiative?: { initiated: number; received: number }
|
||||
response?: { avg: number; fastest: number; count: number }
|
||||
monthly?: Record<string, number>
|
||||
streak?: { days: number; startDate: string; endDate: string }
|
||||
}
|
||||
|
||||
class DualReportService {
|
||||
private broadcastProgress(status: string, progress: number) {
|
||||
if (parentPort) {
|
||||
parentPort.postMessage({
|
||||
type: 'dualReport:progress',
|
||||
data: { status, progress }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) {
|
||||
if (onProgress) {
|
||||
onProgress(status, progress)
|
||||
return
|
||||
}
|
||||
this.broadcastProgress(status, progress)
|
||||
}
|
||||
|
||||
private cleanAccountDirName(dirName: string): string {
|
||||
const trimmed = dirName.trim()
|
||||
if (!trimmed) return trimmed
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
if (match) return match[1]
|
||||
return trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private async ensureConnectedWithConfig(
|
||||
dbPath: string,
|
||||
decryptKey: string,
|
||||
wxid: string
|
||||
): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> {
|
||||
if (!wxid) return { success: false, error: '未配置微信ID' }
|
||||
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||
return { success: true, cleanedWxid, rawWxid: wxid }
|
||||
}
|
||||
|
||||
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
||||
let content = this.decodeMaybeCompressed(compressContent)
|
||||
if (!content || content.length === 0) {
|
||||
content = this.decodeMaybeCompressed(messageContent)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
private decodeMaybeCompressed(raw: any): string {
|
||||
if (!raw) return ''
|
||||
if (typeof raw === 'string') {
|
||||
if (raw.length === 0) return ''
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码
|
||||
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
|
||||
if (raw.length > 16 && this.looksLikeHex(raw)) {
|
||||
const bytes = Buffer.from(raw, 'hex')
|
||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||
}
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||
try {
|
||||
const bytes = Buffer.from(raw, 'base64')
|
||||
return this.decodeBinaryContent(bytes)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private decodeBinaryContent(data: Buffer): string {
|
||||
if (data.length === 0) return ''
|
||||
try {
|
||||
if (data.length >= 4) {
|
||||
const magic = data.readUInt32LE(0)
|
||||
if (magic === 0xFD2FB528) {
|
||||
const fzstd = require('fzstd')
|
||||
const decompressed = fzstd.decompress(data)
|
||||
return Buffer.from(decompressed).toString('utf-8')
|
||||
}
|
||||
}
|
||||
const decoded = data.toString('utf-8')
|
||||
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
|
||||
if (replacementCount < decoded.length * 0.2) {
|
||||
return decoded.replace(/\uFFFD/g, '')
|
||||
}
|
||||
return data.toString('latin1')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private looksLikeHex(s: string): boolean {
|
||||
if (s.length % 2 !== 0) return false
|
||||
return /^[0-9a-fA-F]+$/.test(s)
|
||||
}
|
||||
|
||||
private looksLikeBase64(s: string): boolean {
|
||||
if (s.length % 4 !== 0) return false
|
||||
return /^[A-Za-z0-9+/=]+$/.test(s)
|
||||
}
|
||||
|
||||
private formatDateTime(milliseconds: number): string {
|
||||
const dt = new Date(milliseconds)
|
||||
const month = String(dt.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(dt.getDate()).padStart(2, '0')
|
||||
const hour = String(dt.getHours()).padStart(2, '0')
|
||||
const minute = String(dt.getMinutes()).padStart(2, '0')
|
||||
return `${month}/${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
private getRecordField(record: Record<string, any> | undefined | null, keys: string[]): any {
|
||||
if (!record) return undefined
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(record, key) && record[key] !== undefined && record[key] !== null) {
|
||||
return record[key]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private coerceNumber(raw: any): number {
|
||||
if (raw === undefined || raw === null || raw === '') return NaN
|
||||
if (typeof raw === 'number') return raw
|
||||
if (typeof raw === 'bigint') return Number(raw)
|
||||
if (Buffer.isBuffer(raw)) return parseInt(raw.toString('utf-8'), 10)
|
||||
if (raw instanceof Uint8Array) return parseInt(Buffer.from(raw).toString('utf-8'), 10)
|
||||
const parsed = parseInt(String(raw), 10)
|
||||
return Number.isFinite(parsed) ? parsed : NaN
|
||||
}
|
||||
|
||||
private coerceString(raw: any): string {
|
||||
if (raw === undefined || raw === null) return ''
|
||||
if (typeof raw === 'string') return raw
|
||||
if (Buffer.isBuffer(raw)) return this.decodeBinaryContent(raw)
|
||||
if (raw instanceof Uint8Array) return this.decodeBinaryContent(Buffer.from(raw))
|
||||
return String(raw)
|
||||
}
|
||||
|
||||
private coerceBoolean(raw: any): boolean | undefined {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
if (typeof raw === 'boolean') return raw
|
||||
if (typeof raw === 'number') return raw !== 0
|
||||
|
||||
const normalized = String(raw).trim().toLowerCase()
|
||||
if (!normalized) return undefined
|
||||
|
||||
if (['1', 'true', 'yes', 'me', 'self', 'mine', 'sent', 'out', 'outgoing'].includes(normalized)) return true
|
||||
if (['0', 'false', 'no', 'friend', 'peer', 'other', 'recv', 'received', 'in', 'incoming'].includes(normalized)) return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
private normalizeEmojiMd5(raw: string): string | undefined {
|
||||
if (!raw) return undefined
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return undefined
|
||||
const match = /([a-fA-F0-9]{16,64})/.exec(trimmed)
|
||||
return match ? match[1].toLowerCase() : undefined
|
||||
}
|
||||
|
||||
private normalizeEmojiUrl(raw: string): string | undefined {
|
||||
if (!raw) return undefined
|
||||
let url = raw.trim().replace(/&/g, '&')
|
||||
if (!url) return undefined
|
||||
try {
|
||||
if (url.includes('%')) {
|
||||
url = decodeURIComponent(url)
|
||||
}
|
||||
} catch { }
|
||||
return url || undefined
|
||||
}
|
||||
|
||||
private extractEmojiUrl(content: string | undefined): string | undefined {
|
||||
if (!content) return undefined
|
||||
const direct = this.normalizeEmojiUrl(content)
|
||||
if (direct && /^https?:\/\//i.test(direct)) return direct
|
||||
|
||||
const attrMatch = /(?:cdnurl|thumburl)\s*=\s*['"]([^'"]+)['"]/i.exec(content)
|
||||
|| /(?:cdnurl|thumburl)\s*=\s*([^'"\s>]+)/i.exec(content)
|
||||
if (attrMatch) return this.normalizeEmojiUrl(attrMatch[1])
|
||||
|
||||
const tagMatch = /<(?:cdnurl|thumburl)>([^<]+)<\/(?:cdnurl|thumburl)>/i.exec(content)
|
||||
|| /(?:cdnurl|thumburl)[^>]*>([^<]+)/i.exec(content)
|
||||
return this.normalizeEmojiUrl(tagMatch?.[1] || '')
|
||||
}
|
||||
|
||||
private extractEmojiMd5(content: string | undefined): string | undefined {
|
||||
if (!content) return undefined
|
||||
const direct = this.normalizeEmojiMd5(content)
|
||||
if (direct && direct.length >= 24) return direct
|
||||
|
||||
const match = /md5\s*=\s*['"]([a-fA-F0-9]{16,64})['"]/i.exec(content)
|
||||
|| /md5\s*=\s*([a-fA-F0-9]{16,64})/i.exec(content)
|
||||
|| /<md5>([a-fA-F0-9]{16,64})<\/md5>/i.exec(content)
|
||||
return this.normalizeEmojiMd5(match?.[1] || '')
|
||||
}
|
||||
|
||||
private resolveEmojiOwner(item: any, content: string): boolean | undefined {
|
||||
const sentFlag = this.coerceBoolean(this.getRecordField(item, [
|
||||
'isMe',
|
||||
'is_me',
|
||||
'isSent',
|
||||
'is_sent',
|
||||
'isSend',
|
||||
'is_send',
|
||||
'fromMe',
|
||||
'from_me'
|
||||
]))
|
||||
if (sentFlag !== undefined) return sentFlag
|
||||
|
||||
const sideRaw = this.coerceString(this.getRecordField(item, ['side', 'sender', 'from', 'owner', 'role', 'direction'])).trim().toLowerCase()
|
||||
if (sideRaw) {
|
||||
if (['me', 'self', 'mine', 'out', 'outgoing', 'sent'].includes(sideRaw)) return true
|
||||
if (['friend', 'peer', 'other', 'in', 'incoming', 'received', 'recv'].includes(sideRaw)) return false
|
||||
}
|
||||
|
||||
const prefixMatch = /^\s*([01])\s*:\s*/.exec(content)
|
||||
if (prefixMatch) return prefixMatch[1] === '1'
|
||||
return undefined
|
||||
}
|
||||
|
||||
private stripEmojiOwnerPrefix(content: string): string {
|
||||
if (!content) return ''
|
||||
return content.replace(/^\s*[01]\s*:\s*/, '')
|
||||
}
|
||||
|
||||
private parseEmojiCandidate(item: any): { isMe?: boolean; md5?: string; url?: string; count: number } {
|
||||
const rawContent = this.coerceString(this.getRecordField(item, [
|
||||
'content',
|
||||
'xml',
|
||||
'message_content',
|
||||
'messageContent',
|
||||
'msg',
|
||||
'payload',
|
||||
'raw'
|
||||
]))
|
||||
const content = this.stripEmojiOwnerPrefix(rawContent)
|
||||
|
||||
const countRaw = this.getRecordField(item, ['count', 'cnt', 'times', 'total', 'num'])
|
||||
const parsedCount = this.coerceNumber(countRaw)
|
||||
const count = Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0
|
||||
|
||||
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(item, [
|
||||
'md5',
|
||||
'emojiMd5',
|
||||
'emoji_md5',
|
||||
'emd5'
|
||||
])))
|
||||
const md5 = directMd5 || this.extractEmojiMd5(content)
|
||||
|
||||
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(item, [
|
||||
'cdnUrl',
|
||||
'cdnurl',
|
||||
'emojiUrl',
|
||||
'emoji_url',
|
||||
'url',
|
||||
'thumbUrl',
|
||||
'thumburl'
|
||||
])))
|
||||
const url = directUrl || this.extractEmojiUrl(content)
|
||||
|
||||
return {
|
||||
isMe: this.resolveEmojiOwner(item, rawContent),
|
||||
md5,
|
||||
url,
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
private getRowInt(row: Record<string, any>, keys: string[], fallback = 0): number {
|
||||
const raw = this.getRecordField(row, keys)
|
||||
const parsed = this.coerceNumber(raw)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
private decodeRowMessageContent(row: Record<string, any>): string {
|
||||
const messageContent = this.getRecordField(row, [
|
||||
'message_content',
|
||||
'messageContent',
|
||||
'content',
|
||||
'msg_content',
|
||||
'msgContent',
|
||||
'WCDB_CT_message_content',
|
||||
'WCDB_CT_messageContent'
|
||||
])
|
||||
const compressContent = this.getRecordField(row, [
|
||||
'compress_content',
|
||||
'compressContent',
|
||||
'compressed_content',
|
||||
'WCDB_CT_compress_content',
|
||||
'WCDB_CT_compressContent'
|
||||
])
|
||||
return this.decodeMessageContent(messageContent, compressContent)
|
||||
}
|
||||
|
||||
private async scanEmojiTopFallback(
|
||||
sessionId: string,
|
||||
beginTimestamp: number,
|
||||
endTimestamp: number,
|
||||
rawWxid: string,
|
||||
cleanedWxid: string
|
||||
): Promise<{ my?: { md5: string; url?: string; count: number }; friend?: { md5: string; url?: string; count: number } }> {
|
||||
const cursorResult = await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp)
|
||||
if (!cursorResult.success || !cursorResult.cursor) return {}
|
||||
|
||||
const tallyMap = new Map<string, { isMe: boolean; md5: string; url?: string; count: number }>()
|
||||
try {
|
||||
let hasMore = true
|
||||
while (hasMore) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
||||
if (!batch.success || !Array.isArray(batch.rows)) break
|
||||
|
||||
for (const row of batch.rows) {
|
||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
|
||||
if (localType !== 47) continue
|
||||
|
||||
const rawContent = this.decodeRowMessageContent(row)
|
||||
const content = this.stripEmojiOwnerPrefix(rawContent)
|
||||
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5'])))
|
||||
const md5 = directMd5 || this.extractEmojiMd5(content)
|
||||
if (!md5) continue
|
||||
|
||||
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, [
|
||||
'emoji_cdn_url',
|
||||
'emojiCdnUrl',
|
||||
'cdnurl',
|
||||
'cdn_url',
|
||||
'emoji_url',
|
||||
'emojiUrl',
|
||||
'url',
|
||||
'thumburl',
|
||||
'thumb_url'
|
||||
])))
|
||||
const url = directUrl || this.extractEmojiUrl(content)
|
||||
const isMe = this.resolveIsSent(row, rawWxid, cleanedWxid)
|
||||
const mapKey = `${isMe ? '1' : '0'}:${md5}`
|
||||
const existing = tallyMap.get(mapKey)
|
||||
if (existing) {
|
||||
existing.count += 1
|
||||
if (!existing.url && url) existing.url = url
|
||||
} else {
|
||||
tallyMap.set(mapKey, { isMe, md5, url, count: 1 })
|
||||
}
|
||||
}
|
||||
hasMore = batch.hasMore === true
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
||||
}
|
||||
|
||||
let myTop: { md5: string; url?: string; count: number } | undefined
|
||||
let friendTop: { md5: string; url?: string; count: number } | undefined
|
||||
for (const entry of tallyMap.values()) {
|
||||
if (entry.isMe) {
|
||||
if (!myTop || entry.count > myTop.count) {
|
||||
myTop = { md5: entry.md5, url: entry.url, count: entry.count }
|
||||
}
|
||||
} else if (!friendTop || entry.count > friendTop.count) {
|
||||
friendTop = { md5: entry.md5, url: entry.url, count: entry.count }
|
||||
}
|
||||
}
|
||||
|
||||
return { my: myTop, friend: friendTop }
|
||||
}
|
||||
|
||||
private async getDisplayName(username: string, fallback: string): Promise<string> {
|
||||
const result = await wcdbService.getDisplayNames([username])
|
||||
if (result.success && result.map) {
|
||||
return result.map[username] || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private resolveIsSent(row: any, rawWxid?: string, cleanedWxid?: string): boolean {
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send
|
||||
if (isSendRaw !== undefined && isSendRaw !== null) {
|
||||
return parseInt(isSendRaw, 10) === 1
|
||||
}
|
||||
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
|
||||
if (!sender) return false
|
||||
const rawLower = rawWxid ? rawWxid.toLowerCase() : ''
|
||||
const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : ''
|
||||
return !!(
|
||||
sender === rawLower ||
|
||||
sender === cleanedLower ||
|
||||
(rawLower && rawLower.startsWith(sender + '_')) ||
|
||||
(cleanedLower && cleanedLower.startsWith(sender + '_'))
|
||||
)
|
||||
}
|
||||
|
||||
private async getFirstMessages(
|
||||
sessionId: string,
|
||||
limit: number,
|
||||
beginTimestamp: number,
|
||||
endTimestamp: number
|
||||
): Promise<any[]> {
|
||||
const safeBegin = Math.max(0, beginTimestamp || 0)
|
||||
const safeEnd = endTimestamp && endTimestamp > 0 ? endTimestamp : Math.floor(Date.now() / 1000)
|
||||
const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, safeBegin, safeEnd)
|
||||
if (!cursorResult.success || !cursorResult.cursor) return []
|
||||
try {
|
||||
const rows: any[] = []
|
||||
let hasMore = true
|
||||
while (hasMore && rows.length < limit) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
||||
if (!batch.success || !batch.rows) break
|
||||
for (const row of batch.rows) {
|
||||
rows.push(row)
|
||||
if (rows.length >= limit) break
|
||||
}
|
||||
hasMore = batch.hasMore === true
|
||||
}
|
||||
return rows.slice(0, limit)
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
async generateReportWithConfig(params: {
|
||||
year: number
|
||||
friendUsername: string
|
||||
dbPath: string
|
||||
decryptKey: string
|
||||
wxid: string
|
||||
excludeWords?: string[]
|
||||
onProgress?: (status: string, progress: number) => void
|
||||
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
|
||||
try {
|
||||
const { year, friendUsername, dbPath, decryptKey, wxid, excludeWords, onProgress } = params
|
||||
this.reportProgress('正在连接数据库...', 5, onProgress)
|
||||
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
|
||||
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
|
||||
|
||||
const cleanedWxid = conn.cleanedWxid
|
||||
const rawWxid = conn.rawWxid
|
||||
|
||||
const reportYear = year <= 0 ? 0 : year
|
||||
const isAllTime = reportYear === 0
|
||||
const startTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000)
|
||||
const endTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000)
|
||||
|
||||
this.reportProgress('加载联系人信息...', 10, onProgress)
|
||||
const friendName = await this.getDisplayName(friendUsername, friendUsername)
|
||||
let myName = await this.getDisplayName(rawWxid, rawWxid)
|
||||
if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) {
|
||||
myName = await this.getDisplayName(cleanedWxid, rawWxid)
|
||||
}
|
||||
const avatarCandidates = Array.from(new Set([
|
||||
friendUsername,
|
||||
rawWxid,
|
||||
cleanedWxid
|
||||
].filter(Boolean) as string[]))
|
||||
let selfAvatarUrl: string | undefined
|
||||
let friendAvatarUrl: string | undefined
|
||||
const avatarResult = await wcdbService.getAvatarUrls(avatarCandidates)
|
||||
if (avatarResult.success && avatarResult.map) {
|
||||
selfAvatarUrl = avatarResult.map[rawWxid] || avatarResult.map[cleanedWxid]
|
||||
friendAvatarUrl = avatarResult.map[friendUsername]
|
||||
}
|
||||
|
||||
this.reportProgress('获取首条聊天记录...', 15, onProgress)
|
||||
const firstRows = await this.getFirstMessages(friendUsername, 10, 0, 0)
|
||||
let firstChat: DualReportFirstChat | null = null
|
||||
if (firstRows.length > 0) {
|
||||
const row = firstRows[0]
|
||||
const createTime = parseInt(row.create_time || '0', 10) * 1000
|
||||
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||
let emojiMd5: string | undefined
|
||||
let emojiCdnUrl: string | undefined
|
||||
if (localType === 47) {
|
||||
const stripped = this.stripEmojiOwnerPrefix(rawContent)
|
||||
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||
}
|
||||
|
||||
firstChat = {
|
||||
createTime,
|
||||
createTimeStr: this.formatDateTime(createTime),
|
||||
content: String(rawContent || ''),
|
||||
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||
senderUsername: row.sender_username || row.sender,
|
||||
localType,
|
||||
emojiMd5,
|
||||
emojiCdnUrl
|
||||
}
|
||||
}
|
||||
const firstChatMessages: DualReportMessage[] = firstRows.map((row) => {
|
||||
const msgTime = parseInt(row.create_time || '0', 10) * 1000
|
||||
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||
let emojiMd5: string | undefined
|
||||
let emojiCdnUrl: string | undefined
|
||||
if (localType === 47) {
|
||||
const stripped = this.stripEmojiOwnerPrefix(rawContent)
|
||||
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||
}
|
||||
|
||||
return {
|
||||
content: String(rawContent || ''),
|
||||
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||
createTime: msgTime,
|
||||
createTimeStr: this.formatDateTime(msgTime),
|
||||
localType,
|
||||
emojiMd5,
|
||||
emojiCdnUrl
|
||||
}
|
||||
})
|
||||
|
||||
let yearFirstChat: DualReportData['yearFirstChat'] = null
|
||||
if (!isAllTime) {
|
||||
this.reportProgress('获取今年首次聊天...', 20, onProgress)
|
||||
const firstYearRows = await this.getFirstMessages(friendUsername, 10, startTime, endTime)
|
||||
if (firstYearRows.length > 0) {
|
||||
const firstRow = firstYearRows[0]
|
||||
const createTime = parseInt(firstRow.create_time || '0', 10) * 1000
|
||||
const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => {
|
||||
const msgTime = parseInt(row.create_time || '0', 10) * 1000
|
||||
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||
let emojiMd5: string | undefined
|
||||
let emojiCdnUrl: string | undefined
|
||||
if (localType === 47) {
|
||||
const stripped = this.stripEmojiOwnerPrefix(rawContent)
|
||||
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||
}
|
||||
|
||||
return {
|
||||
content: String(rawContent || ''),
|
||||
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||
createTime: msgTime,
|
||||
createTimeStr: this.formatDateTime(msgTime),
|
||||
localType,
|
||||
emojiMd5,
|
||||
emojiCdnUrl
|
||||
}
|
||||
})
|
||||
const firstRowYear = firstYearRows[0]
|
||||
const rawContentYear = this.decodeMessageContent(firstRowYear.message_content, firstRowYear.compress_content)
|
||||
const localTypeYear = this.getRowInt(firstRowYear, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||
let emojiMd5Year: string | undefined
|
||||
let emojiCdnUrlYear: string | undefined
|
||||
if (localTypeYear === 47) {
|
||||
const stripped = this.stripEmojiOwnerPrefix(rawContentYear)
|
||||
emojiMd5Year = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(firstRowYear, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||
emojiCdnUrlYear = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(firstRowYear, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||
}
|
||||
|
||||
yearFirstChat = {
|
||||
createTime,
|
||||
createTimeStr: this.formatDateTime(createTime),
|
||||
content: String(rawContentYear || ''),
|
||||
isSentByMe: this.resolveIsSent(firstRowYear, rawWxid, cleanedWxid),
|
||||
friendName,
|
||||
firstThreeMessages,
|
||||
localType: localTypeYear,
|
||||
emojiMd5: emojiMd5Year,
|
||||
emojiCdnUrl: emojiCdnUrlYear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.reportProgress('统计聊天数据...', 30, onProgress)
|
||||
|
||||
const statsResult = await wcdbService.getDualReportStats(friendUsername, startTime, endTime)
|
||||
if (!statsResult.success || !statsResult.data) {
|
||||
return { success: false, error: statsResult.error || '获取双人报告统计失败' }
|
||||
}
|
||||
|
||||
const cppData = statsResult.data
|
||||
const counts = cppData.counts || {}
|
||||
|
||||
const stats: DualReportStats = {
|
||||
totalMessages: counts.total || 0,
|
||||
totalWords: counts.words || 0,
|
||||
imageCount: counts.image || 0,
|
||||
voiceCount: counts.voice || 0,
|
||||
emojiCount: counts.emoji || 0
|
||||
}
|
||||
|
||||
// Process Emojis to find top for me and friend
|
||||
let myTopEmojiMd5: string | undefined
|
||||
let myTopEmojiUrl: string | undefined
|
||||
let myTopCount = -1
|
||||
|
||||
let friendTopEmojiMd5: string | undefined
|
||||
let friendTopEmojiUrl: string | undefined
|
||||
let friendTopCount = -1
|
||||
|
||||
if (cppData.emojis && Array.isArray(cppData.emojis)) {
|
||||
for (const item of cppData.emojis) {
|
||||
const candidate = this.parseEmojiCandidate(item)
|
||||
if (!candidate.md5 || candidate.isMe === undefined || candidate.count <= 0) continue
|
||||
|
||||
if (candidate.isMe) {
|
||||
if (candidate.count > myTopCount) {
|
||||
myTopCount = candidate.count
|
||||
myTopEmojiMd5 = candidate.md5
|
||||
myTopEmojiUrl = candidate.url
|
||||
}
|
||||
} else if (candidate.count > friendTopCount) {
|
||||
friendTopCount = candidate.count
|
||||
friendTopEmojiMd5 = candidate.md5
|
||||
friendTopEmojiUrl = candidate.url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const needsEmojiFallback = stats.emojiCount > 0 && (!myTopEmojiMd5 || !friendTopEmojiMd5)
|
||||
if (needsEmojiFallback) {
|
||||
const fallback = await this.scanEmojiTopFallback(friendUsername, startTime, endTime, rawWxid, cleanedWxid)
|
||||
|
||||
if (!myTopEmojiMd5 && fallback.my?.md5) {
|
||||
myTopEmojiMd5 = fallback.my.md5
|
||||
myTopEmojiUrl = myTopEmojiUrl || fallback.my.url
|
||||
myTopCount = fallback.my.count
|
||||
}
|
||||
if (!friendTopEmojiMd5 && fallback.friend?.md5) {
|
||||
friendTopEmojiMd5 = fallback.friend.md5
|
||||
friendTopEmojiUrl = friendTopEmojiUrl || fallback.friend.url
|
||||
friendTopCount = fallback.friend.count
|
||||
}
|
||||
}
|
||||
|
||||
const [myEmojiUrlResult, friendEmojiUrlResult] = await Promise.all([
|
||||
myTopEmojiMd5 && !myTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, myTopEmojiMd5) : Promise.resolve(null),
|
||||
friendTopEmojiMd5 && !friendTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, friendTopEmojiMd5) : Promise.resolve(null)
|
||||
])
|
||||
if (myEmojiUrlResult?.success && myEmojiUrlResult.url) myTopEmojiUrl = myEmojiUrlResult.url
|
||||
if (friendEmojiUrlResult?.success && friendEmojiUrlResult.url) friendTopEmojiUrl = friendEmojiUrlResult.url
|
||||
|
||||
stats.myTopEmojiMd5 = myTopEmojiMd5
|
||||
stats.myTopEmojiUrl = myTopEmojiUrl
|
||||
stats.friendTopEmojiMd5 = friendTopEmojiMd5
|
||||
stats.friendTopEmojiUrl = friendTopEmojiUrl
|
||||
if (myTopCount >= 0) stats.myTopEmojiCount = myTopCount
|
||||
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
|
||||
|
||||
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
|
||||
|
||||
const excludeSet = new Set(excludeWords || [])
|
||||
|
||||
const filterPhrases = (list: any[]) => {
|
||||
return (list || []).filter((p: any) => !excludeSet.has(p.phrase))
|
||||
}
|
||||
|
||||
const cleanPhrases = filterPhrases(cppData.phrases)
|
||||
const cleanMyPhrases = filterPhrases(cppData.myPhrases)
|
||||
const cleanFriendPhrases = filterPhrases(cppData.friendPhrases)
|
||||
|
||||
const topPhrases = cleanPhrases.map((p: any) => ({
|
||||
phrase: p.phrase,
|
||||
count: p.count
|
||||
}))
|
||||
|
||||
// 计算专属词汇:一方频繁使用而另一方很少使用的词
|
||||
const myPhraseMap = new Map<string, number>()
|
||||
const friendPhraseMap = new Map<string, number>()
|
||||
for (const p of cleanMyPhrases) {
|
||||
myPhraseMap.set(p.phrase, p.count)
|
||||
}
|
||||
for (const p of cleanFriendPhrases) {
|
||||
friendPhraseMap.set(p.phrase, p.count)
|
||||
}
|
||||
|
||||
// 专属词汇:该方使用占比 >= 75% 且至少出现 2 次
|
||||
const myExclusivePhrases: Array<{ phrase: string; count: number }> = []
|
||||
const friendExclusivePhrases: Array<{ phrase: string; count: number }> = []
|
||||
|
||||
for (const [phrase, myCount] of myPhraseMap) {
|
||||
const friendCount = friendPhraseMap.get(phrase) || 0
|
||||
const total = myCount + friendCount
|
||||
if (myCount >= 2 && total > 0 && myCount / total >= 0.75) {
|
||||
myExclusivePhrases.push({ phrase, count: myCount })
|
||||
}
|
||||
}
|
||||
for (const [phrase, friendCount] of friendPhraseMap) {
|
||||
const myCount = myPhraseMap.get(phrase) || 0
|
||||
const total = myCount + friendCount
|
||||
if (friendCount >= 2 && total > 0 && friendCount / total >= 0.75) {
|
||||
friendExclusivePhrases.push({ phrase, count: friendCount })
|
||||
}
|
||||
}
|
||||
|
||||
// 按频率排序,取前 20
|
||||
myExclusivePhrases.sort((a, b) => b.count - a.count)
|
||||
friendExclusivePhrases.sort((a, b) => b.count - a.count)
|
||||
if (myExclusivePhrases.length > 20) myExclusivePhrases.length = 20
|
||||
if (friendExclusivePhrases.length > 20) friendExclusivePhrases.length = 20
|
||||
|
||||
const reportData: DualReportData = {
|
||||
year: reportYear,
|
||||
selfName: myName,
|
||||
selfAvatarUrl,
|
||||
friendUsername,
|
||||
friendName,
|
||||
friendAvatarUrl,
|
||||
firstChat,
|
||||
firstChatMessages,
|
||||
yearFirstChat,
|
||||
stats,
|
||||
topPhrases,
|
||||
myExclusivePhrases,
|
||||
friendExclusivePhrases,
|
||||
heatmap: cppData.heatmap,
|
||||
initiative: cppData.initiative,
|
||||
response: cppData.response,
|
||||
monthly: cppData.monthly,
|
||||
streak: cppData.streak
|
||||
} as any
|
||||
|
||||
this.reportProgress('双人报告生成完成', 100, onProgress)
|
||||
return { success: true, data: reportData }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dualReportService = new DualReportService()
|
||||
304
electron/services/exportHtml.css
Normal file
304
electron/services/exportHtml.css
Normal file
@@ -0,0 +1,304 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7fb;
|
||||
--card: #ffffff;
|
||||
--text: #1f2a37;
|
||||
--muted: #6b7280;
|
||||
--accent: #4f46e5;
|
||||
--sent: #dbeafe;
|
||||
--received: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
--radius: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 20px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
padding: 12px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
display: inline;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.meta span {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls input,
|
||||
.controls button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.controls input[type="search"] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.controls input[type="datetime-local"] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.controls button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message.sent .message-row {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: #eef2ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: min(70%, 720px);
|
||||
background: var(--received);
|
||||
border-radius: 18px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.message.sent .bubble {
|
||||
background: var(--sent);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-link-card {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.message-link-card:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.inline-emoji {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
vertical-align: text-bottom;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.message-media {
|
||||
border-radius: 14px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.previewable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.message-media.image,
|
||||
.message-media.emoji {
|
||||
max-height: 260px;
|
||||
object-fit: contain;
|
||||
background: #f1f5f9;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.message-media.emoji {
|
||||
max-height: 160px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.message-media.video {
|
||||
max-height: 360px;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.message-media.audio {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.image-preview.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: min(90vw, 1200px);
|
||||
max-height: 90vh;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||
background: #0f172a;
|
||||
transition: transform 0.1s ease;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: 18px;
|
||||
transition: outline-color 0.3s;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Scroll Container */
|
||||
.scroll-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.load-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
306
electron/services/exportHtmlStyles.ts
Normal file
306
electron/services/exportHtmlStyles.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
export const EXPORT_HTML_STYLES = `:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7fb;
|
||||
--card: #ffffff;
|
||||
--text: #1f2a37;
|
||||
--muted: #6b7280;
|
||||
--accent: #4f46e5;
|
||||
--sent: #dbeafe;
|
||||
--received: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
--radius: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 20px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
padding: 12px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
display: inline;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.meta span {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls input,
|
||||
.controls button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.controls input[type="search"] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.controls input[type="datetime-local"] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.controls button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message.sent .message-row {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: #eef2ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: min(70%, 720px);
|
||||
background: var(--received);
|
||||
border-radius: 18px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.message.sent .bubble {
|
||||
background: var(--sent);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-link-card {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.message-link-card:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.inline-emoji {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
vertical-align: text-bottom;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.message-media {
|
||||
border-radius: 14px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.previewable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.message-media.image,
|
||||
.message-media.emoji {
|
||||
max-height: 260px;
|
||||
object-fit: contain;
|
||||
background: #f1f5f9;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.message-media.emoji {
|
||||
max-height: 160px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.message-media.video {
|
||||
max-height: 360px;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.message-media.audio {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.image-preview.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: min(90vw, 1200px);
|
||||
max-height: 90vh;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||
background: #0f172a;
|
||||
transition: transform 0.1s ease;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: 18px;
|
||||
transition: outline-color 0.3s;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Scroll Container */
|
||||
.scroll-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.load-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,10 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { chatService } from './chatService'
|
||||
import type { Message } from './chatService'
|
||||
|
||||
export interface GroupChatInfo {
|
||||
username: string
|
||||
@@ -12,6 +17,10 @@ export interface GroupMember {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
nickname?: string
|
||||
alias?: string
|
||||
remark?: string
|
||||
groupNickname?: string
|
||||
}
|
||||
|
||||
export interface GroupMessageRank {
|
||||
@@ -41,16 +50,45 @@ class GroupAnalyticsService {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
// 并发控制:限制同时执行的 Promise 数量
|
||||
private async parallelLimit<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length)
|
||||
let currentIndex = 0
|
||||
|
||||
async function runNext(): Promise<void> {
|
||||
while (currentIndex < items.length) {
|
||||
const index = currentIndex++
|
||||
results[index] = await fn(items[index], index)
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array(Math.min(limit, items.length))
|
||||
.fill(null)
|
||||
.map(() => runNext())
|
||||
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
|
||||
private cleanAccountDirName(name: string): string {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return trimmed
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
if (match) return match[1]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
@@ -65,6 +103,329 @@ class GroupAnalyticsService {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 DLL 获取群成员的群昵称
|
||||
*/
|
||||
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||
try {
|
||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||
return new Map<string, string>()
|
||||
}
|
||||
|
||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
||||
if (!extBuffer) return new Map<string, string>()
|
||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom error:', e)
|
||||
return new Map<string, string>()
|
||||
}
|
||||
}
|
||||
|
||||
private looksLikeHex(s: string): boolean {
|
||||
if (s.length % 2 !== 0) return false
|
||||
return /^[0-9a-fA-F]+$/.test(s)
|
||||
}
|
||||
|
||||
private looksLikeBase64(s: string): boolean {
|
||||
if (s.length % 4 !== 0) return false
|
||||
return /^[A-Za-z0-9+/=]+$/.test(s)
|
||||
}
|
||||
|
||||
private decodeExtBuffer(value: unknown): Buffer | null {
|
||||
if (!value) return null
|
||||
if (Buffer.isBuffer(value)) return value
|
||||
if (value instanceof Uint8Array) return Buffer.from(value)
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const raw = value.trim()
|
||||
if (!raw) return null
|
||||
|
||||
if (this.looksLikeHex(raw)) {
|
||||
try { return Buffer.from(raw, 'hex') } catch { }
|
||||
}
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
try { return Buffer.from(raw, 'base64') } catch { }
|
||||
}
|
||||
|
||||
try { return Buffer.from(raw, 'hex') } catch { }
|
||||
try { return Buffer.from(raw, 'base64') } catch { }
|
||||
try { return Buffer.from(raw, 'utf8') } catch { }
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private readVarint(buffer: Buffer, offset: number, limit: number = buffer.length): { value: number; next: number } | null {
|
||||
let value = 0
|
||||
let shift = 0
|
||||
let pos = offset
|
||||
while (pos < limit && shift <= 53) {
|
||||
const byte = buffer[pos]
|
||||
value += (byte & 0x7f) * Math.pow(2, shift)
|
||||
pos += 1
|
||||
if ((byte & 0x80) === 0) return { value, next: pos }
|
||||
shift += 7
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private isLikelyMemberId(value: string): boolean {
|
||||
const id = String(value || '').trim()
|
||||
if (!id) return false
|
||||
if (id.includes('@chatroom')) return false
|
||||
if (id.length < 4 || id.length > 80) return false
|
||||
return /^[A-Za-z][A-Za-z0-9_.@-]*$/.test(id)
|
||||
}
|
||||
|
||||
private isLikelyNickname(value: string): boolean {
|
||||
const cleaned = this.normalizeGroupNickname(value)
|
||||
if (!cleaned) return false
|
||||
if (/^wxid_[a-z0-9_]+$/i.test(cleaned)) return false
|
||||
if (cleaned.includes('@chatroom')) return false
|
||||
if (!/[\u4E00-\u9FFF\u3400-\u4DBF\w]/.test(cleaned)) return false
|
||||
if (cleaned.length === 1) {
|
||||
const code = cleaned.charCodeAt(0)
|
||||
const isCjk = code >= 0x3400 && code <= 0x9fff
|
||||
if (!isCjk) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private parseGroupNicknamesFromExtBuffer(buffer: Buffer, candidates: string[] = []): Map<string, string> {
|
||||
const nicknameMap = new Map<string, string>()
|
||||
if (!buffer || buffer.length === 0) return nicknameMap
|
||||
|
||||
try {
|
||||
const candidateSet = new Set(this.buildIdCandidates(candidates).map((id) => id.toLowerCase()))
|
||||
|
||||
for (let i = 0; i < buffer.length - 2; i += 1) {
|
||||
if (buffer[i] !== 0x0a) continue
|
||||
|
||||
const idLenInfo = this.readVarint(buffer, i + 1)
|
||||
if (!idLenInfo) continue
|
||||
const idLen = idLenInfo.value
|
||||
if (!Number.isFinite(idLen) || idLen <= 0 || idLen > 96) continue
|
||||
|
||||
const idStart = idLenInfo.next
|
||||
const idEnd = idStart + idLen
|
||||
if (idEnd > buffer.length) continue
|
||||
|
||||
const memberId = buffer.toString('utf8', idStart, idEnd).trim()
|
||||
if (!this.isLikelyMemberId(memberId)) continue
|
||||
|
||||
const memberIdLower = memberId.toLowerCase()
|
||||
if (candidateSet.size > 0 && !candidateSet.has(memberIdLower)) {
|
||||
i = idEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
const cursor = idEnd
|
||||
if (cursor >= buffer.length || buffer[cursor] !== 0x12) {
|
||||
i = idEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
const nickLenInfo = this.readVarint(buffer, cursor + 1)
|
||||
if (!nickLenInfo) {
|
||||
i = idEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
const nickLen = nickLenInfo.value
|
||||
if (!Number.isFinite(nickLen) || nickLen <= 0 || nickLen > 128) {
|
||||
i = idEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
const nickStart = nickLenInfo.next
|
||||
const nickEnd = nickStart + nickLen
|
||||
if (nickEnd > buffer.length) {
|
||||
i = idEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
const rawNick = buffer.toString('utf8', nickStart, nickEnd)
|
||||
const nickname = this.normalizeGroupNickname(rawNick.replace(/[\x00-\x1F\x7F]/g, '').trim())
|
||||
if (!this.isLikelyNickname(nickname)) {
|
||||
i = nickEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!nicknameMap.has(memberId)) nicknameMap.set(memberId, nickname)
|
||||
if (!nicknameMap.has(memberIdLower)) nicknameMap.set(memberIdLower, nickname)
|
||||
i = nickEnd - 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse chat_room.ext_buffer:', e)
|
||||
}
|
||||
|
||||
return nicknameMap
|
||||
}
|
||||
|
||||
private escapeCsvValue(value: string): string {
|
||||
if (value == null) return ''
|
||||
const str = String(value)
|
||||
if (/[",\n\r]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
private normalizeGroupNickname(value: string): string {
|
||||
const trimmed = (value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
if (/^["'@]+$/.test(trimmed)) return ''
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private buildIdCandidates(values: Array<string | undefined | null>): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const rawValue of values) {
|
||||
const raw = String(rawValue || '').trim()
|
||||
if (!raw) continue
|
||||
set.add(raw)
|
||||
const cleaned = this.cleanAccountDirName(raw)
|
||||
if (cleaned && cleaned !== raw) {
|
||||
set.add(cleaned)
|
||||
}
|
||||
}
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
||||
const idCandidates = this.buildIdCandidates(candidates)
|
||||
if (idCandidates.length === 0) return ''
|
||||
|
||||
for (const id of idCandidates) {
|
||||
const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '')
|
||||
if (exact) return exact
|
||||
}
|
||||
|
||||
for (const id of idCandidates) {
|
||||
const lower = id.toLowerCase()
|
||||
let found = ''
|
||||
let matched = 0
|
||||
for (const [key, value] of groupNicknames.entries()) {
|
||||
if (String(key || '').toLowerCase() !== lower) continue
|
||||
const normalized = this.normalizeGroupNickname(value || '')
|
||||
if (!normalized) continue
|
||||
found = normalized
|
||||
matched += 1
|
||||
if (matched > 1) return ''
|
||||
}
|
||||
if (matched === 1 && found) return found
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
private sanitizeWorksheetName(name: string): string {
|
||||
const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim()
|
||||
const limited = cleaned.slice(0, 31)
|
||||
return limited || 'Sheet1'
|
||||
}
|
||||
|
||||
private formatDateTime(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
const year = date.getFullYear()
|
||||
const month = pad(date.getMonth() + 1)
|
||||
const day = pad(date.getDate())
|
||||
const hour = pad(date.getHours())
|
||||
const minute = pad(date.getMinutes())
|
||||
const second = pad(date.getSeconds())
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
||||
}
|
||||
|
||||
private formatUnixTime(createTime: number): string {
|
||||
if (!Number.isFinite(createTime) || createTime <= 0) return ''
|
||||
const milliseconds = createTime > 1e12 ? createTime : createTime * 1000
|
||||
const date = new Date(milliseconds)
|
||||
if (Number.isNaN(date.getTime())) return String(createTime)
|
||||
return this.formatDateTime(date)
|
||||
}
|
||||
|
||||
private getSimpleMessageTypeName(localType: number): string {
|
||||
const typeMap: Record<number, string> = {
|
||||
1: '文本',
|
||||
3: '图片',
|
||||
34: '语音',
|
||||
42: '名片',
|
||||
43: '视频',
|
||||
47: '表情',
|
||||
48: '位置',
|
||||
49: '链接/文件',
|
||||
50: '通话',
|
||||
10000: '系统',
|
||||
266287972401: '拍一拍',
|
||||
8594229559345: '红包',
|
||||
8589934592049: '转账'
|
||||
}
|
||||
return typeMap[localType] || `类型(${localType})`
|
||||
}
|
||||
|
||||
private normalizeIdCandidates(values: Array<string | null | undefined>): string[] {
|
||||
return this.buildIdCandidates(values).map(value => value.toLowerCase())
|
||||
}
|
||||
|
||||
private isSameAccountIdentity(left: string | null | undefined, right: string | null | undefined): boolean {
|
||||
const leftCandidates = this.normalizeIdCandidates([left])
|
||||
const rightCandidates = this.normalizeIdCandidates([right])
|
||||
if (leftCandidates.length === 0 || rightCandidates.length === 0) return false
|
||||
|
||||
const rightSet = new Set(rightCandidates)
|
||||
for (const leftCandidate of leftCandidates) {
|
||||
if (rightSet.has(leftCandidate)) return true
|
||||
for (const rightCandidate of rightCandidates) {
|
||||
if (leftCandidate.startsWith(`${rightCandidate}_`) || rightCandidate.startsWith(`${leftCandidate}_`)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private resolveExportMessageContent(message: Message): string {
|
||||
const parsed = String(message.parsedContent || '').trim()
|
||||
if (parsed) return parsed
|
||||
const raw = String(message.rawContent || '').trim()
|
||||
if (raw) return raw
|
||||
return ''
|
||||
}
|
||||
|
||||
private async collectMessagesByMember(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
|
||||
const batchSize = 500
|
||||
const matchedMessages: Message[] = []
|
||||
let offset = 0
|
||||
|
||||
while (true) {
|
||||
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true)
|
||||
if (!batch.success || !batch.messages) {
|
||||
return { success: false, error: batch.error || '获取群消息失败' }
|
||||
}
|
||||
|
||||
for (const message of batch.messages) {
|
||||
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) {
|
||||
matchedMessages.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchedCount = batch.messages.length
|
||||
if (fetchedCount <= 0 || !batch.hasMore) break
|
||||
offset += fetchedCount
|
||||
}
|
||||
|
||||
return { success: true, data: matchedMessages }
|
||||
}
|
||||
|
||||
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
@@ -80,23 +441,38 @@ class GroupAnalyticsService {
|
||||
.map((row) => row.username || row.user_name || row.userName || '')
|
||||
.filter((username) => username.includes('@chatroom'))
|
||||
|
||||
const [displayNames, avatarUrls, memberCounts] = await Promise.all([
|
||||
wcdbService.getDisplayNames(groupIds),
|
||||
wcdbService.getAvatarUrls(groupIds),
|
||||
wcdbService.getGroupMemberCounts(groupIds)
|
||||
const [memberCounts, contactInfo] = await Promise.all([
|
||||
wcdbService.getGroupMemberCounts(groupIds),
|
||||
chatService.enrichSessionsContactInfo(groupIds)
|
||||
])
|
||||
|
||||
let fallbackNames: { success: boolean; map?: Record<string, string> } | null = null
|
||||
let fallbackAvatars: { success: boolean; map?: Record<string, string> } | null = null
|
||||
if (!contactInfo.success || !contactInfo.contacts) {
|
||||
const [displayNames, avatarUrls] = await Promise.all([
|
||||
wcdbService.getDisplayNames(groupIds),
|
||||
wcdbService.getAvatarUrls(groupIds)
|
||||
])
|
||||
fallbackNames = displayNames
|
||||
fallbackAvatars = avatarUrls
|
||||
}
|
||||
|
||||
const groups: GroupChatInfo[] = []
|
||||
for (const groupId of groupIds) {
|
||||
const contact = contactInfo.success && contactInfo.contacts ? contactInfo.contacts[groupId] : undefined
|
||||
const displayName = contact?.displayName ||
|
||||
(fallbackNames && fallbackNames.success && fallbackNames.map ? (fallbackNames.map[groupId] || '') : '') ||
|
||||
groupId
|
||||
const avatarUrl = contact?.avatarUrl ||
|
||||
(fallbackAvatars && fallbackAvatars.success && fallbackAvatars.map ? fallbackAvatars.map[groupId] : undefined)
|
||||
|
||||
groups.push({
|
||||
username: groupId,
|
||||
displayName: displayNames.success && displayNames.map
|
||||
? (displayNames.map[groupId] || groupId)
|
||||
: groupId,
|
||||
displayName,
|
||||
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
|
||||
? memberCounts.map[groupId]
|
||||
: 0,
|
||||
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined
|
||||
avatarUrl
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,15 +493,88 @@ class GroupAnalyticsService {
|
||||
return { success: false, error: result.error || '获取群成员失败' }
|
||||
}
|
||||
|
||||
const members = result.members as { username: string; avatarUrl?: string }[]
|
||||
const usernames = members.map((m) => m.username)
|
||||
const displayNames = await wcdbService.getDisplayNames(usernames)
|
||||
const members = result.members as Array<{
|
||||
username: string
|
||||
avatarUrl?: string
|
||||
originalName?: string
|
||||
}>
|
||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||
|
||||
const data: GroupMember[] = members.map((m) => ({
|
||||
username: m.username,
|
||||
displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username,
|
||||
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||
|
||||
const contactMap = new Map<string, {
|
||||
remark?: string
|
||||
nickName?: string
|
||||
alias?: string
|
||||
username?: string
|
||||
userName?: string
|
||||
encryptUsername?: string
|
||||
encryptUserName?: string
|
||||
}>()
|
||||
const concurrency = 6
|
||||
await this.parallelLimit(usernames, concurrency, async (username) => {
|
||||
const contactResult = await wcdbService.getContact(username)
|
||||
if (contactResult.success && contactResult.contact) {
|
||||
const contact = contactResult.contact as any
|
||||
contactMap.set(username, {
|
||||
remark: contact.remark || '',
|
||||
nickName: contact.nickName || contact.nick_name || '',
|
||||
alias: contact.alias || '',
|
||||
username: contact.username || '',
|
||||
userName: contact.userName || contact.user_name || '',
|
||||
encryptUsername: contact.encryptUsername || contact.encrypt_username || '',
|
||||
encryptUserName: contact.encryptUserName || ''
|
||||
})
|
||||
} else {
|
||||
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
||||
}
|
||||
})
|
||||
|
||||
const displayNames = await displayNamesPromise
|
||||
const nicknameCandidates = this.buildIdCandidates([
|
||||
...members.map((m) => m.username),
|
||||
...members.map((m) => m.originalName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.username),
|
||||
...Array.from(contactMap.values()).map((c) => c?.userName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.encryptUsername),
|
||||
...Array.from(contactMap.values()).map((c) => c?.encryptUserName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.alias)
|
||||
])
|
||||
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||
|
||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||
const data: GroupMember[] = members.map((m) => {
|
||||
const wxid = m.username || ''
|
||||
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||
const contact = contactMap.get(wxid)
|
||||
const nickname = contact?.nickName || ''
|
||||
const remark = contact?.remark || ''
|
||||
const alias = contact?.alias || ''
|
||||
const normalizedWxid = this.cleanAccountDirName(wxid)
|
||||
const lookupCandidates = this.buildIdCandidates([
|
||||
wxid,
|
||||
m.originalName,
|
||||
contact?.username,
|
||||
contact?.userName,
|
||||
contact?.encryptUsername,
|
||||
contact?.encryptUserName,
|
||||
alias
|
||||
])
|
||||
if (normalizedWxid === myWxid) {
|
||||
lookupCandidates.push(myWxid)
|
||||
}
|
||||
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
|
||||
|
||||
return {
|
||||
username: wxid,
|
||||
displayName,
|
||||
nickname,
|
||||
alias,
|
||||
remark,
|
||||
groupNickname,
|
||||
avatarUrl: m.avatarUrl
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
@@ -181,6 +630,8 @@ class GroupAnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
@@ -246,6 +697,394 @@ class GroupAnalyticsService {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async exportGroupMemberMessages(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
outputPath: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success) return { success: false, error: conn.error }
|
||||
|
||||
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||
if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' }
|
||||
|
||||
const beginTimestamp = Number.isFinite(startTime) && typeof startTime === 'number'
|
||||
? Math.max(0, Math.floor(startTime))
|
||||
: 0
|
||||
const endTimestampValue = Number.isFinite(endTime) && typeof endTime === 'number'
|
||||
? Math.max(0, Math.floor(endTime))
|
||||
: 0
|
||||
|
||||
const exportDate = new Date()
|
||||
const exportTime = this.formatDateTime(exportDate)
|
||||
const exportVersion = '0.0.2'
|
||||
const exportGenerator = 'WeFlow'
|
||||
const exportPlatform = 'wechat'
|
||||
|
||||
const groupDisplay = await wcdbService.getDisplayNames([normalizedChatroomId, normalizedMemberUsername])
|
||||
const groupName = groupDisplay.success && groupDisplay.map
|
||||
? (groupDisplay.map[normalizedChatroomId] || normalizedChatroomId)
|
||||
: normalizedChatroomId
|
||||
const defaultMemberDisplayName = groupDisplay.success && groupDisplay.map
|
||||
? (groupDisplay.map[normalizedMemberUsername] || normalizedMemberUsername)
|
||||
: normalizedMemberUsername
|
||||
|
||||
let memberDisplayName = defaultMemberDisplayName
|
||||
let memberAlias = ''
|
||||
let memberRemark = ''
|
||||
let memberGroupNickname = ''
|
||||
const membersResult = await this.getGroupMembers(normalizedChatroomId)
|
||||
if (membersResult.success && membersResult.data) {
|
||||
const matchedMember = membersResult.data.find((item) =>
|
||||
this.isSameAccountIdentity(item.username, normalizedMemberUsername)
|
||||
)
|
||||
if (matchedMember) {
|
||||
memberDisplayName = matchedMember.displayName || defaultMemberDisplayName
|
||||
memberAlias = matchedMember.alias || ''
|
||||
memberRemark = matchedMember.remark || ''
|
||||
memberGroupNickname = matchedMember.groupNickname || ''
|
||||
}
|
||||
}
|
||||
|
||||
const collected = await this.collectMessagesByMember(
|
||||
normalizedChatroomId,
|
||||
normalizedMemberUsername,
|
||||
beginTimestamp,
|
||||
endTimestampValue
|
||||
)
|
||||
if (!collected.success || !collected.data) {
|
||||
return { success: false, error: collected.error || '获取成员消息失败' }
|
||||
}
|
||||
|
||||
const records = collected.data.map((message, index) => ({
|
||||
index: index + 1,
|
||||
time: this.formatUnixTime(message.createTime),
|
||||
sender: message.senderUsername || '',
|
||||
messageType: this.getSimpleMessageTypeName(message.localType),
|
||||
content: this.resolveExportMessageContent(message)
|
||||
}))
|
||||
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||
const ext = path.extname(outputPath).toLowerCase()
|
||||
if (ext === '.csv') {
|
||||
const infoTitleRow = ['会话信息']
|
||||
const infoRow = ['群聊ID', normalizedChatroomId, '', '群聊名称', groupName, '成员wxid', normalizedMemberUsername, '']
|
||||
const memberRow = ['成员显示名', memberDisplayName, '成员备注', memberRemark, '群昵称', memberGroupNickname, '微信号', memberAlias]
|
||||
const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime]
|
||||
const header = ['序号', '时间', '发送者wxid', '消息类型', '内容']
|
||||
|
||||
const csvRows: string[][] = [infoTitleRow, infoRow, memberRow, metaRow, header]
|
||||
for (const record of records) {
|
||||
csvRows.push([String(record.index), record.time, record.sender, record.messageType, record.content])
|
||||
}
|
||||
|
||||
const csvLines = csvRows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(','))
|
||||
const content = '\ufeff' + csvLines.join('\n')
|
||||
fs.writeFileSync(outputPath, content, 'utf8')
|
||||
} else {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const worksheet = workbook.addWorksheet(this.sanitizeWorksheetName('成员消息记录'))
|
||||
|
||||
worksheet.getCell(1, 1).value = '会话信息'
|
||||
worksheet.getCell(1, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getRow(1).height = 24
|
||||
|
||||
worksheet.getCell(2, 1).value = '群聊ID'
|
||||
worksheet.getCell(2, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.mergeCells(2, 2, 2, 3)
|
||||
worksheet.getCell(2, 2).value = normalizedChatroomId
|
||||
|
||||
worksheet.getCell(2, 4).value = '群聊名称'
|
||||
worksheet.getCell(2, 4).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(2, 5).value = groupName
|
||||
worksheet.getCell(2, 6).value = '成员wxid'
|
||||
worksheet.getCell(2, 6).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.mergeCells(2, 7, 2, 8)
|
||||
worksheet.getCell(2, 7).value = normalizedMemberUsername
|
||||
|
||||
worksheet.getCell(3, 1).value = '成员显示名'
|
||||
worksheet.getCell(3, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(3, 2).value = memberDisplayName
|
||||
worksheet.getCell(3, 3).value = '成员备注'
|
||||
worksheet.getCell(3, 3).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(3, 4).value = memberRemark
|
||||
worksheet.getCell(3, 5).value = '群昵称'
|
||||
worksheet.getCell(3, 5).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(3, 6).value = memberGroupNickname
|
||||
worksheet.getCell(3, 7).value = '微信号'
|
||||
worksheet.getCell(3, 7).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(3, 8).value = memberAlias
|
||||
|
||||
worksheet.getCell(4, 1).value = '导出工具'
|
||||
worksheet.getCell(4, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(4, 2).value = exportGenerator
|
||||
worksheet.getCell(4, 3).value = '导出版本'
|
||||
worksheet.getCell(4, 3).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(4, 4).value = exportVersion
|
||||
worksheet.getCell(4, 5).value = '平台'
|
||||
worksheet.getCell(4, 5).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(4, 6).value = exportPlatform
|
||||
worksheet.getCell(4, 7).value = '导出时间'
|
||||
worksheet.getCell(4, 7).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
worksheet.getCell(4, 8).value = exportTime
|
||||
|
||||
const headerRow = worksheet.getRow(5)
|
||||
const header = ['序号', '时间', '发送者wxid', '消息类型', '内容']
|
||||
header.forEach((title, index) => {
|
||||
const cell = headerRow.getCell(index + 1)
|
||||
cell.value = title
|
||||
cell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||
})
|
||||
headerRow.height = 22
|
||||
|
||||
worksheet.getColumn(1).width = 10
|
||||
worksheet.getColumn(2).width = 22
|
||||
worksheet.getColumn(3).width = 30
|
||||
worksheet.getColumn(4).width = 16
|
||||
worksheet.getColumn(5).width = 90
|
||||
worksheet.getColumn(6).width = 16
|
||||
worksheet.getColumn(7).width = 20
|
||||
worksheet.getColumn(8).width = 24
|
||||
|
||||
let currentRow = 6
|
||||
for (const record of records) {
|
||||
const row = worksheet.getRow(currentRow)
|
||||
row.getCell(1).value = record.index
|
||||
row.getCell(2).value = record.time
|
||||
row.getCell(3).value = record.sender
|
||||
row.getCell(4).value = record.messageType
|
||||
row.getCell(5).value = record.content
|
||||
row.alignment = { vertical: 'top', wrapText: true }
|
||||
currentRow += 1
|
||||
}
|
||||
|
||||
await workbook.xlsx.writeFile(outputPath)
|
||||
}
|
||||
|
||||
return { success: true, count: records.length }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success) return { success: false, error: conn.error }
|
||||
|
||||
const exportDate = new Date()
|
||||
const exportTime = this.formatDateTime(exportDate)
|
||||
const exportVersion = '0.0.2'
|
||||
const exportGenerator = 'WeFlow'
|
||||
const exportPlatform = 'wechat'
|
||||
|
||||
const groupDisplay = await wcdbService.getDisplayNames([chatroomId])
|
||||
const groupName = groupDisplay.success && groupDisplay.map
|
||||
? (groupDisplay.map[chatroomId] || chatroomId)
|
||||
: chatroomId
|
||||
|
||||
const groupContact = await wcdbService.getContact(chatroomId)
|
||||
const sessionRemark = (groupContact.success && groupContact.contact)
|
||||
? (groupContact.contact.remark || '')
|
||||
: ''
|
||||
|
||||
const membersResult = await wcdbService.getGroupMembers(chatroomId)
|
||||
if (!membersResult.success || !membersResult.members) {
|
||||
return { success: false, error: membersResult.error || '获取群成员失败' }
|
||||
}
|
||||
|
||||
const members = membersResult.members as Array<{
|
||||
username: string
|
||||
avatarUrl?: string
|
||||
originalName?: string
|
||||
}>
|
||||
if (members.length === 0) {
|
||||
return { success: false, error: '群成员为空' }
|
||||
}
|
||||
|
||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||
|
||||
const contactMap = new Map<string, {
|
||||
remark?: string
|
||||
nickName?: string
|
||||
alias?: string
|
||||
username?: string
|
||||
userName?: string
|
||||
encryptUsername?: string
|
||||
encryptUserName?: string
|
||||
}>()
|
||||
const concurrency = 6
|
||||
await this.parallelLimit(usernames, concurrency, async (username) => {
|
||||
const result = await wcdbService.getContact(username)
|
||||
if (result.success && result.contact) {
|
||||
const contact = result.contact as any
|
||||
contactMap.set(username, {
|
||||
remark: contact.remark || '',
|
||||
nickName: contact.nickName || contact.nick_name || '',
|
||||
alias: contact.alias || '',
|
||||
username: contact.username || '',
|
||||
userName: contact.userName || contact.user_name || '',
|
||||
encryptUsername: contact.encryptUsername || contact.encrypt_username || '',
|
||||
encryptUserName: contact.encryptUserName || ''
|
||||
})
|
||||
} else {
|
||||
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
||||
}
|
||||
})
|
||||
|
||||
const infoTitleRow = ['会话信息']
|
||||
const infoRow = ['微信ID', chatroomId, '', '昵称', groupName, '备注', sessionRemark || '', '']
|
||||
const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime]
|
||||
|
||||
const header = ['微信昵称', '微信备注', '群昵称', 'wxid', '微信号']
|
||||
const rows: string[][] = [infoTitleRow, infoRow, metaRow, header]
|
||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||
|
||||
const displayNames = await displayNamesPromise
|
||||
const nicknameCandidates = this.buildIdCandidates([
|
||||
...members.map((m) => m.username),
|
||||
...members.map((m) => m.originalName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.username),
|
||||
...Array.from(contactMap.values()).map((c) => c?.userName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.encryptUsername),
|
||||
...Array.from(contactMap.values()).map((c) => c?.encryptUserName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.alias)
|
||||
])
|
||||
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||
|
||||
for (const member of members) {
|
||||
const wxid = member.username
|
||||
const normalizedWxid = this.cleanAccountDirName(wxid || '')
|
||||
const contact = contactMap.get(wxid)
|
||||
const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : ''
|
||||
const nickName = contact?.nickName || fallbackName || ''
|
||||
const remark = contact?.remark || ''
|
||||
const alias = contact?.alias || ''
|
||||
const lookupCandidates = this.buildIdCandidates([
|
||||
wxid,
|
||||
member.originalName,
|
||||
contact?.username,
|
||||
contact?.userName,
|
||||
contact?.encryptUsername,
|
||||
contact?.encryptUserName,
|
||||
alias
|
||||
])
|
||||
if (normalizedWxid === myWxid) {
|
||||
lookupCandidates.push(myWxid)
|
||||
}
|
||||
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
|
||||
|
||||
rows.push([nickName, remark, groupNickname, wxid, alias])
|
||||
}
|
||||
|
||||
const ext = path.extname(outputPath).toLowerCase()
|
||||
if (ext === '.csv') {
|
||||
const csvLines = rows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(','))
|
||||
const content = '\ufeff' + csvLines.join('\n')
|
||||
fs.writeFileSync(outputPath, content, 'utf8')
|
||||
} else {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const sheet = workbook.addWorksheet(this.sanitizeWorksheetName('群成员列表'))
|
||||
|
||||
let currentRow = 1
|
||||
const titleCell = sheet.getCell(currentRow, 1)
|
||||
titleCell.value = '会话信息'
|
||||
titleCell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||
titleCell.alignment = { vertical: 'middle', horizontal: 'left' }
|
||||
sheet.getRow(currentRow).height = 25
|
||||
currentRow++
|
||||
|
||||
sheet.getCell(currentRow, 1).value = '微信ID'
|
||||
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.mergeCells(currentRow, 2, currentRow, 3)
|
||||
sheet.getCell(currentRow, 2).value = chatroomId
|
||||
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }
|
||||
|
||||
sheet.getCell(currentRow, 4).value = '昵称'
|
||||
sheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.getCell(currentRow, 5).value = groupName
|
||||
sheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }
|
||||
|
||||
sheet.getCell(currentRow, 6).value = '备注'
|
||||
sheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.mergeCells(currentRow, 7, currentRow, 8)
|
||||
sheet.getCell(currentRow, 7).value = sessionRemark
|
||||
sheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 }
|
||||
|
||||
sheet.getRow(currentRow).height = 20
|
||||
currentRow++
|
||||
|
||||
sheet.getCell(currentRow, 1).value = '导出工具'
|
||||
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.getCell(currentRow, 2).value = exportGenerator
|
||||
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
sheet.getCell(currentRow, 3).value = '导出版本'
|
||||
sheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.getCell(currentRow, 4).value = exportVersion
|
||||
sheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
sheet.getCell(currentRow, 5).value = '平台'
|
||||
sheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.getCell(currentRow, 6).value = exportPlatform
|
||||
sheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
sheet.getCell(currentRow, 7).value = '导出时间'
|
||||
sheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.getCell(currentRow, 8).value = exportTime
|
||||
sheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
sheet.getRow(currentRow).height = 20
|
||||
currentRow++
|
||||
|
||||
const headerRow = sheet.getRow(currentRow)
|
||||
headerRow.height = 22
|
||||
header.forEach((text, index) => {
|
||||
const cell = headerRow.getCell(index + 1)
|
||||
cell.value = text
|
||||
cell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||
})
|
||||
currentRow++
|
||||
|
||||
sheet.getColumn(1).width = 28
|
||||
sheet.getColumn(2).width = 28
|
||||
sheet.getColumn(3).width = 28
|
||||
sheet.getColumn(4).width = 36
|
||||
sheet.getColumn(5).width = 28
|
||||
sheet.getColumn(6).width = 18
|
||||
sheet.getColumn(7).width = 24
|
||||
sheet.getColumn(8).width = 22
|
||||
|
||||
for (let i = 4; i < rows.length; i++) {
|
||||
const [nickName, remark, groupNickname, wxid, alias] = rows[i]
|
||||
const row = sheet.getRow(currentRow)
|
||||
row.getCell(1).value = nickName
|
||||
row.getCell(2).value = remark
|
||||
row.getCell(3).value = groupNickname
|
||||
row.getCell(4).value = wxid
|
||||
row.getCell(5).value = alias
|
||||
row.alignment = { vertical: 'top', wrapText: true }
|
||||
currentRow++
|
||||
}
|
||||
|
||||
await workbook.xlsx.writeFile(outputPath)
|
||||
}
|
||||
|
||||
return { success: true, count: members.length }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
export const groupAnalyticsService = new GroupAnalyticsService()
|
||||
|
||||
906
electron/services/httpService.ts
Normal file
906
electron/services/httpService.ts
Normal file
@@ -0,0 +1,906 @@
|
||||
/**
|
||||
* HTTP API 服务
|
||||
* 提供 ChatLab 标准化格式的消息查询 API
|
||||
*/
|
||||
import * as http from 'http'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { URL } from 'url'
|
||||
import { chatService, Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { videoService } from './videoService'
|
||||
|
||||
// ChatLab 格式定义
|
||||
interface ChatLabHeader {
|
||||
version: string
|
||||
exportedAt: number
|
||||
generator: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ChatLabMeta {
|
||||
name: string
|
||||
platform: string
|
||||
type: 'group' | 'private'
|
||||
groupId?: string
|
||||
groupAvatar?: string
|
||||
ownerId?: string
|
||||
}
|
||||
|
||||
interface ChatLabMember {
|
||||
platformId: string
|
||||
accountName: string
|
||||
groupNickname?: string
|
||||
aliases?: string[]
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
interface ChatLabMessage {
|
||||
sender: string
|
||||
accountName: string
|
||||
groupNickname?: string
|
||||
timestamp: number
|
||||
type: number
|
||||
content: string | null
|
||||
platformMessageId?: string
|
||||
replyToMessageId?: string
|
||||
mediaPath?: string
|
||||
}
|
||||
|
||||
interface ChatLabData {
|
||||
chatlab: ChatLabHeader
|
||||
meta: ChatLabMeta
|
||||
members: ChatLabMember[]
|
||||
messages: ChatLabMessage[]
|
||||
}
|
||||
|
||||
interface ApiMediaOptions {
|
||||
enabled: boolean
|
||||
exportImages: boolean
|
||||
exportVoices: boolean
|
||||
exportVideos: boolean
|
||||
exportEmojis: boolean
|
||||
}
|
||||
|
||||
type MediaKind = 'image' | 'voice' | 'video' | 'emoji'
|
||||
|
||||
interface ApiExportedMedia {
|
||||
kind: MediaKind
|
||||
fileName: string
|
||||
fullPath: string
|
||||
}
|
||||
|
||||
// ChatLab 消息类型映射
|
||||
const ChatLabType = {
|
||||
TEXT: 0,
|
||||
IMAGE: 1,
|
||||
VOICE: 2,
|
||||
VIDEO: 3,
|
||||
FILE: 4,
|
||||
EMOJI: 5,
|
||||
LINK: 7,
|
||||
LOCATION: 8,
|
||||
RED_PACKET: 20,
|
||||
TRANSFER: 21,
|
||||
POKE: 22,
|
||||
CALL: 23,
|
||||
SHARE: 24,
|
||||
REPLY: 25,
|
||||
FORWARD: 26,
|
||||
CONTACT: 27,
|
||||
SYSTEM: 80,
|
||||
RECALL: 81,
|
||||
OTHER: 99
|
||||
} as const
|
||||
|
||||
class HttpService {
|
||||
private server: http.Server | null = null
|
||||
private configService: ConfigService
|
||||
private port: number = 5031
|
||||
private running: boolean = false
|
||||
private connections: Set<import('net').Socket> = new Set()
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 HTTP 服务
|
||||
*/
|
||||
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
|
||||
if (this.running && this.server) {
|
||||
return { success: true, port: this.port }
|
||||
}
|
||||
|
||||
this.port = port
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res))
|
||||
|
||||
// 跟踪所有连接,以便关闭时能强制断开
|
||||
this.server.on('connection', (socket) => {
|
||||
this.connections.add(socket)
|
||||
socket.on('close', () => {
|
||||
this.connections.delete(socket)
|
||||
})
|
||||
})
|
||||
|
||||
this.server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[HttpService] Port ${this.port} is already in use`)
|
||||
resolve({ success: false, error: `Port ${this.port} is already in use` })
|
||||
} else {
|
||||
console.error('[HttpService] Server error:', err)
|
||||
resolve({ success: false, error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => {
|
||||
this.running = true
|
||||
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
||||
resolve({ success: true, port: this.port })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 HTTP 服务
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
// 强制关闭所有活动连接
|
||||
for (const socket of this.connections) {
|
||||
socket.destroy()
|
||||
}
|
||||
this.connections.clear()
|
||||
|
||||
this.server.close(() => {
|
||||
this.running = false
|
||||
this.server = null
|
||||
console.log('[HttpService] HTTP API server stopped')
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
this.running = false
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查服务是否运行
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.running
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前端口
|
||||
*/
|
||||
getPort(): number {
|
||||
return this.port
|
||||
}
|
||||
|
||||
getDefaultMediaExportPath(): string {
|
||||
return this.getApiMediaExportPath()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// 设置 CORS 头
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
|
||||
const pathname = url.pathname
|
||||
|
||||
try {
|
||||
// 路由处理
|
||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||
this.sendJson(res, { status: 'ok' })
|
||||
} else if (pathname === '/api/v1/messages') {
|
||||
await this.handleMessages(url, res)
|
||||
} else if (pathname === '/api/v1/sessions') {
|
||||
await this.handleSessions(url, res)
|
||||
} else if (pathname === '/api/v1/contacts') {
|
||||
await this.handleContacts(url, res)
|
||||
} else {
|
||||
this.sendError(res, 404, 'Not Found')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HttpService] Request error:', error)
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取消息(循环游标直到满足 limit)
|
||||
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
||||
*/
|
||||
private async fetchMessagesBatch(
|
||||
talker: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
ascending: boolean
|
||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||
try {
|
||||
// 使用固定 batch 大小(与 limit 相同或最多 500)来减少循环次数
|
||||
const batchSize = Math.min(limit, 500)
|
||||
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||
|
||||
const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||
}
|
||||
|
||||
const cursor = cursorResult.cursor
|
||||
try {
|
||||
const allRows: Record<string, any>[] = []
|
||||
let hasMore = true
|
||||
let skipped = 0
|
||||
|
||||
// 循环获取消息,处理 offset 跳过 + limit 累积
|
||||
while (allRows.length < limit && hasMore) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batch.success || !batch.rows || batch.rows.length === 0) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
let rows = batch.rows
|
||||
hasMore = batch.hasMore === true
|
||||
|
||||
// 处理 offset:跳过前 N 条
|
||||
if (skipped < offset) {
|
||||
const remaining = offset - skipped
|
||||
if (remaining >= rows.length) {
|
||||
skipped += rows.length
|
||||
continue
|
||||
}
|
||||
rows = rows.slice(remaining)
|
||||
skipped = offset
|
||||
}
|
||||
|
||||
allRows.push(...rows)
|
||||
}
|
||||
|
||||
const trimmedRows = allRows.slice(0, limit)
|
||||
const finalHasMore = hasMore || allRows.length > limit
|
||||
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
||||
return { success: true, messages, hasMore: finalHasMore }
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HttpService] fetchMessagesBatch error:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query param helpers.
|
||||
*/
|
||||
private parseIntParam(value: string | null, defaultValue: number, min: number, max: number): number {
|
||||
const parsed = parseInt(value || '', 10)
|
||||
if (!Number.isFinite(parsed)) return defaultValue
|
||||
return Math.min(Math.max(parsed, min), max)
|
||||
}
|
||||
|
||||
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
||||
for (const key of keys) {
|
||||
const raw = url.searchParams.get(key)
|
||||
if (raw === null) continue
|
||||
const normalized = raw.trim().toLowerCase()
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
private parseMediaOptions(url: URL): ApiMediaOptions {
|
||||
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
|
||||
if (!mediaEnabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
exportImages: false,
|
||||
exportVoices: false,
|
||||
exportVideos: false,
|
||||
exportEmojis: false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
exportImages: this.parseBooleanParam(url, ['image', 'tupian'], true),
|
||||
exportVoices: this.parseBooleanParam(url, ['voice', 'vioce'], true),
|
||||
exportVideos: this.parseBooleanParam(url, ['video'], true),
|
||||
exportEmojis: this.parseBooleanParam(url, ['emoji'], true)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const talker = (url.searchParams.get('talker') || '').trim()
|
||||
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||
const keyword = (url.searchParams.get('keyword') || '').trim().toLowerCase()
|
||||
const startParam = url.searchParams.get('start')
|
||||
const endParam = url.searchParams.get('end')
|
||||
const chatlab = this.parseBooleanParam(url, ['chatlab'], false)
|
||||
const formatParam = (url.searchParams.get('format') || '').trim().toLowerCase()
|
||||
const format = formatParam || (chatlab ? 'chatlab' : 'json')
|
||||
const mediaOptions = this.parseMediaOptions(url)
|
||||
|
||||
if (!talker) {
|
||||
this.sendError(res, 400, 'Missing required parameter: talker')
|
||||
return
|
||||
}
|
||||
|
||||
if (format !== 'json' && format !== 'chatlab') {
|
||||
this.sendError(res, 400, 'Invalid format, supported: json/chatlab')
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = this.parseTimeParam(startParam)
|
||||
const endTime = this.parseTimeParam(endParam, true)
|
||||
const queryOffset = keyword ? 0 : offset
|
||||
const queryLimit = keyword ? 10000 : limit
|
||||
|
||||
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true)
|
||||
if (!result.success || !result.messages) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||
return
|
||||
}
|
||||
|
||||
let messages = result.messages
|
||||
let hasMore = result.hasMore === true
|
||||
|
||||
if (keyword) {
|
||||
const filtered = messages.filter((msg) => {
|
||||
const content = (msg.parsedContent || msg.rawContent || '').toLowerCase()
|
||||
return content.includes(keyword)
|
||||
})
|
||||
const endIndex = offset + limit
|
||||
hasMore = filtered.length > endIndex
|
||||
messages = filtered.slice(offset, endIndex)
|
||||
}
|
||||
|
||||
const mediaMap = mediaOptions.enabled
|
||||
? await this.exportMediaForMessages(messages, talker, mediaOptions)
|
||||
: new Map<number, ApiExportedMedia>()
|
||||
|
||||
const displayNames = await this.getDisplayNames([talker])
|
||||
const talkerName = displayNames[talker] || talker
|
||||
|
||||
if (format === 'chatlab') {
|
||||
const chatLabData = await this.convertToChatLab(messages, talker, talkerName, mediaMap)
|
||||
this.sendJson(res, {
|
||||
...chatLabData,
|
||||
media: {
|
||||
enabled: mediaOptions.enabled,
|
||||
exportPath: this.getApiMediaExportPath(),
|
||||
count: mediaMap.size
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const apiMessages = messages.map((msg) => this.toApiMessage(msg, mediaMap.get(msg.localId)))
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
talker,
|
||||
count: apiMessages.length,
|
||||
hasMore,
|
||||
media: {
|
||||
enabled: mediaOptions.enabled,
|
||||
exportPath: this.getApiMediaExportPath(),
|
||||
count: mediaMap.size
|
||||
},
|
||||
messages: apiMessages
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理会话列表查询
|
||||
* GET /api/v1/sessions?keyword=xxx&limit=100
|
||||
*/
|
||||
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const keyword = (url.searchParams.get('keyword') || '').trim()
|
||||
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||
|
||||
try {
|
||||
const sessions = await chatService.getSessions()
|
||||
if (!sessions.success || !sessions.sessions) {
|
||||
this.sendError(res, 500, sessions.error || 'Failed to get sessions')
|
||||
return
|
||||
}
|
||||
|
||||
let filteredSessions = sessions.sessions
|
||||
if (keyword) {
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
filteredSessions = sessions.sessions.filter(s =>
|
||||
s.username.toLowerCase().includes(lowerKeyword) ||
|
||||
(s.displayName && s.displayName.toLowerCase().includes(lowerKeyword))
|
||||
)
|
||||
}
|
||||
|
||||
// 应用 limit
|
||||
const limitedSessions = filteredSessions.slice(0, limit)
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
count: limitedSessions.length,
|
||||
sessions: limitedSessions.map(s => ({
|
||||
username: s.username,
|
||||
displayName: s.displayName,
|
||||
type: s.type,
|
||||
lastTimestamp: s.lastTimestamp,
|
||||
unreadCount: s.unreadCount
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理联系人查询
|
||||
* GET /api/v1/contacts?keyword=xxx&limit=100
|
||||
*/
|
||||
private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const keyword = (url.searchParams.get('keyword') || '').trim()
|
||||
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||
|
||||
try {
|
||||
const contacts = await chatService.getContacts()
|
||||
if (!contacts.success || !contacts.contacts) {
|
||||
this.sendError(res, 500, contacts.error || 'Failed to get contacts')
|
||||
return
|
||||
}
|
||||
|
||||
let filteredContacts = contacts.contacts
|
||||
if (keyword) {
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
filteredContacts = contacts.contacts.filter(c =>
|
||||
c.username.toLowerCase().includes(lowerKeyword) ||
|
||||
(c.nickname && c.nickname.toLowerCase().includes(lowerKeyword)) ||
|
||||
(c.remark && c.remark.toLowerCase().includes(lowerKeyword)) ||
|
||||
(c.displayName && c.displayName.toLowerCase().includes(lowerKeyword))
|
||||
)
|
||||
}
|
||||
|
||||
const limited = filteredContacts.slice(0, limit)
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
count: limited.length,
|
||||
contacts: limited
|
||||
})
|
||||
} catch (error) {
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
private getApiMediaExportPath(): string {
|
||||
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||
}
|
||||
|
||||
private sanitizeFileName(value: string, fallback: string): string {
|
||||
const safe = (value || '')
|
||||
.trim()
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||
.replace(/\.+$/g, '')
|
||||
return safe || fallback
|
||||
}
|
||||
|
||||
private ensureDir(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private detectImageExt(buffer: Buffer): string {
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
|
||||
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return '.png'
|
||||
if (buffer.length >= 6) {
|
||||
const sig6 = buffer.subarray(0, 6).toString('ascii')
|
||||
if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return '.gif'
|
||||
}
|
||||
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return '.webp'
|
||||
if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d) return '.bmp'
|
||||
return '.jpg'
|
||||
}
|
||||
|
||||
private async exportMediaForMessages(
|
||||
messages: Message[],
|
||||
talker: string,
|
||||
options: ApiMediaOptions
|
||||
): Promise<Map<number, ApiExportedMedia>> {
|
||||
const mediaMap = new Map<number, ApiExportedMedia>()
|
||||
if (!options.enabled || messages.length === 0) {
|
||||
return mediaMap
|
||||
}
|
||||
|
||||
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
||||
this.ensureDir(sessionDir)
|
||||
|
||||
for (const msg of messages) {
|
||||
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
||||
if (exported) {
|
||||
mediaMap.set(msg.localId, exported)
|
||||
}
|
||||
}
|
||||
|
||||
return mediaMap
|
||||
}
|
||||
|
||||
private async exportMediaForMessage(
|
||||
msg: Message,
|
||||
talker: string,
|
||||
sessionDir: string,
|
||||
options: ApiMediaOptions
|
||||
): Promise<ApiExportedMedia | null> {
|
||||
try {
|
||||
if (msg.localType === 3 && options.exportImages) {
|
||||
const result = await chatService.getImageData(talker, String(msg.localId))
|
||||
if (result.success && result.data) {
|
||||
const imageBuffer = Buffer.from(result.data, 'base64')
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
const fileName = `${fileBase}${ext}`
|
||||
const targetDir = path.join(sessionDir, 'images')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, imageBuffer)
|
||||
}
|
||||
return { kind: 'image', fileName, fullPath }
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.localType === 34 && options.exportVoices) {
|
||||
const result = await chatService.getVoiceData(
|
||||
talker,
|
||||
String(msg.localId),
|
||||
msg.createTime || undefined,
|
||||
msg.serverId || undefined
|
||||
)
|
||||
if (result.success && result.data) {
|
||||
const fileName = `voice_${msg.localId}.wav`
|
||||
const targetDir = path.join(sessionDir, 'voices')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
||||
}
|
||||
return { kind: 'voice', fileName, fullPath }
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.localType === 43 && options.exportVideos && msg.videoMd5) {
|
||||
const info = await videoService.getVideoInfo(msg.videoMd5)
|
||||
if (info.exists && info.videoUrl && fs.existsSync(info.videoUrl)) {
|
||||
const ext = path.extname(info.videoUrl) || '.mp4'
|
||||
const fileName = `${this.sanitizeFileName(msg.videoMd5, `video_${msg.localId}`)}${ext}`
|
||||
const targetDir = path.join(sessionDir, 'videos')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.copyFileSync(info.videoUrl, fullPath)
|
||||
}
|
||||
return { kind: 'video', fileName, fullPath }
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.localType === 47 && options.exportEmojis && msg.emojiCdnUrl) {
|
||||
const result = await chatService.downloadEmoji(msg.emojiCdnUrl, msg.emojiMd5)
|
||||
if (result.success && result.localPath && fs.existsSync(result.localPath)) {
|
||||
const sourceExt = path.extname(result.localPath) || '.gif'
|
||||
const fileName = `${this.sanitizeFileName(msg.emojiMd5 || `emoji_${msg.localId}`, `emoji_${msg.localId}`)}${sourceExt}`
|
||||
const targetDir = path.join(sessionDir, 'emojis')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.copyFileSync(result.localPath, fullPath)
|
||||
}
|
||||
return { kind: 'emoji', fileName, fullPath }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[HttpService] exportMediaForMessage failed:', e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private toApiMessage(msg: Message, media?: ApiExportedMedia): Record<string, any> {
|
||||
return {
|
||||
localId: msg.localId,
|
||||
serverId: msg.serverId,
|
||||
localType: msg.localType,
|
||||
createTime: msg.createTime,
|
||||
sortSeq: msg.sortSeq,
|
||||
isSend: msg.isSend,
|
||||
senderUsername: msg.senderUsername,
|
||||
content: this.getMessageContent(msg),
|
||||
rawContent: msg.rawContent,
|
||||
parsedContent: msg.parsedContent,
|
||||
mediaType: media?.kind,
|
||||
mediaFileName: media?.fileName,
|
||||
mediaPath: media?.fullPath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间参数
|
||||
* 支持 YYYYMMDD 格式,返回秒级时间戳
|
||||
*/
|
||||
private parseTimeParam(param: string | null, isEnd: boolean = false): number {
|
||||
if (!param) return 0
|
||||
|
||||
// 纯数字且长度为 8,视为 YYYYMMDD
|
||||
if (/^\d{8}$/.test(param)) {
|
||||
const year = parseInt(param.slice(0, 4), 10)
|
||||
const month = parseInt(param.slice(4, 6), 10) - 1
|
||||
const day = parseInt(param.slice(6, 8), 10)
|
||||
const date = new Date(year, month, day)
|
||||
if (isEnd) {
|
||||
// 结束时间设为当天 23:59:59
|
||||
date.setHours(23, 59, 59, 999)
|
||||
}
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
// 纯数字,视为时间戳
|
||||
if (/^\d+$/.test(param)) {
|
||||
const ts = parseInt(param, 10)
|
||||
// 如果是毫秒级时间戳,转为秒级
|
||||
return ts > 10000000000 ? Math.floor(ts / 1000) : ts
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取显示名称
|
||||
*/
|
||||
private async getDisplayNames(usernames: string[]): Promise<Record<string, string>> {
|
||||
try {
|
||||
const result = await wcdbService.getDisplayNames(usernames)
|
||||
if (result.success && result.map) {
|
||||
return result.map
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HttpService] Failed to get display names:', e)
|
||||
}
|
||||
// 返回空对象,调用方会使用 username 作为备用
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 ChatLab 格式
|
||||
*/
|
||||
private async convertToChatLab(
|
||||
messages: Message[],
|
||||
talkerId: string,
|
||||
talkerName: string,
|
||||
mediaMap: Map<number, ApiExportedMedia> = new Map()
|
||||
): Promise<ChatLabData> {
|
||||
const isGroup = talkerId.endsWith('@chatroom')
|
||||
const myWxid = this.configService.get('myWxid') || ''
|
||||
|
||||
// 收集所有发送者
|
||||
const senderSet = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
if (msg.senderUsername) {
|
||||
senderSet.add(msg.senderUsername)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取发送者显示名
|
||||
const senderNames = await this.getDisplayNames(Array.from(senderSet))
|
||||
|
||||
// 获取群昵称(如果是群聊)
|
||||
let groupNicknamesMap = new Map<string, string>()
|
||||
if (isGroup) {
|
||||
try {
|
||||
const result = await wcdbService.getGroupNicknames(talkerId)
|
||||
if (result.success && result.nicknames) {
|
||||
groupNicknamesMap = new Map(Object.entries(result.nicknames))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HttpService] Failed to get group nicknames:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建成员列表
|
||||
const memberMap = new Map<string, ChatLabMember>()
|
||||
for (const msg of messages) {
|
||||
const sender = msg.senderUsername || ''
|
||||
if (sender && !memberMap.has(sender)) {
|
||||
const displayName = senderNames[sender] || sender
|
||||
const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase()
|
||||
// 获取群昵称(尝试多种方式)
|
||||
const groupNickname = isGroup
|
||||
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
||||
: ''
|
||||
memberMap.set(sender, {
|
||||
platformId: sender,
|
||||
accountName: isSelf ? '我' : displayName,
|
||||
groupNickname: groupNickname || undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 转换消息
|
||||
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
|
||||
const sender = msg.senderUsername || ''
|
||||
const isSelf = msg.isSend === 1 || sender === myWxid
|
||||
const accountName = isSelf ? '我' : (senderNames[sender] || sender)
|
||||
// 获取该发送者的群昵称
|
||||
const groupNickname = isGroup
|
||||
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
||||
: ''
|
||||
|
||||
return {
|
||||
sender,
|
||||
accountName,
|
||||
groupNickname: groupNickname || undefined,
|
||||
timestamp: msg.createTime,
|
||||
type: this.mapMessageType(msg.localType, msg),
|
||||
content: this.getMessageContent(msg),
|
||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
||||
mediaPath: mediaMap.get(msg.localId)?.fullPath
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
chatlab: {
|
||||
version: '0.0.2',
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
generator: 'WeFlow'
|
||||
},
|
||||
meta: {
|
||||
name: talkerName,
|
||||
platform: 'wechat',
|
||||
type: isGroup ? 'group' : 'private',
|
||||
groupId: isGroup ? talkerId : undefined,
|
||||
ownerId: myWxid || undefined
|
||||
},
|
||||
members: Array.from(memberMap.values()),
|
||||
messages: chatLabMessages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射 WeChat 消息类型到 ChatLab 类型
|
||||
*/
|
||||
private mapMessageType(localType: number, msg: Message): number {
|
||||
switch (localType) {
|
||||
case 1: // 文本
|
||||
return ChatLabType.TEXT
|
||||
case 3: // 图片
|
||||
return ChatLabType.IMAGE
|
||||
case 34: // 语音
|
||||
return ChatLabType.VOICE
|
||||
case 43: // 视频
|
||||
return ChatLabType.VIDEO
|
||||
case 47: // 动画表情
|
||||
return ChatLabType.EMOJI
|
||||
case 48: // 位置
|
||||
return ChatLabType.LOCATION
|
||||
case 42: // 名片
|
||||
return ChatLabType.CONTACT
|
||||
case 50: // 语音/视频通话
|
||||
return ChatLabType.CALL
|
||||
case 10000: // 系统消息
|
||||
return ChatLabType.SYSTEM
|
||||
case 49: // 复合消息
|
||||
return this.mapType49(msg)
|
||||
case 244813135921: // 引用消息
|
||||
return ChatLabType.REPLY
|
||||
case 266287972401: // 拍一拍
|
||||
return ChatLabType.POKE
|
||||
case 8594229559345: // 红包
|
||||
return ChatLabType.RED_PACKET
|
||||
case 8589934592049: // 转账
|
||||
return ChatLabType.TRANSFER
|
||||
default:
|
||||
return ChatLabType.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射 Type 49 子类型
|
||||
*/
|
||||
private mapType49(msg: Message): number {
|
||||
const xmlType = msg.xmlType
|
||||
|
||||
switch (xmlType) {
|
||||
case '5': // 链接
|
||||
case '49':
|
||||
return ChatLabType.LINK
|
||||
case '6': // 文件
|
||||
return ChatLabType.FILE
|
||||
case '19': // 聊天记录
|
||||
return ChatLabType.FORWARD
|
||||
case '33': // 小程序
|
||||
case '36':
|
||||
return ChatLabType.SHARE
|
||||
case '57': // 引用消息
|
||||
return ChatLabType.REPLY
|
||||
case '2000': // 转账
|
||||
return ChatLabType.TRANSFER
|
||||
case '2001': // 红包
|
||||
return ChatLabType.RED_PACKET
|
||||
default:
|
||||
return ChatLabType.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息内容
|
||||
*/
|
||||
private getMessageContent(msg: Message): string | null {
|
||||
// 优先使用已解析的内容
|
||||
if (msg.parsedContent) {
|
||||
return msg.parsedContent
|
||||
}
|
||||
|
||||
// 根据类型返回占位符
|
||||
switch (msg.localType) {
|
||||
case 1:
|
||||
return msg.rawContent || null
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
return '[语音]'
|
||||
case 43:
|
||||
return '[视频]'
|
||||
case 47:
|
||||
return '[表情]'
|
||||
case 42:
|
||||
return msg.cardNickname || '[名片]'
|
||||
case 48:
|
||||
return '[位置]'
|
||||
case 49:
|
||||
return msg.linkTitle || msg.fileName || '[消息]'
|
||||
default:
|
||||
return msg.rawContent || null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 JSON 响应
|
||||
*/
|
||||
private sendJson(res: http.ServerResponse, data: any): void {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.writeHead(200)
|
||||
res.end(JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误响应
|
||||
*/
|
||||
private sendError(res: http.ServerResponse, code: number, message: string): void {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.writeHead(code)
|
||||
res.end(JSON.stringify({ error: message }))
|
||||
}
|
||||
}
|
||||
|
||||
export const httpService = new HttpService()
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||
import { writeFile, rm, readdir } from 'fs/promises'
|
||||
import crypto from 'crypto'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
// 获取 ffmpeg-static 的路径
|
||||
function getStaticFfmpegPath(): string | null {
|
||||
try {
|
||||
// 优先处理打包后的路径
|
||||
if (app.isPackaged) {
|
||||
const resourcesPath = process.resourcesPath
|
||||
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(packedPath)) {
|
||||
return packedPath
|
||||
}
|
||||
}
|
||||
|
||||
// 方法1: 直接 require ffmpeg-static(开发环境)
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ffmpegStatic = require('ffmpeg-static')
|
||||
|
||||
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
|
||||
return ffmpegStatic
|
||||
}
|
||||
|
||||
// 方法2: 手动构建路径(开发环境备用)
|
||||
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(devPath)) {
|
||||
return devPath
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type DecryptResult = {
|
||||
success: boolean
|
||||
localPath?: string
|
||||
@@ -32,10 +64,37 @@ export class ImageDecryptService {
|
||||
|
||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
if (meta) {
|
||||
console.info(message, meta)
|
||||
} else {
|
||||
console.info(message)
|
||||
const timestamp = new Date().toISOString()
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
|
||||
|
||||
// 只写入文件,不输出到控制台
|
||||
this.writeLog(logLine)
|
||||
}
|
||||
|
||||
private logError(message: string, error?: unknown, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
const timestamp = new Date().toISOString()
|
||||
const errorStr = error ? ` Error: ${String(error)}` : ''
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n`
|
||||
|
||||
// 同时输出到控制台
|
||||
console.error(message, error, meta)
|
||||
|
||||
// 写入日志文件
|
||||
this.writeLog(logLine)
|
||||
}
|
||||
|
||||
private writeLog(line: string): void {
|
||||
try {
|
||||
const logDir = join(app.getPath('userData'), 'logs')
|
||||
if (!existsSync(logDir)) {
|
||||
mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' })
|
||||
} catch (err) {
|
||||
console.error('写入日志失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +140,7 @@ export class ImageDecryptService {
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
|
||||
}
|
||||
}
|
||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到缓存图片' }
|
||||
}
|
||||
|
||||
@@ -120,15 +180,18 @@ export class ImageDecryptService {
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
|
||||
cacheKey: string
|
||||
): Promise<DecryptResult> {
|
||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
|
||||
try {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) {
|
||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||
return { success: false, error: '未配置账号或数据库路径' }
|
||||
}
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) {
|
||||
this.logError('未找到账号目录', undefined, { dbPath, wxid })
|
||||
return { success: false, error: '未找到账号目录' }
|
||||
}
|
||||
|
||||
@@ -142,12 +205,16 @@ export class ImageDecryptService {
|
||||
|
||||
// 如果要求高清图但没找到,直接返回提示
|
||||
if (!datPath && payload.force) {
|
||||
this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
|
||||
}
|
||||
if (!datPath) {
|
||||
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到图片文件' }
|
||||
}
|
||||
|
||||
this.logInfo('找到DAT文件', { datPath })
|
||||
|
||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
|
||||
const dataUrl = this.fileToDataUrl(datPath)
|
||||
@@ -160,6 +227,7 @@ export class ImageDecryptService {
|
||||
// 查找已缓存的解密文件
|
||||
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
|
||||
if (existing) {
|
||||
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
|
||||
const isHd = this.isHdPath(existing)
|
||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||
if (!(payload.force && !isHd)) {
|
||||
@@ -192,23 +260,45 @@ export class ImageDecryptService {
|
||||
const aesKeyRaw = this.configService.get('imageAesKey')
|
||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||
|
||||
const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||
|
||||
const ext = this.detectImageExtension(decrypted) || '.jpg'
|
||||
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
|
||||
const wxgfResult = await this.unwrapWxgf(decrypted)
|
||||
decrypted = wxgfResult.data
|
||||
|
||||
const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId)
|
||||
let ext = this.detectImageExtension(decrypted)
|
||||
|
||||
// 如果是 wxgf 格式且没检测到扩展名
|
||||
if (wxgfResult.isWxgf && !ext) {
|
||||
ext = '.hevc'
|
||||
}
|
||||
|
||||
const finalExt = ext || '.jpg'
|
||||
|
||||
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
|
||||
await writeFile(outputPath, decrypted)
|
||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||
|
||||
// 对于 hevc 格式,返回错误提示
|
||||
if (finalExt === '.hevc') {
|
||||
return {
|
||||
success: false,
|
||||
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
|
||||
isThumb: this.isThumbnailPath(datPath)
|
||||
}
|
||||
}
|
||||
const isThumb = this.isThumbnailPath(datPath)
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||
if (!isThumb) {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
}
|
||||
const dataUrl = this.bufferToDataUrl(decrypted, ext)
|
||||
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
||||
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
return { success: true, localPath, isThumb }
|
||||
} catch (e) {
|
||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -233,7 +323,7 @@ export class ImageDecryptService {
|
||||
if (this.isAccountDir(entryPath)) return entryPath
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -290,9 +380,9 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
if (suffixMatch) return suffixMatch[1]
|
||||
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||
|
||||
return trimmed
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private async resolveDatPath(
|
||||
@@ -325,10 +415,16 @@ export class ImageDecryptService {
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||
return hardlinkPath
|
||||
}
|
||||
// hardlink 找到的是缩略图,但要求高清图,直接返回 null,不再搜索
|
||||
if (!allowThumbnail && isThumb) {
|
||||
return null
|
||||
// hardlink 找到的是缩略图,但要求高清图
|
||||
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
|
||||
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
// 没找到高清图,返回 null(不进行全局搜索)
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
|
||||
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
|
||||
@@ -341,9 +437,13 @@ export class ImageDecryptService {
|
||||
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
|
||||
return fallbackPath
|
||||
}
|
||||
if (!allowThumbnail && isThumb) {
|
||||
return null
|
||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||
const hdPath = this.findHdVariantInSameDir(fallbackPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
}
|
||||
@@ -359,10 +459,13 @@ export class ImageDecryptService {
|
||||
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||
return hardlinkPath
|
||||
}
|
||||
// hardlink 找到的是缩略图,但要求高清图,直接返回 null
|
||||
if (!allowThumbnail && isThumb) {
|
||||
return null
|
||||
// hardlink 找到的是缩略图,但要求高清图
|
||||
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
}
|
||||
@@ -377,6 +480,9 @@ export class ImageDecryptService {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(cached)) {
|
||||
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(cached)
|
||||
if (hdPath) return hdPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,7 +580,7 @@ export class ImageDecryptService {
|
||||
if (!hasUpdate) return
|
||||
this.updateFlags.set(cacheKey, true)
|
||||
this.emitImageUpdate(payload, cacheKey)
|
||||
}).catch(() => {})
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
private looksLikeMd5(value: string): boolean {
|
||||
@@ -671,6 +777,17 @@ export class ImageDecryptService {
|
||||
|
||||
const root = join(accountDir, 'msg', 'attach')
|
||||
if (!existsSync(root)) return null
|
||||
|
||||
// 优化1:快速概率性查找
|
||||
// 包含:1. 基于文件名的前缀猜测 (旧版)
|
||||
// 2. 基于日期的最近月份扫描 (新版无索引时)
|
||||
const fastHit = await this.fastProbabilisticSearch(root, datName)
|
||||
if (fastHit) {
|
||||
this.resolvedCache.set(key, fastHit)
|
||||
return fastHit
|
||||
}
|
||||
|
||||
// 优化2:兜底扫描 (异步非阻塞)
|
||||
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
|
||||
if (found) {
|
||||
this.resolvedCache.set(key, found)
|
||||
@@ -679,6 +796,134 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于文件名的哈希特征猜测可能的路径
|
||||
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
|
||||
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
|
||||
*/
|
||||
private async fastProbabilisticSearch(root: string, datName: string): Promise<string | null> {
|
||||
const { promises: fs } = require('fs')
|
||||
const { join } = require('path')
|
||||
|
||||
try {
|
||||
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
|
||||
const lowerName = datName.toLowerCase()
|
||||
let baseName = lowerName
|
||||
if (baseName.endsWith('.dat')) {
|
||||
baseName = baseName.slice(0, -4)
|
||||
if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) {
|
||||
baseName = baseName.slice(0, -3)
|
||||
} else if (baseName.endsWith('_thumb')) {
|
||||
baseName = baseName.slice(0, -6)
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: string[] = []
|
||||
if (/^[a-f0-9]{32}$/.test(baseName)) {
|
||||
const dir1 = baseName.substring(0, 2)
|
||||
const dir2 = baseName.substring(2, 4)
|
||||
candidates.push(
|
||||
join(root, dir1, dir2, datName),
|
||||
join(root, dir1, dir2, 'Img', datName),
|
||||
join(root, dir1, dir2, 'mg', datName),
|
||||
join(root, dir1, dir2, 'Image', datName)
|
||||
)
|
||||
}
|
||||
|
||||
for (const path of candidates) {
|
||||
try {
|
||||
await fs.access(path)
|
||||
return path
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// --- 策略 B: 新版 Session 哈希路径猜测 ---
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true })
|
||||
const sessionDirs = entries
|
||||
.filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name))
|
||||
.map((e: any) => e.name)
|
||||
|
||||
if (sessionDirs.length === 0) return null
|
||||
|
||||
const now = new Date()
|
||||
const months: string[] = []
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
months.push(mStr)
|
||||
}
|
||||
|
||||
const targetNames = [datName]
|
||||
if (baseName !== lowerName) {
|
||||
targetNames.push(`${baseName}.dat`)
|
||||
targetNames.push(`${baseName}_t.dat`)
|
||||
targetNames.push(`${baseName}_thumb.dat`)
|
||||
}
|
||||
|
||||
const batchSize = 20
|
||||
for (let i = 0; i < sessionDirs.length; i += batchSize) {
|
||||
const batch = sessionDirs.slice(i, i + batchSize)
|
||||
const tasks = batch.map(async (sessDir: string) => {
|
||||
for (const month of months) {
|
||||
const subDirs = ['Img', 'Image']
|
||||
for (const sub of subDirs) {
|
||||
const dirPath = join(root, sessDir, month, sub)
|
||||
try { await fs.access(dirPath) } catch { continue }
|
||||
for (const name of targetNames) {
|
||||
const p = join(dirPath, name)
|
||||
try { await fs.access(p); return p } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
const results = await Promise.all(tasks)
|
||||
const hit = results.find(r => r !== null)
|
||||
if (hit) return hit
|
||||
}
|
||||
} catch { }
|
||||
|
||||
} catch { }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 在同一目录下查找高清图变体
|
||||
* 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat
|
||||
*/
|
||||
private findHdVariantInSameDir(thumbPath: string): string | null {
|
||||
try {
|
||||
const dir = dirname(thumbPath)
|
||||
const fileName = basename(thumbPath).toLowerCase()
|
||||
|
||||
// 提取基础名称(去掉 _t.dat 或 .t.dat)
|
||||
let baseName = fileName
|
||||
if (baseName.endsWith('_t.dat')) {
|
||||
baseName = baseName.slice(0, -6)
|
||||
} else if (baseName.endsWith('.t.dat')) {
|
||||
baseName = baseName.slice(0, -6)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
// 尝试查找高清图变体
|
||||
const variants = [
|
||||
`${baseName}_h.dat`,
|
||||
`${baseName}.h.dat`,
|
||||
`${baseName}.dat`
|
||||
]
|
||||
|
||||
for (const variant of variants) {
|
||||
const variantPath = join(dir, variant)
|
||||
if (existsSync(variantPath)) {
|
||||
return variantPath
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
return null
|
||||
}
|
||||
|
||||
private async searchDatFileInDir(
|
||||
dirPath: string,
|
||||
datName: string,
|
||||
@@ -809,35 +1054,63 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
|
||||
const root = this.getCacheRoot()
|
||||
const allRoots = this.getAllCacheRoots()
|
||||
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
|
||||
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||
|
||||
// 遍历所有可能的缓存根路径
|
||||
for (const root of allRoots) {
|
||||
// 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg
|
||||
if (sessionId) {
|
||||
const sessionDir = join(root, this.sanitizeDirName(sessionId))
|
||||
if (existsSync(sessionDir)) {
|
||||
try {
|
||||
const sessionEntries = readdirSync(sessionDir)
|
||||
for (const entry of sessionEntries) {
|
||||
const timeDir = join(sessionDir, entry)
|
||||
if (!this.isDirectory(timeDir)) continue
|
||||
const hit = this.findCachedOutputInDir(timeDir, normalizedKey, extensions, preferHd)
|
||||
if (hit) return hit
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
const dateDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||
.map(d => d.name)
|
||||
.sort()
|
||||
.reverse() // 最新的日期优先
|
||||
|
||||
// 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg
|
||||
const imageDir = join(root, normalizedKey)
|
||||
if (existsSync(imageDir)) {
|
||||
for (const dateDir of dateDirs) {
|
||||
const imageDir = join(sessionDir, dateDir)
|
||||
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
||||
if (hit) return hit
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧的平铺结构
|
||||
// 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId)
|
||||
try {
|
||||
const sessionDirs = readdirSync(root, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
|
||||
for (const session of sessionDirs) {
|
||||
const sessionDir = join(root, session)
|
||||
// 检查是否是日期目录结构
|
||||
try {
|
||||
const subDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||
.map(d => d.name)
|
||||
|
||||
for (const dateDir of subDirs) {
|
||||
const imageDir = join(sessionDir, dateDir)
|
||||
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
||||
if (hit) return hit
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
// 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg
|
||||
const oldImageDir = join(root, normalizedKey)
|
||||
if (existsSync(oldImageDir)) {
|
||||
const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd)
|
||||
if (hit) return hit
|
||||
}
|
||||
|
||||
// 策略4: 最旧的平铺结构 Images/{file}.jpg
|
||||
for (const ext of extensions) {
|
||||
const candidate = join(root, `${cacheKey}${ext}`)
|
||||
if (existsSync(candidate)) return candidate
|
||||
@@ -846,6 +1119,7 @@ export class ImageDecryptService {
|
||||
const candidate = join(root, `${cacheKey}_t${ext}`)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -856,6 +1130,18 @@ export class ImageDecryptService {
|
||||
extensions: string[],
|
||||
preferHd: boolean
|
||||
): string | null {
|
||||
// 先检查并删除旧的 .hevc 文件(ffmpeg 转换失败时遗留的)
|
||||
const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`)
|
||||
const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`)
|
||||
try {
|
||||
if (existsSync(hevcThumb)) {
|
||||
require('fs').unlinkSync(hevcThumb)
|
||||
}
|
||||
if (existsSync(hevcHd)) {
|
||||
require('fs').unlinkSync(hevcHd)
|
||||
}
|
||||
} catch { }
|
||||
|
||||
for (const ext of extensions) {
|
||||
if (preferHd) {
|
||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||
@@ -863,6 +1149,8 @@ export class ImageDecryptService {
|
||||
}
|
||||
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
|
||||
if (existsSync(thumbPath)) return thumbPath
|
||||
|
||||
// 允许返回 _hd 格式(因为它有 _hd 变体后缀)
|
||||
if (!preferHd) {
|
||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||
if (existsSync(hdPath)) return hdPath
|
||||
@@ -960,8 +1248,9 @@ export class ImageDecryptService {
|
||||
const lower = entry.toLowerCase()
|
||||
if (!lower.endsWith('.dat')) continue
|
||||
if (this.isThumbnailDat(lower)) continue
|
||||
if (!this.hasXVariant(lower.slice(0, -4))) continue
|
||||
const baseLower = lower.slice(0, -4)
|
||||
// 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的)
|
||||
if (!this.hasXVariant(baseLower)) continue
|
||||
if (this.normalizeDatBase(baseLower) !== target) continue
|
||||
return join(dirPath, entry)
|
||||
}
|
||||
@@ -973,6 +1262,7 @@ export class ImageDecryptService {
|
||||
if (!lower.endsWith('.dat')) return false
|
||||
if (this.isThumbnailDat(lower)) return false
|
||||
const baseLower = lower.slice(0, -4)
|
||||
// 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的)
|
||||
return this.hasXVariant(baseLower)
|
||||
}
|
||||
|
||||
@@ -998,15 +1288,19 @@ export class ImageDecryptService {
|
||||
if (this.cacheIndexed) return
|
||||
if (this.cacheIndexing) return this.cacheIndexing
|
||||
this.cacheIndexing = new Promise((resolve) => {
|
||||
const root = this.getCacheRoot()
|
||||
// 扫描所有可能的缓存根目录
|
||||
const allRoots = this.getAllCacheRoots()
|
||||
this.logInfo('开始索引缓存', { roots: allRoots.length })
|
||||
|
||||
for (const root of allRoots) {
|
||||
try {
|
||||
this.indexCacheDir(root, 2, 0)
|
||||
} catch {
|
||||
this.cacheIndexed = true
|
||||
this.cacheIndexing = null
|
||||
resolve()
|
||||
return
|
||||
this.indexCacheDir(root, 3, 0) // 增加深度到3,支持 sessionId/YYYY-MM 结构
|
||||
} catch (e) {
|
||||
this.logError('索引目录失败', e, { root })
|
||||
}
|
||||
}
|
||||
|
||||
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
|
||||
this.cacheIndexed = true
|
||||
this.cacheIndexing = null
|
||||
resolve()
|
||||
@@ -1014,6 +1308,39 @@ export class ImageDecryptService {
|
||||
return this.cacheIndexing
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可能的缓存根路径(用于查找已缓存的图片)
|
||||
* 包含当前路径、配置路径、旧版本路径
|
||||
*/
|
||||
private getAllCacheRoots(): string[] {
|
||||
const roots: string[] = []
|
||||
const configured = this.configService.get('cachePath')
|
||||
const documentsPath = app.getPath('documents')
|
||||
|
||||
// 主要路径(当前使用的)
|
||||
const mainRoot = this.getCacheRoot()
|
||||
roots.push(mainRoot)
|
||||
|
||||
// 如果配置了自定义路径,也检查其下的 Images
|
||||
if (configured) {
|
||||
roots.push(join(configured, 'Images'))
|
||||
roots.push(join(configured, 'images'))
|
||||
}
|
||||
|
||||
// 默认路径
|
||||
roots.push(join(documentsPath, 'WeFlow', 'Images'))
|
||||
roots.push(join(documentsPath, 'WeFlow', 'images'))
|
||||
|
||||
// 兼容旧路径(如果有的话)
|
||||
roots.push(join(documentsPath, 'WeFlowData', 'Images'))
|
||||
|
||||
// 去重并过滤存在的路径
|
||||
const uniqueRoots = Array.from(new Set(roots))
|
||||
const existingRoots = uniqueRoots.filter(r => existsSync(r))
|
||||
|
||||
return existingRoots
|
||||
}
|
||||
|
||||
private indexCacheDir(root: string, maxDepth: number, depth: number): void {
|
||||
let entries: string[]
|
||||
try {
|
||||
@@ -1332,10 +1659,10 @@ export class ImageDecryptService {
|
||||
keyCount.set(key, (keyCount.get(key) || 0) + 1)
|
||||
filesChecked++
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
scanDir(dirPath)
|
||||
@@ -1354,6 +1681,159 @@ export class ImageDecryptService {
|
||||
return mostCommonKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 解包 wxgf 格式
|
||||
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
||||
*/
|
||||
private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> {
|
||||
// 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf")
|
||||
if (buffer.length < 20 ||
|
||||
buffer[0] !== 0x77 || buffer[1] !== 0x78 ||
|
||||
buffer[2] !== 0x67 || buffer[3] !== 0x66) {
|
||||
return { data: buffer, isWxgf: false }
|
||||
}
|
||||
|
||||
// 先尝试搜索内嵌的传统图片签名
|
||||
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) {
|
||||
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
|
||||
return { data: buffer.subarray(i), isWxgf: false }
|
||||
}
|
||||
if (buffer[i] === 0x89 && buffer[i + 1] === 0x50 &&
|
||||
buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47) {
|
||||
return { data: buffer.subarray(i), isWxgf: false }
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 HEVC NALU 裸流
|
||||
const hevcData = this.extractHevcNalu(buffer)
|
||||
if (!hevcData || hevcData.length < 100) {
|
||||
return { data: buffer, isWxgf: true }
|
||||
}
|
||||
|
||||
// 尝试用 ffmpeg 转换
|
||||
try {
|
||||
const jpgData = await this.convertHevcToJpg(hevcData)
|
||||
if (jpgData && jpgData.length > 0) {
|
||||
return { data: jpgData, isWxgf: false }
|
||||
}
|
||||
} catch {
|
||||
// ffmpeg 转换失败
|
||||
}
|
||||
|
||||
return { data: hevcData, isWxgf: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
||||
*/
|
||||
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||
const nalUnits: Buffer[] = []
|
||||
let i = 4
|
||||
|
||||
while (i < buffer.length - 4) {
|
||||
if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) {
|
||||
let nalStart = i
|
||||
let nalEnd = buffer.length
|
||||
|
||||
for (let j = i + 4; j < buffer.length - 3; j++) {
|
||||
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) {
|
||||
if (buffer[j + 2] === 0x01 ||
|
||||
(buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) {
|
||||
nalEnd = j
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nalUnit = buffer.subarray(nalStart, nalEnd)
|
||||
if (nalUnit.length > 3) {
|
||||
nalUnits.push(nalUnit)
|
||||
}
|
||||
i = nalEnd
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
if (nalUnits.length === 0) {
|
||||
for (let j = 4; j < buffer.length - 4; j++) {
|
||||
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 &&
|
||||
buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) {
|
||||
return buffer.subarray(j)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return Buffer.concat(nalUnits)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 ffmpeg 可执行文件路径
|
||||
*/
|
||||
private getFfmpegPath(): string {
|
||||
const staticPath = getStaticFfmpegPath()
|
||||
this.logInfo('ffmpeg 路径检测', { staticPath, exists: staticPath ? existsSync(staticPath) : false })
|
||||
|
||||
if (staticPath) {
|
||||
return staticPath
|
||||
}
|
||||
|
||||
// 回退到系统 ffmpeg
|
||||
return 'ffmpeg'
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
||||
*/
|
||||
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
||||
const ffmpeg = this.getFfmpegPath()
|
||||
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const { spawn } = require('child_process')
|
||||
const chunks: Buffer[] = []
|
||||
const errChunks: Buffer[] = []
|
||||
|
||||
const proc = spawn(ffmpeg, [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-f', 'hevc',
|
||||
'-i', 'pipe:0',
|
||||
'-vframes', '1',
|
||||
'-q:v', '3',
|
||||
'-f', 'mjpeg',
|
||||
'pipe:1'
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
if (code === 0 && chunks.length > 0) {
|
||||
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
|
||||
resolve(Buffer.concat(chunks))
|
||||
} else {
|
||||
const errMsg = Buffer.concat(errChunks).toString()
|
||||
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
|
||||
proc.on('error', (err: Error) => {
|
||||
this.logInfo('ffmpeg 进程错误', { error: err.message })
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
proc.stdin.write(hevcData)
|
||||
proc.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
// 保留原有的解密到文件方法(用于兼容)
|
||||
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
||||
const version = this.getDatVersion(inputPath)
|
||||
@@ -1378,6 +1858,71 @@ export class ImageDecryptService {
|
||||
|
||||
await writeFile(outputPath, decrypted)
|
||||
}
|
||||
|
||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||
this.resolvedCache.clear()
|
||||
this.hardlinkCache.clear()
|
||||
this.pending.clear()
|
||||
this.updateFlags.clear()
|
||||
this.cacheIndexed = false
|
||||
this.cacheIndexing = null
|
||||
|
||||
const configured = this.configService.get('cachePath')
|
||||
const root = configured
|
||||
? join(configured, 'Images')
|
||||
: join(app.getPath('documents'), 'WeFlow', 'Images')
|
||||
|
||||
try {
|
||||
if (!existsSync(root)) {
|
||||
return { success: true }
|
||||
}
|
||||
const monthPattern = /^\d{4}-\d{2}$/
|
||||
const clearFilesInDir = async (dirPath: string): Promise<void> => {
|
||||
let entries: Array<{ name: string; isDirectory: () => boolean }>
|
||||
try {
|
||||
entries = await readdir(dirPath, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await clearFilesInDir(fullPath)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await rm(fullPath, { force: true })
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
const traverse = async (dirPath: string): Promise<void> => {
|
||||
let entries: Array<{ name: string; isDirectory: () => boolean }>
|
||||
try {
|
||||
entries = await readdir(dirPath, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (monthPattern.test(entry.name)) {
|
||||
await clearFilesInDir(fullPath)
|
||||
} else {
|
||||
await traverse(fullPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await rm(fullPath, { force: true })
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
await traverse(root)
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageDecryptService = new ImageDecryptService()
|
||||
|
||||
127
electron/services/isaac64.ts
Normal file
127
electron/services/isaac64.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* ISAAC-64: A fast cryptographic PRNG
|
||||
* Re-implemented in TypeScript using BigInt for 64-bit support.
|
||||
* Used for WeChat Channels/SNS video decryption.
|
||||
*/
|
||||
|
||||
export class Isaac64 {
|
||||
private mm = new BigUint64Array(256);
|
||||
private aa = 0n;
|
||||
private bb = 0n;
|
||||
private cc = 0n;
|
||||
private randrsl = new BigUint64Array(256);
|
||||
private randcnt = 0;
|
||||
private static readonly MASK = 0xFFFFFFFFFFFFFFFFn;
|
||||
|
||||
constructor(seed: number | string | bigint) {
|
||||
const seedBig = BigInt(seed);
|
||||
// 通常单密钥初始化是将密钥放在第一个槽位,其余清零(或者按某种规律填充)
|
||||
// 这里我们尝试仅设置第一个槽位,这在很多 WASM 移植版本中更为常见
|
||||
this.randrsl.fill(0n);
|
||||
this.randrsl[0] = seedBig;
|
||||
this.init(true);
|
||||
}
|
||||
|
||||
private init(flag: boolean) {
|
||||
let a: bigint, b: bigint, c: bigint, d: bigint, e: bigint, f: bigint, g: bigint, h: bigint;
|
||||
a = b = c = d = e = f = g = h = 0x9e3779b97f4a7c15n;
|
||||
|
||||
const mix = () => {
|
||||
a = (a - e) & Isaac64.MASK; f ^= (h >> 9n); h = (h + a) & Isaac64.MASK;
|
||||
b = (b - f) & Isaac64.MASK; g ^= (a << 9n) & Isaac64.MASK; a = (a + b) & Isaac64.MASK;
|
||||
c = (c - g) & Isaac64.MASK; h ^= (b >> 23n); b = (b + c) & Isaac64.MASK;
|
||||
d = (d - h) & Isaac64.MASK; a ^= (c << 15n) & Isaac64.MASK; c = (c + d) & Isaac64.MASK;
|
||||
e = (e - a) & Isaac64.MASK; b ^= (d >> 14n); d = (d + e) & Isaac64.MASK;
|
||||
f = (f - b) & Isaac64.MASK; c ^= (e << 20n) & Isaac64.MASK; e = (e + f) & Isaac64.MASK;
|
||||
g = (g - c) & Isaac64.MASK; d ^= (f >> 17n); f = (f + g) & Isaac64.MASK;
|
||||
h = (h - d) & Isaac64.MASK; e ^= (g << 14n) & Isaac64.MASK; g = (g + h) & Isaac64.MASK;
|
||||
};
|
||||
|
||||
for (let i = 0; i < 4; i++) mix();
|
||||
|
||||
for (let i = 0; i < 256; i += 8) {
|
||||
if (flag) {
|
||||
a = (a + this.randrsl[i]) & Isaac64.MASK;
|
||||
b = (b + this.randrsl[i + 1]) & Isaac64.MASK;
|
||||
c = (c + this.randrsl[i + 2]) & Isaac64.MASK;
|
||||
d = (d + this.randrsl[i + 3]) & Isaac64.MASK;
|
||||
e = (e + this.randrsl[i + 4]) & Isaac64.MASK;
|
||||
f = (f + this.randrsl[i + 5]) & Isaac64.MASK;
|
||||
g = (g + this.randrsl[i + 6]) & Isaac64.MASK;
|
||||
h = (h + this.randrsl[i + 7]) & Isaac64.MASK;
|
||||
}
|
||||
mix();
|
||||
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
|
||||
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
|
||||
}
|
||||
|
||||
if (flag) {
|
||||
for (let i = 0; i < 256; i += 8) {
|
||||
a = (a + this.mm[i]) & Isaac64.MASK;
|
||||
b = (b + this.mm[i + 1]) & Isaac64.MASK;
|
||||
c = (c + this.mm[i + 2]) & Isaac64.MASK;
|
||||
d = (d + this.mm[i + 3]) & Isaac64.MASK;
|
||||
e = (e + this.mm[i + 4]) & Isaac64.MASK;
|
||||
f = (f + this.mm[i + 5]) & Isaac64.MASK;
|
||||
g = (g + this.mm[i + 6]) & Isaac64.MASK;
|
||||
h = (h + this.mm[i + 7]) & Isaac64.MASK;
|
||||
mix();
|
||||
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
|
||||
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
|
||||
}
|
||||
}
|
||||
|
||||
this.isaac64();
|
||||
this.randcnt = 256;
|
||||
}
|
||||
|
||||
private isaac64() {
|
||||
this.cc = (this.cc + 1n) & Isaac64.MASK;
|
||||
this.bb = (this.bb + this.cc) & Isaac64.MASK;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let x = this.mm[i];
|
||||
switch (i & 3) {
|
||||
case 0: this.aa = (this.aa ^ (((this.aa << 21n) & Isaac64.MASK) ^ Isaac64.MASK)) & Isaac64.MASK; break;
|
||||
case 1: this.aa = (this.aa ^ (this.aa >> 5n)) & Isaac64.MASK; break;
|
||||
case 2: this.aa = (this.aa ^ ((this.aa << 12n) & Isaac64.MASK)) & Isaac64.MASK; break;
|
||||
case 3: this.aa = (this.aa ^ (this.aa >> 33n)) & Isaac64.MASK; break;
|
||||
}
|
||||
this.aa = (this.mm[(i + 128) & 255] + this.aa) & Isaac64.MASK;
|
||||
const y = (this.mm[Number(x >> 3n) & 255] + this.aa + this.bb) & Isaac64.MASK;
|
||||
this.mm[i] = y;
|
||||
this.bb = (this.mm[Number(y >> 11n) & 255] + x) & Isaac64.MASK;
|
||||
this.randrsl[i] = this.bb;
|
||||
}
|
||||
}
|
||||
|
||||
public getNext(): bigint {
|
||||
if (this.randcnt === 0) {
|
||||
this.isaac64();
|
||||
this.randcnt = 256;
|
||||
}
|
||||
return this.randrsl[--this.randcnt];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a keystream where each 64-bit block is Big-Endian.
|
||||
* This matches WeChat's behavior (Reverse index order + byte reversal).
|
||||
*/
|
||||
public generateKeystreamBE(size: number): Buffer {
|
||||
const buffer = Buffer.allocUnsafe(size);
|
||||
const fullBlocks = Math.floor(size / 8);
|
||||
|
||||
for (let i = 0; i < fullBlocks; i++) {
|
||||
buffer.writeBigUInt64BE(this.getNext(), i * 8);
|
||||
}
|
||||
|
||||
const remaining = size % 8;
|
||||
if (remaining > 0) {
|
||||
const lastK = this.getNext();
|
||||
const temp = Buffer.allocUnsafe(8);
|
||||
temp.writeBigUInt64BE(lastK, 0);
|
||||
temp.copy(buffer, fullBlocks * 8, 0, remaining);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { app } from 'electron'
|
||||
import { join, dirname, basename } from 'path'
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
import os from 'os'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -32,6 +33,7 @@ export class KeyService {
|
||||
private ReadProcessMemory: any = null
|
||||
private MEMORY_BASIC_INFORMATION: any = null
|
||||
private TerminateProcess: any = null
|
||||
private QueryFullProcessImageNameW: any = null
|
||||
|
||||
// User32
|
||||
private EnumWindows: any = null
|
||||
@@ -41,6 +43,7 @@ export class KeyService {
|
||||
private GetWindowThreadProcessId: any = null
|
||||
private IsWindowVisible: any = null
|
||||
private EnumChildWindows: any = null
|
||||
private PostMessageW: any = null
|
||||
private WNDENUMPROC_PTR: any = null
|
||||
|
||||
// Advapi32
|
||||
@@ -55,20 +58,97 @@ export class KeyService {
|
||||
private readonly HKEY_LOCAL_MACHINE = 0x80000002
|
||||
private readonly HKEY_CURRENT_USER = 0x80000001
|
||||
private readonly ERROR_SUCCESS = 0
|
||||
private readonly WM_CLOSE = 0x0010
|
||||
|
||||
private getDllPath(): string {
|
||||
const resourcesPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
return join(resourcesPath, 'wx_key.dll')
|
||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||
|
||||
// 候选路径列表
|
||||
const candidates: string[] = []
|
||||
|
||||
// 1. 显式环境变量 (最高优先级)
|
||||
if (process.env.WX_KEY_DLL_PATH) {
|
||||
candidates.push(process.env.WX_KEY_DLL_PATH)
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
// 生产环境: 通常在 resources 目录下,但也可能直接在 resources 根目录
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
||||
} else {
|
||||
// 开发环境
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
||||
}
|
||||
|
||||
// 检查并返回第一个存在的路径
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都没找到,返回最可能的路径以便报错信息有参考
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
// 检查路径是否为 UNC 路径或网络路径
|
||||
private isNetworkPath(path: string): boolean {
|
||||
// UNC 路径以 \\ 开头
|
||||
if (path.startsWith('\\\\')) {
|
||||
return true
|
||||
}
|
||||
// 检查是否为网络映射驱动器(简化检测:A: 表示驱动器)
|
||||
// 注意:这是一个启发式检测,更准确的方式需要调用 GetDriveType Windows API
|
||||
// 但对于大多数 VM 共享场景,UNC 路径检测已足够
|
||||
return false
|
||||
}
|
||||
|
||||
// 将 DLL 复制到本地临时目录
|
||||
private localizeNetworkDll(originalPath: string): string {
|
||||
try {
|
||||
const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
|
||||
if (!existsSync(tempDir)) {
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
const localPath = join(tempDir, 'wx_key.dll')
|
||||
|
||||
// 检查是否已经有本地副本,如果有就使用它
|
||||
if (existsSync(localPath)) {
|
||||
|
||||
return localPath
|
||||
}
|
||||
|
||||
|
||||
copyFileSync(originalPath, localPath)
|
||||
|
||||
return localPath
|
||||
} catch (e) {
|
||||
console.error('DLL 本地化失败:', e)
|
||||
// 如果本地化失败,返回原路径
|
||||
return originalPath
|
||||
}
|
||||
}
|
||||
|
||||
private ensureLoaded(): boolean {
|
||||
if (this.initialized) return true
|
||||
|
||||
let dllPath = ''
|
||||
try {
|
||||
this.koffi = require('koffi')
|
||||
const dllPath = this.getDllPath()
|
||||
if (!existsSync(dllPath)) return false
|
||||
dllPath = this.getDllPath()
|
||||
|
||||
if (!existsSync(dllPath)) {
|
||||
console.error(`wx_key.dll 不存在于路径: ${dllPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否为网络路径,如果是则本地化
|
||||
if (this.isNetworkPath(dllPath)) {
|
||||
|
||||
dllPath = this.localizeNetworkDll(dllPath)
|
||||
}
|
||||
|
||||
this.lib = this.koffi.load(dllPath)
|
||||
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
|
||||
@@ -80,7 +160,14 @@ export class KeyService {
|
||||
this.initialized = true
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('加载 wx_key.dll 失败:', e)
|
||||
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||
const errorStack = e instanceof Error ? e.stack : ''
|
||||
console.error(`加载 wx_key.dll 失败`)
|
||||
console.error(` 路径: ${dllPath}`)
|
||||
console.error(` 错误: ${errorMsg}`)
|
||||
if (errorStack) {
|
||||
console.error(` 堆栈: ${errorStack}`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -110,6 +197,7 @@ export class KeyService {
|
||||
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
|
||||
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
|
||||
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
|
||||
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
|
||||
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
|
||||
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
|
||||
|
||||
@@ -138,6 +226,7 @@ export class KeyService {
|
||||
|
||||
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
|
||||
|
||||
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
||||
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
|
||||
@@ -226,7 +315,46 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getProcessExecutablePath(pid: number): Promise<string | null> {
|
||||
if (!this.ensureKernel32()) return null
|
||||
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
|
||||
const hProcess = this.OpenProcess(0x1000, false, pid)
|
||||
if (!hProcess) return null
|
||||
|
||||
try {
|
||||
const sizeBuf = Buffer.alloc(4)
|
||||
sizeBuf.writeUInt32LE(1024, 0)
|
||||
const pathBuf = Buffer.alloc(1024 * 2)
|
||||
|
||||
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
|
||||
if (ret) {
|
||||
const len = sizeBuf.readUInt32LE(0)
|
||||
return pathBuf.toString('ucs2', 0, len * 2)
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
console.error('获取进程路径失败:', e)
|
||||
return null
|
||||
} finally {
|
||||
this.CloseHandle(hProcess)
|
||||
}
|
||||
}
|
||||
|
||||
private async findWeChatInstallPath(): Promise<string | null> {
|
||||
// 0. 优先尝试获取正在运行的微信进程路径
|
||||
try {
|
||||
const pid = await this.findWeChatPid()
|
||||
if (pid) {
|
||||
const runPath = await this.getProcessExecutablePath(pid)
|
||||
if (runPath && existsSync(runPath)) {
|
||||
|
||||
return runPath
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('尝试获取运行中微信路径失败:', e)
|
||||
}
|
||||
|
||||
// 1. Registry - Uninstall Keys
|
||||
const uninstallKeys = [
|
||||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||
@@ -312,16 +440,60 @@ export class KeyService {
|
||||
return fallbackPid ?? null
|
||||
}
|
||||
|
||||
private async killWeChatProcesses() {
|
||||
private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const weixinPid = await this.findPidByImageName('Weixin.exe')
|
||||
const wechatPid = await this.findPidByImageName('WeChat.exe')
|
||||
if (!weixinPid && !wechatPid) return true
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async closeWeChatWindows(): Promise<boolean> {
|
||||
if (!this.ensureUser32()) return false
|
||||
let requested = false
|
||||
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
|
||||
if (!this.IsWindowVisible(hWnd)) return true
|
||||
const title = this.getWindowTitle(hWnd)
|
||||
const className = this.getClassName(hWnd)
|
||||
const classLower = (className || '').toLowerCase()
|
||||
const isWeChatWindow = this.isWeChatWindowTitle(title) || classLower.includes('wechat') || classLower.includes('weixin')
|
||||
if (!isWeChatWindow) return true
|
||||
|
||||
requested = true
|
||||
try {
|
||||
await execFileAsync('taskkill', ['/F', '/IM', 'Weixin.exe'])
|
||||
await execFileAsync('taskkill', ['/F', '/IM', 'WeChat.exe'])
|
||||
this.PostMessageW?.(hWnd, this.WM_CLOSE, 0, 0)
|
||||
} catch { }
|
||||
return true
|
||||
}, this.WNDENUMPROC_PTR)
|
||||
|
||||
this.EnumWindows(enumWindowsCallback, 0)
|
||||
this.koffi.unregister(enumWindowsCallback)
|
||||
|
||||
return requested
|
||||
}
|
||||
|
||||
private async killWeChatProcesses(): Promise<boolean> {
|
||||
const requested = await this.closeWeChatWindows()
|
||||
if (requested) {
|
||||
const gracefulOk = await this.waitForWeChatExit(1500)
|
||||
if (gracefulOk) return true
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
|
||||
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
|
||||
} catch (e) {
|
||||
// Ignore if not found
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
return await this.waitForWeChatExit(5000)
|
||||
}
|
||||
|
||||
|
||||
// --- Window Detection ---
|
||||
|
||||
private getWindowTitle(hWnd: any): string {
|
||||
@@ -480,15 +652,24 @@ export class KeyService {
|
||||
}
|
||||
|
||||
// 2. Restart WeChat
|
||||
onStatus?.('正在重启微信以进行获取...', 0)
|
||||
await this.killWeChatProcesses()
|
||||
onStatus?.('正在关闭微信以进行获取...', 0)
|
||||
const closed = await this.killWeChatProcesses()
|
||||
if (!closed) {
|
||||
const err = '无法自动关闭微信,请手动退出后重试'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
// 3. Launch
|
||||
// 3. Launch
|
||||
onStatus?.('正在启动微信...', 0)
|
||||
const sub = spawn(wechatPath, { detached: true, stdio: 'ignore' })
|
||||
const sub = spawn(wechatPath, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: dirname(wechatPath)
|
||||
})
|
||||
sub.unref()
|
||||
|
||||
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
|
||||
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
|
||||
onStatus?.('等待微信界面就绪...', 0)
|
||||
const pid = await this.waitForWeChatWindow()
|
||||
if (!pid) {
|
||||
@@ -504,6 +685,11 @@ export class KeyService {
|
||||
if (!ok) {
|
||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||
if (error) {
|
||||
// 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED)
|
||||
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
|
||||
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||
return { success: false, error: friendlyError }
|
||||
}
|
||||
return { success: false, error }
|
||||
}
|
||||
const statusBuffer = Buffer.alloc(256)
|
||||
@@ -695,33 +881,41 @@ export class KeyService {
|
||||
}
|
||||
|
||||
private getXorKey(templateFiles: string[]): number | null {
|
||||
const counts = new Map<string, number>()
|
||||
const counts = new Map<number, number>()
|
||||
const tailSignatures = [
|
||||
Buffer.from([0xFF, 0xD9]),
|
||||
Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82])
|
||||
]
|
||||
for (const file of templateFiles) {
|
||||
try {
|
||||
const bytes = readFileSync(file)
|
||||
if (bytes.length < 2) continue
|
||||
const x = bytes[bytes.length - 2]
|
||||
const y = bytes[bytes.length - 1]
|
||||
const key = `${x}_${y}`
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
for (const signature of tailSignatures) {
|
||||
if (bytes.length < signature.length) continue
|
||||
const tail = bytes.subarray(bytes.length - signature.length)
|
||||
const xorKey = tail[0] ^ signature[0]
|
||||
let valid = true
|
||||
for (let i = 1; i < signature.length; i++) {
|
||||
if ((tail[i] ^ xorKey) !== signature[i]) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
if (!counts.size) return null
|
||||
let mostKey = ''
|
||||
let mostCount = 0
|
||||
let bestKey: number | null = null
|
||||
let bestCount = 0
|
||||
for (const [key, count] of counts) {
|
||||
if (count > mostCount) {
|
||||
mostCount = count
|
||||
mostKey = key
|
||||
if (count > bestCount) {
|
||||
bestCount = count
|
||||
bestKey = key
|
||||
}
|
||||
}
|
||||
if (!mostKey) return null
|
||||
const [xStr, yStr] = mostKey.split('_')
|
||||
const x = Number(xStr)
|
||||
const y = Number(yStr)
|
||||
const xorKey = x ^ 0xFF
|
||||
const check = y ^ 0xD9
|
||||
return xorKey === check ? xorKey : null
|
||||
return bestKey
|
||||
}
|
||||
|
||||
private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null {
|
||||
@@ -744,16 +938,17 @@ export class KeyService {
|
||||
return null
|
||||
}
|
||||
|
||||
private isAlphaNumAscii(byte: number): boolean {
|
||||
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39)
|
||||
private isAlphaNumLower(byte: number): boolean {
|
||||
// 只匹配小写字母 a-z 和数字 0-9(AES密钥格式)
|
||||
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39)
|
||||
}
|
||||
|
||||
private isUtf16AsciiKey(buf: Buffer, start: number): boolean {
|
||||
private isUtf16LowerKey(buf: Buffer, start: number): boolean {
|
||||
if (start + 64 > buf.length) return false
|
||||
for (let j = 0; j < 32; j++) {
|
||||
const charByte = buf[start + j * 2]
|
||||
const nullByte = buf[start + j * 2 + 1]
|
||||
if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) {
|
||||
if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -766,7 +961,17 @@ export class KeyService {
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
|
||||
decipher.setAutoPadding(false)
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
return decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff
|
||||
const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff
|
||||
const isPng = decrypted.length >= 8 &&
|
||||
decrypted[0] === 0x89 &&
|
||||
decrypted[1] === 0x50 &&
|
||||
decrypted[2] === 0x4e &&
|
||||
decrypted[3] === 0x47 &&
|
||||
decrypted[4] === 0x0d &&
|
||||
decrypted[5] === 0x0a &&
|
||||
decrypted[6] === 0x1a &&
|
||||
decrypted[7] === 0x0a
|
||||
return isJpeg || isPng
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -776,8 +981,6 @@ export class KeyService {
|
||||
const regions: Array<[number, number]> = []
|
||||
const MEM_COMMIT = 0x1000
|
||||
const MEM_PRIVATE = 0x20000
|
||||
const MEM_MAPPED = 0x40000
|
||||
const MEM_IMAGE = 0x1000000
|
||||
const PAGE_NOACCESS = 0x01
|
||||
const PAGE_GUARD = 0x100
|
||||
|
||||
@@ -792,11 +995,10 @@ export class KeyService {
|
||||
const protect = info.Protect
|
||||
const type = info.Type
|
||||
const regionSize = Number(info.RegionSize)
|
||||
if (state === MEM_COMMIT && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
|
||||
if (type === MEM_PRIVATE || type === MEM_MAPPED || type === MEM_IMAGE) {
|
||||
// 只收集已提交的私有内存(大幅减少扫描区域)
|
||||
if (state === MEM_COMMIT && type === MEM_PRIVATE && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
|
||||
regions.push([Number(info.BaseAddress), regionSize])
|
||||
}
|
||||
}
|
||||
|
||||
const nextAddress = address + regionSize
|
||||
if (nextAddress <= address) break
|
||||
@@ -813,72 +1015,63 @@ export class KeyService {
|
||||
return buffer.subarray(0, bytesRead[0])
|
||||
}
|
||||
|
||||
private async getAesKeyFromMemory(pid: number, ciphertext: Buffer): Promise<string | null> {
|
||||
private async getAesKeyFromMemory(
|
||||
pid: number,
|
||||
ciphertext: Buffer,
|
||||
onProgress?: (current: number, total: number, message: string) => void
|
||||
): Promise<string | null> {
|
||||
if (!this.ensureKernel32()) return null
|
||||
const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid)
|
||||
if (!hProcess) return null
|
||||
|
||||
try {
|
||||
const regions = this.getMemoryRegions(hProcess)
|
||||
const chunkSize = 4 * 1024 * 1024
|
||||
const overlap = 65
|
||||
for (const [baseAddress, regionSize] of regions) {
|
||||
if (regionSize > 100 * 1024 * 1024) continue
|
||||
let offset = 0
|
||||
let trailing: Buffer | null = null
|
||||
while (offset < regionSize) {
|
||||
const remaining = regionSize - offset
|
||||
const currentChunkSize = remaining > chunkSize ? chunkSize : remaining
|
||||
const chunk = this.readProcessMemory(hProcess, baseAddress + offset, currentChunkSize)
|
||||
if (!chunk || !chunk.length) {
|
||||
offset += currentChunkSize
|
||||
trailing = null
|
||||
const allRegions = this.getMemoryRegions(hProcess)
|
||||
const totalRegions = allRegions.length
|
||||
let scannedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
for (const [baseAddress, regionSize] of allRegions) {
|
||||
// 跳过太大的内存区域(> 100MB)
|
||||
if (regionSize > 100 * 1024 * 1024) {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
let dataToScan: Buffer
|
||||
if (trailing && trailing.length) {
|
||||
dataToScan = Buffer.concat([trailing, chunk])
|
||||
} else {
|
||||
dataToScan = chunk
|
||||
scannedCount++
|
||||
if (scannedCount % 10 === 0) {
|
||||
onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
for (let i = 0; i < dataToScan.length - 34; i++) {
|
||||
if (this.isAlphaNumAscii(dataToScan[i])) continue
|
||||
const memory = this.readProcessMemory(hProcess, baseAddress, regionSize)
|
||||
if (!memory) continue
|
||||
|
||||
// 直接在原始字节中搜索32字节的小写字母数字序列
|
||||
for (let i = 0; i < memory.length - 34; i++) {
|
||||
// 检查前导字符(不是小写字母或数字)
|
||||
if (this.isAlphaNumLower(memory[i])) continue
|
||||
|
||||
// 检查接下来32个字节是否都是小写字母或数字
|
||||
let valid = true
|
||||
for (let j = 1; j <= 32; j++) {
|
||||
if (!this.isAlphaNumAscii(dataToScan[i + j])) {
|
||||
if (!this.isAlphaNumLower(memory[i + j])) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) {
|
||||
valid = false
|
||||
if (!valid) continue
|
||||
|
||||
// 检查尾部字符(不是小写字母或数字)
|
||||
if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) {
|
||||
continue
|
||||
}
|
||||
if (valid) {
|
||||
const keyBytes = dataToScan.subarray(i + 1, i + 33)
|
||||
|
||||
const keyBytes = memory.subarray(i + 1, i + 33)
|
||||
if (this.verifyKey(ciphertext, keyBytes)) {
|
||||
return keyBytes.toString('ascii')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < dataToScan.length - 65; i++) {
|
||||
if (!this.isUtf16AsciiKey(dataToScan, i)) continue
|
||||
const keyBytes = Buffer.alloc(32)
|
||||
for (let j = 0; j < 32; j++) {
|
||||
keyBytes[j] = dataToScan[i + j * 2]
|
||||
}
|
||||
if (this.verifyKey(ciphertext, keyBytes)) {
|
||||
return keyBytes.toString('ascii')
|
||||
}
|
||||
}
|
||||
|
||||
const start = dataToScan.length - overlap
|
||||
trailing = dataToScan.subarray(start < 0 ? 0 : start)
|
||||
offset += currentChunkSize
|
||||
}
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
try {
|
||||
@@ -915,7 +1108,9 @@ export class KeyService {
|
||||
if (!pid) return { success: false, error: '未检测到微信进程' }
|
||||
|
||||
onProgress?.('正在扫描内存获取 AES 密钥...')
|
||||
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext)
|
||||
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext, (current, total, msg) => {
|
||||
onProgress?.(`${msg} (${current}/${total})`)
|
||||
})
|
||||
if (!aesKey) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
371
electron/services/llamaService.ts
Normal file
371
electron/services/llamaService.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import fs from "fs";
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import path from "path";
|
||||
import { ConfigService } from './config';
|
||||
|
||||
// Define interfaces locally to avoid static import of types that might not be available or cause issues
|
||||
type LlamaModel = any;
|
||||
type LlamaContext = any;
|
||||
type LlamaChatSession = any;
|
||||
|
||||
export class LlamaService {
|
||||
private _model: LlamaModel | null = null;
|
||||
private _context: LlamaContext | null = null;
|
||||
private _sequence: any = null;
|
||||
private _session: LlamaChatSession | null = null;
|
||||
private _llama: any = null;
|
||||
private _nodeLlamaCpp: any = null;
|
||||
private configService = new ConfigService();
|
||||
private _initialized = false;
|
||||
|
||||
constructor() {
|
||||
// 延迟初始化,只在需要时初始化
|
||||
}
|
||||
|
||||
public async init() {
|
||||
if (this._initialized) return;
|
||||
|
||||
try {
|
||||
// Dynamic import to handle ESM module in CJS context
|
||||
this._nodeLlamaCpp = await import("node-llama-cpp");
|
||||
this._llama = await this._nodeLlamaCpp.getLlama();
|
||||
this._initialized = true;
|
||||
console.log("[LlamaService] Llama initialized");
|
||||
} catch (error) {
|
||||
console.error("[LlamaService] Failed to initialize Llama:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public async loadModel(modelPath: string) {
|
||||
if (!this._llama) await this.init();
|
||||
|
||||
try {
|
||||
console.log("[LlamaService] Loading model from:", modelPath);
|
||||
if (!this._llama) {
|
||||
throw new Error("Llama not initialized");
|
||||
}
|
||||
this._model = await this._llama.loadModel({
|
||||
modelPath: modelPath,
|
||||
gpuLayers: 'max', // Offload all layers to GPU if possible
|
||||
useMlock: false // Disable mlock to avoid "VirtualLock" errors (common on Windows)
|
||||
});
|
||||
|
||||
if (!this._model) throw new Error("Failed to load model");
|
||||
|
||||
this._context = await this._model.createContext({
|
||||
contextSize: 8192, // Balanced context size for better performance
|
||||
batchSize: 2048 // Increase batch size for better prompt processing speed
|
||||
});
|
||||
|
||||
if (!this._context) throw new Error("Failed to create context");
|
||||
|
||||
this._sequence = this._context.getSequence();
|
||||
|
||||
const { LlamaChatSession } = this._nodeLlamaCpp;
|
||||
this._session = new LlamaChatSession({
|
||||
contextSequence: this._sequence
|
||||
});
|
||||
|
||||
console.log("[LlamaService] Model loaded successfully");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[LlamaService] Failed to load model:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async createSession(systemPrompt?: string) {
|
||||
if (!this._context) throw new Error("Model not loaded");
|
||||
if (!this._nodeLlamaCpp) await this.init();
|
||||
|
||||
const { LlamaChatSession } = this._nodeLlamaCpp;
|
||||
|
||||
if (!this._sequence) {
|
||||
this._sequence = this._context.getSequence();
|
||||
}
|
||||
|
||||
this._session = new LlamaChatSession({
|
||||
contextSequence: this._sequence,
|
||||
systemPrompt: systemPrompt
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async chat(message: string, options: { thinking?: boolean } = {}, onToken: (token: string) => void) {
|
||||
if (!this._session) throw new Error("Session not initialized");
|
||||
|
||||
const thinking = options.thinking ?? false;
|
||||
|
||||
// Sampling parameters based on mode
|
||||
const samplingParams = thinking ? {
|
||||
temperature: 0.6,
|
||||
topP: 0.95,
|
||||
topK: 20,
|
||||
repeatPenalty: 1.5 // PresencePenalty=1.5
|
||||
} : {
|
||||
temperature: 0.7,
|
||||
topP: 0.8,
|
||||
topK: 20,
|
||||
repeatPenalty: 1.5
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this._session.prompt(message, {
|
||||
...samplingParams,
|
||||
onTextChunk: (chunk: string) => {
|
||||
onToken(chunk);
|
||||
}
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("[LlamaService] Chat error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getModelStatus(modelPath: string) {
|
||||
try {
|
||||
const exists = fs.existsSync(modelPath);
|
||||
if (!exists) {
|
||||
return { exists: false, path: modelPath };
|
||||
}
|
||||
const stats = fs.statSync(modelPath);
|
||||
return {
|
||||
exists: true,
|
||||
path: modelPath,
|
||||
size: stats.size
|
||||
};
|
||||
} catch (error) {
|
||||
return { exists: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
private resolveModelDir(): string {
|
||||
const configured = this.configService.get('whisperModelDir') as string | undefined;
|
||||
if (configured) return configured;
|
||||
return path.join(app.getPath('documents'), 'WeFlow', 'models');
|
||||
}
|
||||
|
||||
public async downloadModel(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(savePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
console.info(`[LlamaService] Multi-threaded download check for: ${savePath}`);
|
||||
|
||||
if (fs.existsSync(savePath)) {
|
||||
fs.unlinkSync(savePath);
|
||||
}
|
||||
|
||||
// 1. Get total size and check range support
|
||||
let probeResult;
|
||||
try {
|
||||
probeResult = await this.probeUrl(url);
|
||||
} catch (err) {
|
||||
console.warn("[LlamaService] Probe failed, falling back to single-thread.", err);
|
||||
return this.downloadSingleThread(url, savePath, onProgress);
|
||||
}
|
||||
|
||||
const { totalSize, acceptRanges, finalUrl } = probeResult;
|
||||
console.log(`[LlamaService] Total size: ${totalSize}, Accept-Ranges: ${acceptRanges}`);
|
||||
|
||||
if (totalSize <= 0 || !acceptRanges) {
|
||||
console.warn("[LlamaService] Ranges not supported or size unknown, falling back to single-thread.");
|
||||
return this.downloadSingleThread(finalUrl, savePath, onProgress);
|
||||
}
|
||||
|
||||
const threadCount = 4;
|
||||
const chunkSize = Math.ceil(totalSize / threadCount);
|
||||
const fd = fs.openSync(savePath, 'w');
|
||||
|
||||
let downloadedLength = 0;
|
||||
let lastDownloadedLength = 0;
|
||||
let lastTime = Date.now();
|
||||
let speed = 0;
|
||||
|
||||
const speedInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const duration = (now - lastTime) / 1000;
|
||||
if (duration > 0) {
|
||||
speed = (downloadedLength - lastDownloadedLength) / duration;
|
||||
lastDownloadedLength = downloadedLength;
|
||||
lastTime = now;
|
||||
onProgress({ downloaded: downloadedLength, total: totalSize, speed });
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
const promises = [];
|
||||
for (let i = 0; i < threadCount; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1;
|
||||
|
||||
promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => {
|
||||
downloadedLength += bytes;
|
||||
}));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
console.log("[LlamaService] Multi-threaded download complete");
|
||||
|
||||
// Final progress update
|
||||
onProgress({ downloaded: totalSize, total: totalSize, speed: 0 });
|
||||
} catch (err) {
|
||||
console.error("[LlamaService] Multi-threaded download failed:", err);
|
||||
throw err;
|
||||
} finally {
|
||||
clearInterval(speedInterval);
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
private async probeUrl(url: string): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
|
||||
const protocol = url.startsWith('https') ? require('https') : require('http');
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.modelscope.cn/',
|
||||
'Range': 'bytes=0-0'
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = protocol.get(url, options, (res: any) => {
|
||||
if ([301, 302, 307, 308].includes(res.statusCode)) {
|
||||
const location = res.headers.location;
|
||||
const nextUrl = new URL(location, url).href;
|
||||
this.probeUrl(nextUrl).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 206 && res.statusCode !== 200) {
|
||||
reject(new Error(`Probe failed: HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const contentRange = res.headers['content-range'];
|
||||
let totalSize = 0;
|
||||
if (contentRange) {
|
||||
const parts = contentRange.split('/');
|
||||
totalSize = parseInt(parts[parts.length - 1], 10);
|
||||
} else {
|
||||
totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
||||
}
|
||||
|
||||
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange;
|
||||
resolve({ totalSize, acceptRanges, finalUrl: url });
|
||||
res.destroy();
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
|
||||
const protocol = url.startsWith('https') ? require('https') : require('http');
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.modelscope.cn/',
|
||||
'Range': `bytes=${start}-${end}`
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = protocol.get(url, options, (res: any) => {
|
||||
if (res.statusCode !== 206) {
|
||||
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let currentOffset = start;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
try {
|
||||
fs.writeSync(fd, chunk, 0, chunk.length, currentOffset);
|
||||
currentOffset += chunk.length;
|
||||
onData(chunk.length);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => resolve());
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async downloadSingleThread(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? require('https') : require('http');
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.modelscope.cn/'
|
||||
}
|
||||
};
|
||||
|
||||
const request = protocol.get(url, options, (response: any) => {
|
||||
if ([301, 302, 307, 308].includes(response.statusCode)) {
|
||||
const location = response.headers.location;
|
||||
const nextUrl = new URL(location, url).href;
|
||||
this.downloadSingleThread(nextUrl, savePath, onProgress).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalLength = parseInt(response.headers['content-length'] || '0', 10);
|
||||
let downloadedLength = 0;
|
||||
let lastDownloadedLength = 0;
|
||||
let lastTime = Date.now();
|
||||
let speed = 0;
|
||||
|
||||
const fileStream = fs.createWriteStream(savePath);
|
||||
response.pipe(fileStream);
|
||||
|
||||
const speedInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const duration = (now - lastTime) / 1000;
|
||||
if (duration > 0) {
|
||||
speed = (downloadedLength - lastDownloadedLength) / duration;
|
||||
lastDownloadedLength = downloadedLength;
|
||||
lastTime = now;
|
||||
onProgress({ downloaded: downloadedLength, total: totalLength, speed });
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
response.on('data', (chunk: any) => {
|
||||
downloadedLength += chunk.length;
|
||||
});
|
||||
|
||||
fileStream.on('finish', () => {
|
||||
clearInterval(speedInterval);
|
||||
fileStream.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
fileStream.on('error', (err: any) => {
|
||||
clearInterval(speedInterval);
|
||||
fs.unlink(savePath, () => { });
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
public getModelsPath() {
|
||||
return this.resolveModelDir();
|
||||
}
|
||||
}
|
||||
|
||||
export const llamaService = new LlamaService();
|
||||
78
electron/services/messageCacheService.ts
Normal file
78
electron/services/messageCacheService.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
export interface SessionMessageCacheEntry {
|
||||
updatedAt: number
|
||||
messages: any[]
|
||||
}
|
||||
|
||||
export class MessageCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private cache: Record<string, SessionMessageCacheEntry> = {}
|
||||
private readonly sessionLimit = 150
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: ConfigService.getInstance().getCacheBasePath()
|
||||
this.cacheFilePath = join(basePath, 'session-messages.json')
|
||||
this.ensureCacheDir()
|
||||
this.loadCache()
|
||||
}
|
||||
|
||||
private ensureCacheDir() {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private loadCache() {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.cache = parsed
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MessageCacheService: 载入缓存失败', error)
|
||||
this.cache = {}
|
||||
}
|
||||
}
|
||||
|
||||
get(sessionId: string): SessionMessageCacheEntry | undefined {
|
||||
return this.cache[sessionId]
|
||||
}
|
||||
|
||||
set(sessionId: string, messages: any[]): void {
|
||||
if (!sessionId) return
|
||||
const trimmed = messages.length > this.sessionLimit
|
||||
? messages.slice(-this.sessionLimit)
|
||||
: messages.slice()
|
||||
this.cache[sessionId] = {
|
||||
updatedAt: Date.now(),
|
||||
messages: trimmed
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('MessageCacheService: 保存缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache = {}
|
||||
try {
|
||||
rmSync(this.cacheFilePath, { force: true })
|
||||
} catch (error) {
|
||||
console.error('MessageCacheService: 清理缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
850
electron/services/snsService.ts
Normal file
850
electron/services/snsService.ts
Normal file
@@ -0,0 +1,850 @@
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { ContactCacheService } from './contactCacheService'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import crypto from 'crypto'
|
||||
import { WasmService } from './wasmService'
|
||||
|
||||
export interface SnsLivePhoto {
|
||||
url: string
|
||||
thumb: string
|
||||
md5?: string
|
||||
token?: string
|
||||
key?: string
|
||||
encIdx?: string
|
||||
}
|
||||
|
||||
export interface SnsMedia {
|
||||
url: string
|
||||
thumb: string
|
||||
md5?: string
|
||||
token?: string
|
||||
key?: string
|
||||
encIdx?: string
|
||||
livePhoto?: SnsLivePhoto
|
||||
}
|
||||
|
||||
export interface SnsPost {
|
||||
id: string
|
||||
username: string
|
||||
nickname: string
|
||||
avatarUrl?: string
|
||||
createTime: number
|
||||
contentDesc: string
|
||||
type?: number
|
||||
media: SnsMedia[]
|
||||
likes: string[]
|
||||
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
||||
rawXml?: string
|
||||
linkTitle?: string
|
||||
linkUrl?: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
|
||||
if (!url) return url
|
||||
|
||||
let fixedUrl = url.replace('http://', 'https://')
|
||||
|
||||
// 只有非视频(即图片)才需要处理 /150 变 /0
|
||||
if (!isVideo) {
|
||||
fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1')
|
||||
}
|
||||
|
||||
if (!token || fixedUrl.includes('token=')) return fixedUrl
|
||||
|
||||
// 根据用户要求,视频链接组合方式为: BASE_URL + "?" + "token=" + token + "&idx=1" + 原有参数
|
||||
if (isVideo) {
|
||||
const urlParts = fixedUrl.split('?')
|
||||
const baseUrl = urlParts[0]
|
||||
const existingParams = urlParts[1] ? `&${urlParts[1]}` : ''
|
||||
return `${baseUrl}?token=${token}&idx=1${existingParams}`
|
||||
}
|
||||
|
||||
const connector = fixedUrl.includes('?') ? '&' : '?'
|
||||
return `${fixedUrl}${connector}token=${token}&idx=1`
|
||||
}
|
||||
|
||||
const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
|
||||
if (!buf || buf.length < 4) return fallback
|
||||
|
||||
// JPEG
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
|
||||
|
||||
// PNG
|
||||
if (
|
||||
buf.length >= 8 &&
|
||||
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
|
||||
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
|
||||
) return 'image/png'
|
||||
|
||||
// GIF
|
||||
if (buf.length >= 6) {
|
||||
const sig = buf.subarray(0, 6).toString('ascii')
|
||||
if (sig === 'GIF87a' || sig === 'GIF89a') return 'image/gif'
|
||||
}
|
||||
|
||||
// WebP
|
||||
if (
|
||||
buf.length >= 12 &&
|
||||
buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
|
||||
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50
|
||||
) return 'image/webp'
|
||||
|
||||
// BMP
|
||||
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
|
||||
|
||||
// MP4: 00 00 00 18 / 20 / ... + 'ftyp'
|
||||
if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4'
|
||||
|
||||
// Fallback logic for video
|
||||
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
export const isVideoUrl = (url: string) => {
|
||||
if (!url) return false
|
||||
// 排除 vweixinthumb 域名 (缩略图)
|
||||
if (url.includes('vweixinthumb')) return false
|
||||
return url.includes('snsvideodownload') || url.includes('video') || url.includes('.mp4')
|
||||
}
|
||||
|
||||
import { Isaac64 } from './isaac64'
|
||||
|
||||
const extractVideoKey = (xml: string): string | undefined => {
|
||||
if (!xml) return undefined
|
||||
// 匹配 <enc key="2105122989" ... /> 或 <enc key="2105122989">
|
||||
const match = xml.match(/<enc\s+key="(\d+)"/i)
|
||||
return match ? match[1] : undefined
|
||||
}
|
||||
|
||||
class SnsService {
|
||||
private configService: ConfigService
|
||||
private contactCache: ContactCacheService
|
||||
private imageCache = new Map<string, string>()
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
||||
}
|
||||
|
||||
private getSnsCacheDir(): string {
|
||||
const cachePath = this.configService.getCacheBasePath()
|
||||
const snsCacheDir = join(cachePath, 'sns_cache')
|
||||
if (!existsSync(snsCacheDir)) {
|
||||
mkdirSync(snsCacheDir, { recursive: true })
|
||||
}
|
||||
return snsCacheDir
|
||||
}
|
||||
|
||||
private getCacheFilePath(url: string): string {
|
||||
const hash = crypto.createHash('md5').update(url).digest('hex')
|
||||
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
|
||||
return join(this.getSnsCacheDir(), `${hash}${ext}`)
|
||||
}
|
||||
|
||||
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
|
||||
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
|
||||
if (result.success && result.timeline) {
|
||||
const enrichedTimeline = result.timeline.map((post: any) => {
|
||||
const contact = this.contactCache.get(post.username)
|
||||
const isVideoPost = post.type === 15
|
||||
|
||||
// 尝试从 rawXml 中提取视频解密密钥 (针对视频号视频)
|
||||
const videoKey = extractVideoKey(post.rawXml || '')
|
||||
|
||||
const fixedMedia = (post.media || []).map((m: any) => ({
|
||||
// 如果是视频动态,url 是视频,thumb 是缩略图
|
||||
url: fixSnsUrl(m.url, m.token, isVideoPost),
|
||||
thumb: fixSnsUrl(m.thumb, m.token, false),
|
||||
md5: m.md5,
|
||||
token: m.token,
|
||||
// 只有在视频动态 (Type 15) 下才尝试将 XML 提取的 videoKey 赋予主媒体
|
||||
// 对于图片或实况照片的静态部分,应保留原始 m.key (由 DLL/DB 提供),避免由于错误的 Isaac64 密钥导致图片解密损坏
|
||||
key: isVideoPost ? (videoKey || m.key) : m.key,
|
||||
encIdx: m.encIdx || m.enc_idx,
|
||||
livePhoto: m.livePhoto
|
||||
? {
|
||||
...m.livePhoto,
|
||||
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true),
|
||||
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false),
|
||||
token: m.livePhoto.token,
|
||||
// 实况照片的视频部分优先使用从 XML 提取的 Key
|
||||
key: videoKey || m.livePhoto.key || m.key,
|
||||
encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
|
||||
}
|
||||
: undefined
|
||||
}))
|
||||
|
||||
return {
|
||||
...post,
|
||||
avatarUrl: contact?.avatarUrl,
|
||||
nickname: post.nickname || contact?.displayName || post.username,
|
||||
media: fixedMedia
|
||||
}
|
||||
})
|
||||
return { ...result, timeline: enrichedTimeline }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const https = require('https')
|
||||
const urlObj = new URL(url)
|
||||
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351',
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Connection': 'keep-alive',
|
||||
'Range': 'bytes=0-10'
|
||||
}
|
||||
}
|
||||
|
||||
const req = https.request(options, (res: any) => {
|
||||
resolve({
|
||||
success: true,
|
||||
status: res.statusCode,
|
||||
headers: {
|
||||
'x-enc': res.headers['x-enc'],
|
||||
'x-time': res.headers['x-time'],
|
||||
'content-length': res.headers['content-length'],
|
||||
'content-type': res.headers['content-type']
|
||||
}
|
||||
})
|
||||
req.destroy()
|
||||
})
|
||||
|
||||
req.on('error', (e: any) => resolve({ success: false, error: e.message }))
|
||||
req.end()
|
||||
} catch (e: any) {
|
||||
resolve({ success: false, error: e.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; error?: string }> {
|
||||
if (!url) return { success: false, error: 'url 不能为空' }
|
||||
const cacheKey = `${url}|${key ?? ''}`
|
||||
|
||||
if (this.imageCache.has(cacheKey)) {
|
||||
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
|
||||
}
|
||||
|
||||
const result = await this.fetchAndDecryptImage(url, key)
|
||||
if (result.success) {
|
||||
// 如果是视频,返回本地文件路径 (需配合 webSecurity: false 或自定义协议)
|
||||
if (result.contentType?.startsWith('video/')) {
|
||||
// Return cachePath directly for video
|
||||
// 注意:fetchAndDecryptImage 需要修改以返回 cachePath
|
||||
return { success: true, videoPath: result.cachePath }
|
||||
}
|
||||
|
||||
if (result.data && result.contentType) {
|
||||
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
||||
this.imageCache.set(cacheKey, dataUrl)
|
||||
return { success: true, dataUrl }
|
||||
}
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
}
|
||||
|
||||
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> {
|
||||
return this.fetchAndDecryptImage(url, key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出朋友圈动态
|
||||
* 支持筛选条件(用户名、关键词)和媒体文件导出
|
||||
*/
|
||||
async exportTimeline(options: {
|
||||
outputDir: string
|
||||
format: 'json' | 'html'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportMedia?: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> {
|
||||
const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options
|
||||
|
||||
try {
|
||||
// 确保输出目录存在
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 1. 分页加载全部帖子
|
||||
const allPosts: SnsPost[] = []
|
||||
const pageSize = 50
|
||||
let endTs: number | undefined = endTime // 使用 endTime 作为分页起始上界
|
||||
let hasMore = true
|
||||
|
||||
progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' })
|
||||
|
||||
while (hasMore) {
|
||||
const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs)
|
||||
if (result.success && result.timeline && result.timeline.length > 0) {
|
||||
allPosts.push(...result.timeline)
|
||||
// 下一页的 endTs 为当前最后一条帖子的时间 - 1
|
||||
const lastTs = result.timeline[result.timeline.length - 1].createTime - 1
|
||||
endTs = lastTs
|
||||
hasMore = result.timeline.length >= pageSize
|
||||
// 如果已经低于 startTime,提前终止
|
||||
if (startTime && lastTs < startTime) {
|
||||
hasMore = false
|
||||
}
|
||||
progressCallback?.({ current: allPosts.length, total: 0, status: `已加载 ${allPosts.length} 条动态...` })
|
||||
} else {
|
||||
hasMore = false
|
||||
}
|
||||
}
|
||||
|
||||
if (allPosts.length === 0) {
|
||||
return { success: true, filePath: '', postCount: 0, mediaCount: 0 }
|
||||
}
|
||||
|
||||
progressCallback?.({ current: 0, total: allPosts.length, status: `共 ${allPosts.length} 条动态,准备导出...` })
|
||||
|
||||
// 2. 如果需要导出媒体,创建 media 子目录并下载
|
||||
let mediaCount = 0
|
||||
const mediaDir = join(outputDir, 'media')
|
||||
|
||||
if (exportMedia) {
|
||||
if (!existsSync(mediaDir)) {
|
||||
mkdirSync(mediaDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 收集所有媒体下载任务
|
||||
const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = []
|
||||
for (const post of allPosts) {
|
||||
post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi }))
|
||||
}
|
||||
|
||||
// 并发下载(5路)
|
||||
let done = 0
|
||||
const concurrency = 5
|
||||
const runTask = async (task: typeof mediaTasks[0]) => {
|
||||
const { media, postId, mi } = task
|
||||
try {
|
||||
const isVideo = isVideoUrl(media.url)
|
||||
const ext = isVideo ? 'mp4' : 'jpg'
|
||||
const fileName = `${postId}_${mi}.${ext}`
|
||||
const filePath = join(mediaDir, fileName)
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
;(media as any).localPath = `media/${fileName}`
|
||||
mediaCount++
|
||||
} else {
|
||||
const result = await this.fetchAndDecryptImage(media.url, media.key)
|
||||
if (result.success && result.data) {
|
||||
await writeFile(filePath, result.data)
|
||||
;(media as any).localPath = `media/${fileName}`
|
||||
mediaCount++
|
||||
} else if (result.success && result.cachePath) {
|
||||
const cachedData = await readFile(result.cachePath)
|
||||
await writeFile(filePath, cachedData)
|
||||
;(media as any).localPath = `media/${fileName}`
|
||||
mediaCount++
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e)
|
||||
}
|
||||
done++
|
||||
progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` })
|
||||
}
|
||||
|
||||
// 控制并发的执行器
|
||||
const queue = [...mediaTasks]
|
||||
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const task = queue.shift()!
|
||||
await runTask(task)
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
// 2.5 下载头像
|
||||
const avatarMap = new Map<string, string>()
|
||||
if (format === 'html') {
|
||||
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
|
||||
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
|
||||
let avatarDone = 0
|
||||
const avatarQueue = [...uniqueUsers]
|
||||
const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => {
|
||||
while (avatarQueue.length > 0) {
|
||||
const post = avatarQueue.shift()!
|
||||
try {
|
||||
const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg`
|
||||
const filePath = join(mediaDir, fileName)
|
||||
if (existsSync(filePath)) {
|
||||
avatarMap.set(post.username, `media/${fileName}`)
|
||||
} else {
|
||||
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
|
||||
if (result.success && result.data) {
|
||||
await writeFile(filePath, result.data)
|
||||
avatarMap.set(post.username, `media/${fileName}`)
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 头像下载失败不影响导出 */ }
|
||||
avatarDone++
|
||||
progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` })
|
||||
}
|
||||
})
|
||||
await Promise.all(avatarWorkers)
|
||||
}
|
||||
|
||||
// 3. 生成输出文件
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
let outputFilePath: string
|
||||
|
||||
if (format === 'json') {
|
||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
||||
const exportData = {
|
||||
exportTime: new Date().toISOString(),
|
||||
totalPosts: allPosts.length,
|
||||
filters: {
|
||||
usernames: usernames || [],
|
||||
keyword: keyword || ''
|
||||
},
|
||||
posts: allPosts.map(p => ({
|
||||
id: p.id,
|
||||
username: p.username,
|
||||
nickname: p.nickname,
|
||||
createTime: p.createTime,
|
||||
createTimeStr: new Date(p.createTime * 1000).toLocaleString('zh-CN'),
|
||||
contentDesc: p.contentDesc,
|
||||
type: p.type,
|
||||
media: p.media.map(m => ({
|
||||
url: m.url,
|
||||
thumb: m.thumb,
|
||||
localPath: (m as any).localPath || undefined
|
||||
})),
|
||||
likes: p.likes,
|
||||
comments: p.comments,
|
||||
linkTitle: (p as any).linkTitle,
|
||||
linkUrl: (p as any).linkUrl
|
||||
}))
|
||||
}
|
||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||
} else {
|
||||
// HTML 格式
|
||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
||||
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
|
||||
await writeFile(outputFilePath, html, 'utf-8')
|
||||
}
|
||||
|
||||
progressCallback?.({ current: allPosts.length, total: allPosts.length, status: '导出完成!' })
|
||||
|
||||
return { success: true, filePath: outputFilePath, postCount: allPosts.length, mediaCount }
|
||||
} catch (e: any) {
|
||||
console.error('[SnsExport] 导出失败:', e)
|
||||
return { success: false, error: e.message || String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成朋友圈 HTML 导出文件
|
||||
*/
|
||||
private generateHtml(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }, avatarMap?: Map<string, string>): string {
|
||||
const escapeHtml = (str: string) => str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\n/g, '<br>')
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
const now = new Date()
|
||||
const isCurrentYear = d.getFullYear() === now.getFullYear()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
const m = d.getMonth() + 1, day = d.getDate()
|
||||
return isCurrentYear ? `${m}月${day}日 ${timeStr}` : `${d.getFullYear()}年${m}月${day}日 ${timeStr}`
|
||||
}
|
||||
|
||||
// 生成头像首字母
|
||||
const avatarLetter = (name: string) => {
|
||||
const ch = name.charAt(0)
|
||||
return escapeHtml(ch || '?')
|
||||
}
|
||||
|
||||
let filterInfo = ''
|
||||
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
|
||||
if (filters.usernames && filters.usernames.length > 0) filterInfo += `筛选用户: ${filters.usernames.length} 人`
|
||||
|
||||
const postsHtml = posts.map(post => {
|
||||
const mediaCount = post.media.length
|
||||
const gridClass = mediaCount === 1 ? 'grid-1' : mediaCount === 2 || mediaCount === 4 ? 'grid-2' : 'grid-3'
|
||||
|
||||
const mediaHtml = post.media.map((m, mi) => {
|
||||
const localPath = (m as any).localPath
|
||||
if (localPath) {
|
||||
if (isVideoUrl(m.url)) {
|
||||
return `<div class="mi"><video src="${escapeHtml(localPath)}" controls preload="metadata"></video></div>`
|
||||
}
|
||||
return `<div class="mi"><img src="${escapeHtml(localPath)}" loading="lazy" onclick="openLb(this.src)" alt=""></div>`
|
||||
}
|
||||
return `<div class="mi ml"><a href="${escapeHtml(m.url)}" target="_blank">查看媒体</a></div>`
|
||||
}).join('')
|
||||
|
||||
const linkHtml = post.linkTitle && post.linkUrl
|
||||
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a">›</span></a>`
|
||||
: ''
|
||||
|
||||
const likesHtml = post.likes.length > 0
|
||||
? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>`
|
||||
: ''
|
||||
|
||||
const commentsHtml = post.comments.length > 0
|
||||
? `<div class="interactions${post.likes.length > 0 ? ' cmt-border' : ''}"><div class="cmts">${post.comments.map(c => {
|
||||
const ref = c.refNickname ? `<span class="re">回复</span><b>${escapeHtml(c.refNickname)}</b>` : ''
|
||||
return `<div class="cmt"><b>${escapeHtml(c.nickname)}</b>${ref}:${escapeHtml(c.content)}</div>`
|
||||
}).join('')}</div></div>`
|
||||
: ''
|
||||
|
||||
const avatarSrc = avatarMap?.get(post.username)
|
||||
const avatarHtml = avatarSrc
|
||||
? `<div class="avatar"><img src="${escapeHtml(avatarSrc)}" alt=""></div>`
|
||||
: `<div class="avatar">${avatarLetter(post.nickname)}</div>`
|
||||
|
||||
return `<div class="post">
|
||||
${avatarHtml}
|
||||
<div class="body">
|
||||
<div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div>
|
||||
${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''}
|
||||
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
|
||||
${linkHtml}
|
||||
${likesHtml}
|
||||
${commentsHtml}
|
||||
</div></div>`
|
||||
}).join('\n')
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>朋友圈导出</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--t1);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
:root{--bg:#F0EEE9;--card:rgba(255,255,255,.92);--t1:#3d3d3d;--t2:#666;--t3:#999;--accent:#8B7355;--border:rgba(0,0,0,.08);--bg3:rgba(0,0,0,.03)}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#1a1a1a;--card:rgba(40,40,40,.85);--t1:#e0e0e0;--t2:#aaa;--t3:#777;--accent:#c4a882;--border:rgba(255,255,255,.1);--bg3:rgba(255,255,255,.06)}}
|
||||
.container{max-width:800px;margin:0 auto;padding:20px 24px 60px}
|
||||
|
||||
/* 页面标题 */
|
||||
.feed-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 4px}
|
||||
.feed-hd h2{font-size:20px;font-weight:700}
|
||||
.feed-hd .info{font-size:12px;color:var(--t3)}
|
||||
|
||||
/* 帖子卡片 - 头像+内容双列 */
|
||||
.post{background:var(--card);border-radius:16px;border:1px solid var(--border);padding:20px;margin-bottom:24px;display:flex;gap:16px;box-shadow:0 2px 8px rgba(0,0,0,.02);transition:transform .2s,box-shadow .2s}
|
||||
.post:hover{transform:translateY(-2px);box-shadow:0 8px 16px rgba(0,0,0,.06)}
|
||||
.avatar{width:48px;height:48px;border-radius:12px;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:600;flex-shrink:0;overflow:hidden}
|
||||
.avatar img{width:100%;height:100%;object-fit:cover}
|
||||
.body{flex:1;min-width:0}
|
||||
.hd{display:flex;flex-direction:column;margin-bottom:8px}
|
||||
.nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px}
|
||||
.tm{font-size:12px;color:var(--t3)}
|
||||
.txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px}
|
||||
|
||||
/* 媒体网格 */
|
||||
.mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px}
|
||||
.grid-1{max-width:300px}
|
||||
.grid-1 .mi{border-radius:12px}
|
||||
.grid-1 .mi img{aspect-ratio:auto;max-height:480px;object-fit:contain;background:var(--bg3)}
|
||||
.grid-2{grid-template-columns:1fr 1fr}
|
||||
.grid-3{grid-template-columns:1fr 1fr 1fr}
|
||||
.mi{overflow:hidden;border-radius:12px;background:var(--bg3);position:relative;aspect-ratio:1}
|
||||
.mi img{width:100%;height:100%;object-fit:cover;display:block;cursor:zoom-in;transition:opacity .2s}
|
||||
.mi img:hover{opacity:.9}
|
||||
.mi video{width:100%;height:100%;object-fit:cover;display:block;background:#000}
|
||||
.ml{display:flex;align-items:center;justify-content:center}
|
||||
.ml a{color:var(--accent);text-decoration:none;font-size:13px}
|
||||
|
||||
/* 链接卡片 */
|
||||
.lk{display:flex;align-items:center;gap:10px;padding:10px;background:var(--bg3);border:1px solid var(--border);border-radius:12px;text-decoration:none;color:var(--t1);font-size:14px;margin-bottom:12px;transition:background .15s}
|
||||
.lk:hover{background:var(--border)}
|
||||
.lk-t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
|
||||
.lk-a{color:var(--t3);font-size:18px;flex-shrink:0}
|
||||
|
||||
/* 互动区域 */
|
||||
.interactions{margin-top:12px;padding-top:12px;border-top:1px dashed var(--border);font-size:13px}
|
||||
.interactions.cmt-border{border-top:none;padding-top:0;margin-top:8px}
|
||||
.likes{color:var(--accent);font-weight:500;line-height:1.8}
|
||||
.cmts{background:var(--bg3);border-radius:8px;padding:8px 12px;line-height:1.4}
|
||||
.cmt{margin-bottom:4px;color:var(--t2)}
|
||||
.cmt:last-child{margin-bottom:0}
|
||||
.cmt b{color:var(--accent);font-weight:500}
|
||||
.re{color:var(--t3);margin:0 4px;font-size:12px}
|
||||
|
||||
/* 灯箱 */
|
||||
.lb{display:none;position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;align-items:center;justify-content:center;cursor:zoom-out}
|
||||
.lb.on{display:flex}
|
||||
.lb img{max-width:92vw;max-height:92vh;object-fit:contain;border-radius:4px}
|
||||
|
||||
/* 回到顶部 */
|
||||
.btt{position:fixed;right:24px;bottom:32px;width:44px;height:44px;border-radius:50%;background:var(--card);box-shadow:0 2px 12px rgba(0,0,0,.12);border:1px solid var(--border);cursor:pointer;font-size:18px;display:none;align-items:center;justify-content:center;z-index:100;color:var(--t2)}
|
||||
.btt:hover{transform:scale(1.1)}
|
||||
.btt.show{display:flex}
|
||||
|
||||
/* 页脚 */
|
||||
.ft{text-align:center;padding:32px 0 24px;font-size:12px;color:var(--t3)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="feed-hd"><h2>朋友圈</h2><span class="info">共 ${posts.length} 条${filterInfo ? ` · ${filterInfo}` : ''}</span></div>
|
||||
${postsHtml}
|
||||
<div class="ft">由 WeFlow 导出 · ${new Date().toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
<div class="lb" id="lb" onclick="closeLb()"><img id="lbi" src=""></div>
|
||||
<button class="btt" id="btt" onclick="scrollTo({top:0,behavior:'smooth'})">↑</button>
|
||||
<script>
|
||||
function openLb(s){document.getElementById('lbi').src=s;document.getElementById('lb').classList.add('on');document.body.style.overflow='hidden'}
|
||||
function closeLb(){document.getElementById('lb').classList.remove('on');document.body.style.overflow=''}
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape')closeLb()})
|
||||
window.addEventListener('scroll',function(){document.getElementById('btt').classList.toggle('show',window.scrollY>600)})
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
|
||||
if (!url) return { success: false, error: 'url 不能为空' }
|
||||
|
||||
const isVideo = isVideoUrl(url)
|
||||
const cachePath = this.getCacheFilePath(url)
|
||||
|
||||
// 1. 尝试从磁盘缓存读取
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
// 对于视频,不读取整个文件到内存,只确认存在即可
|
||||
if (isVideo) {
|
||||
return { success: true, cachePath, contentType: 'video/mp4' }
|
||||
}
|
||||
|
||||
const data = await readFile(cachePath)
|
||||
const contentType = detectImageMime(data)
|
||||
return { success: true, data, contentType, cachePath }
|
||||
} catch (e) {
|
||||
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
|
||||
}
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
// 视频专用下载逻辑 (下载 -> 解密 -> 缓存)
|
||||
return new Promise(async (resolve) => {
|
||||
const tmpPath = join(require('os').tmpdir(), `sns_video_${Date.now()}_${Math.random().toString(36).slice(2)}.enc`)
|
||||
|
||||
try {
|
||||
const https = require('https')
|
||||
const urlObj = new URL(url)
|
||||
const fs = require('fs')
|
||||
|
||||
const fileStream = fs.createWriteStream(tmpPath)
|
||||
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'MicroMessenger Client',
|
||||
'Accept': '*/*',
|
||||
// 'Accept-Encoding': 'gzip, deflate, br', // 视频流通常不压缩,去掉以免 stream 处理复杂
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
}
|
||||
|
||||
const req = https.request(options, (res: any) => {
|
||||
if (res.statusCode !== 200 && res.statusCode !== 206) {
|
||||
fileStream.close()
|
||||
fs.unlink(tmpPath, () => { }) // 删除临时文件
|
||||
resolve({ success: false, error: `HTTP ${res.statusCode}` })
|
||||
return
|
||||
}
|
||||
|
||||
res.pipe(fileStream)
|
||||
fileStream.on('finish', async () => {
|
||||
fileStream.close()
|
||||
|
||||
try {
|
||||
const encryptedBuffer = await readFile(tmpPath)
|
||||
const raw = encryptedBuffer // 引用,方便后续操作
|
||||
|
||||
|
||||
if (key && String(key).trim().length > 0) {
|
||||
try {
|
||||
const keyText = String(key).trim()
|
||||
let keystream: Buffer
|
||||
|
||||
try {
|
||||
const wasmService = WasmService.getInstance()
|
||||
// 只需要前 128KB (131072 bytes) 用于解密头部
|
||||
keystream = await wasmService.getKeystream(keyText, 131072)
|
||||
} catch (wasmErr) {
|
||||
// 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64
|
||||
const isaac = new Isaac64(keyText)
|
||||
keystream = isaac.generateKeystreamBE(131072)
|
||||
}
|
||||
|
||||
const decryptLen = Math.min(keystream.length, raw.length)
|
||||
|
||||
// XOR 解密
|
||||
for (let i = 0; i < decryptLen; i++) {
|
||||
raw[i] ^= keystream[i]
|
||||
}
|
||||
|
||||
// 验证 MP4 签名 ('ftyp' at offset 4)
|
||||
const ftyp = raw.subarray(4, 8).toString('ascii')
|
||||
if (ftyp !== 'ftyp') {
|
||||
// 可以在此处记录解密可能失败的标记,但不打印详细 hex
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[SnsService] 视频解密出错: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入最终缓存 (覆盖)
|
||||
await writeFile(cachePath, raw)
|
||||
|
||||
// 删除临时文件
|
||||
try { await import('fs/promises').then(fs => fs.unlink(tmpPath)) } catch (e) { }
|
||||
|
||||
resolve({ success: true, data: raw, contentType: 'video/mp4', cachePath })
|
||||
} catch (e: any) {
|
||||
console.error(`[SnsService] 视频处理失败:`, e)
|
||||
resolve({ success: false, error: e.message })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', (e: any) => {
|
||||
fs.unlink(tmpPath, () => { })
|
||||
resolve({ success: false, error: e.message })
|
||||
})
|
||||
|
||||
req.setTimeout(15000, () => {
|
||||
req.destroy()
|
||||
fs.unlink(tmpPath, () => { })
|
||||
resolve({ success: false, error: '请求超时' })
|
||||
})
|
||||
|
||||
req.end()
|
||||
|
||||
} catch (e: any) {
|
||||
resolve({ success: false, error: e.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 图片逻辑 (保持流式处理)
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const https = require('https')
|
||||
const zlib = require('zlib')
|
||||
const urlObj = new URL(url)
|
||||
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'MicroMessenger Client',
|
||||
'Accept': '*/*',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
}
|
||||
|
||||
const req = https.request(options, (res: any) => {
|
||||
if (res.statusCode !== 200 && res.statusCode !== 206) {
|
||||
resolve({ success: false, error: `HTTP ${res.statusCode}` })
|
||||
return
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
let stream = res
|
||||
|
||||
const encoding = res.headers['content-encoding']
|
||||
if (encoding === 'gzip') stream = res.pipe(zlib.createGunzip())
|
||||
else if (encoding === 'deflate') stream = res.pipe(zlib.createInflate())
|
||||
else if (encoding === 'br') stream = res.pipe(zlib.createBrotliDecompress())
|
||||
|
||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
stream.on('end', async () => {
|
||||
const raw = Buffer.concat(chunks)
|
||||
const xEnc = String(res.headers['x-enc'] || '').trim()
|
||||
|
||||
let decoded = raw
|
||||
|
||||
// 图片逻辑
|
||||
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
||||
if (shouldDecrypt) {
|
||||
try {
|
||||
const keyStr = String(key).trim()
|
||||
if (/^\d+$/.test(keyStr)) {
|
||||
// 使用 WASM 版本的 Isaac64 解密图片
|
||||
// 修正逻辑:使用带 reverse 且修正了 8字节对齐偏移的 getKeystream
|
||||
const wasmService = WasmService.getInstance()
|
||||
const keystream = await wasmService.getKeystream(keyStr, raw.length)
|
||||
|
||||
const decrypted = Buffer.allocUnsafe(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
decrypted[i] = raw[i] ^ keystream[i]
|
||||
}
|
||||
|
||||
decoded = decrypted
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SnsService] TS Decrypt Error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入磁盘缓存
|
||||
try {
|
||||
await writeFile(cachePath, decoded)
|
||||
} catch (e) {
|
||||
console.warn(`[SnsService] 写入缓存失败: ${cachePath}`, e)
|
||||
}
|
||||
|
||||
const contentType = detectImageMime(decoded, (res.headers['content-type'] || 'image/jpeg') as string)
|
||||
resolve({ success: true, data: decoded, contentType, cachePath })
|
||||
})
|
||||
stream.on('error', (e: any) => resolve({ success: false, error: e.message }))
|
||||
})
|
||||
|
||||
req.on('error', (e: any) => resolve({ success: false, error: e.message }))
|
||||
req.setTimeout(15000, () => {
|
||||
req.destroy()
|
||||
resolve({ success: false, error: '请求超时' })
|
||||
})
|
||||
req.end()
|
||||
} catch (e: any) {
|
||||
resolve({ success: false, error: e.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const snsService = new SnsService()
|
||||
279
electron/services/videoService.ts
Normal file
279
electron/services/videoService.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
import Database from 'better-sqlite3'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
export interface VideoInfo {
|
||||
videoUrl?: string // 视频文件路径(用于 readFile)
|
||||
coverUrl?: string // 封面 data URL
|
||||
thumbUrl?: string // 缩略图 data URL
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
class VideoService {
|
||||
private configService: ConfigService
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库根目录
|
||||
*/
|
||||
private getDbPath(): string {
|
||||
return this.configService.get('dbPath') || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的wxid
|
||||
*/
|
||||
private getMyWxid(): string {
|
||||
return this.configService.get('myWxid') || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存目录(解密后的数据库存放位置)
|
||||
*/
|
||||
private getCachePath(): string {
|
||||
return this.configService.getCacheBasePath()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 wxid 目录名(去掉后缀)
|
||||
*/
|
||||
private cleanWxid(wxid: string): string {
|
||||
const trimmed = wxid.trim()
|
||||
if (!trimmed) return trimmed
|
||||
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
if (match) return match[1]
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
if (suffixMatch) return suffixMatch[1]
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
||||
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
*/
|
||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||
const cachePath = this.getCachePath()
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
if (!wxid) return undefined
|
||||
|
||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
||||
if (cachePath) {
|
||||
const cacheDbPaths = [
|
||||
join(cachePath, cleanedWxid, 'hardlink.db'),
|
||||
join(cachePath, wxid, 'hardlink.db'),
|
||||
join(cachePath, 'hardlink.db'),
|
||||
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
|
||||
join(cachePath, 'databases', wxid, 'hardlink.db')
|
||||
]
|
||||
|
||||
for (const p of cacheDbPaths) {
|
||||
if (existsSync(p)) {
|
||||
try {
|
||||
const db = new Database(p, { readonly: true })
|
||||
const row = db.prepare(`
|
||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
||||
WHERE md5 = ?
|
||||
LIMIT 1
|
||||
`).get(md5) as { file_name: string; md5: string } | undefined
|
||||
db.close()
|
||||
|
||||
if (row?.file_name) {
|
||||
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
||||
return realMd5
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
if (dbPath) {
|
||||
// 检查 dbPath 是否已经包含 wxid
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||
|
||||
const encryptedDbPaths: string[] = []
|
||||
if (dbPathContainsWxid) {
|
||||
// dbPath 已包含 wxid,不需要再拼接
|
||||
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||
} else {
|
||||
// dbPath 不包含 wxid,需要拼接
|
||||
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||
}
|
||||
|
||||
for (const p of encryptedDbPaths) {
|
||||
if (existsSync(p)) {
|
||||
try {
|
||||
const escapedMd5 = md5.replace(/'/g, "''")
|
||||
|
||||
// 用 md5 字段查询,获取 file_name
|
||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
||||
|
||||
const result = await wcdbService.execQuery('media', p, sql)
|
||||
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
if (row?.file_name) {
|
||||
// 提取不带扩展名的文件名作为实际视频 MD5
|
||||
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
||||
return realMd5
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 data URL
|
||||
*/
|
||||
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
|
||||
try {
|
||||
if (!existsSync(filePath)) return undefined
|
||||
const buffer = readFileSync(filePath)
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据视频MD5获取视频文件信息
|
||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||
*/
|
||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
|
||||
if (!dbPath || !wxid || !videoMd5) {
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
// 先尝试从数据库查询真正的视频文件名
|
||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
||||
|
||||
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
let videoBaseDir: string
|
||||
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
||||
// dbPath 已经包含 wxid,直接使用
|
||||
videoBaseDir = join(dbPath, 'msg', 'video')
|
||||
} else {
|
||||
// dbPath 不包含 wxid,需要拼接
|
||||
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
||||
}
|
||||
|
||||
if (!existsSync(videoBaseDir)) {
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
// 遍历年月目录查找视频文件
|
||||
try {
|
||||
const allDirs = readdirSync(videoBaseDir)
|
||||
|
||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
||||
const yearMonthDirs = allDirs
|
||||
.filter(dir => {
|
||||
const dirPath = join(videoBaseDir, dir)
|
||||
return statSync(dirPath).isDirectory()
|
||||
})
|
||||
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
|
||||
|
||||
for (const yearMonth of yearMonthDirs) {
|
||||
const dirPath = join(videoBaseDir, yearMonth)
|
||||
|
||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
||||
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
||||
|
||||
// 检查视频文件是否存在
|
||||
if (existsSync(videoPath)) {
|
||||
return {
|
||||
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息内容解析视频MD5
|
||||
*/
|
||||
parseVideoMd5(content: string): string | undefined {
|
||||
|
||||
// 打印前500字符看看 XML 结构
|
||||
|
||||
if (!content) return undefined
|
||||
|
||||
try {
|
||||
// 提取所有可能的 md5 值进行日志
|
||||
const allMd5s: string[] = []
|
||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
|
||||
let match
|
||||
while ((match = md5Regex.exec(content)) !== null) {
|
||||
allMd5s.push(`${match[0]}`)
|
||||
}
|
||||
|
||||
// 提取 md5(用于查询 hardlink.db)
|
||||
// 注意:不是 rawmd5,rawmd5 是另一个值
|
||||
// 格式: md5="xxx" 或 <md5>xxx</md5>
|
||||
|
||||
// 尝试从videomsg标签中提取md5
|
||||
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (videoMsgMatch) {
|
||||
return videoMsgMatch[1].toLowerCase()
|
||||
}
|
||||
|
||||
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (attrMatch) {
|
||||
return attrMatch[1].toLowerCase()
|
||||
}
|
||||
|
||||
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||
if (md5Match) {
|
||||
return md5Match[1].toLowerCase()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VideoService] 解析视频MD5失败:', e)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const videoService = new VideoService()
|
||||
476
electron/services/voiceTranscribeService.ts
Normal file
476
electron/services/voiceTranscribeService.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import * as https from 'https'
|
||||
import * as http from 'http'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
// Sherpa-onnx 类型定义
|
||||
type OfflineRecognizer = any
|
||||
type OfflineStream = any
|
||||
|
||||
type ModelInfo = {
|
||||
name: string
|
||||
files: {
|
||||
model: string
|
||||
tokens: string
|
||||
}
|
||||
sizeBytes: number
|
||||
sizeLabel: string
|
||||
}
|
||||
|
||||
type DownloadProgress = {
|
||||
modelName: string
|
||||
downloadedBytes: number
|
||||
totalBytes?: number
|
||||
percent?: number
|
||||
speed?: number
|
||||
}
|
||||
|
||||
const SENSEVOICE_MODEL: ModelInfo = {
|
||||
name: 'SenseVoiceSmall',
|
||||
files: {
|
||||
model: 'model.int8.onnx',
|
||||
tokens: 'tokens.txt'
|
||||
},
|
||||
sizeBytes: 245_000_000,
|
||||
sizeLabel: '245 MB'
|
||||
}
|
||||
|
||||
const MODEL_DOWNLOAD_URLS = {
|
||||
model: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/model.int8.onnx',
|
||||
tokens: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/tokens.txt'
|
||||
}
|
||||
|
||||
export class VoiceTranscribeService {
|
||||
private configService = new ConfigService()
|
||||
private downloadTasks = new Map<string, Promise<{ success: boolean; path?: string; error?: string }>>()
|
||||
private recognizer: OfflineRecognizer | null = null
|
||||
private isInitializing = false
|
||||
|
||||
private resolveModelDir(): string {
|
||||
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||
if (configured) return configured
|
||||
return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice')
|
||||
}
|
||||
|
||||
private resolveModelPath(fileName: string): string {
|
||||
return join(this.resolveModelDir(), fileName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型状态
|
||||
*/
|
||||
async getModelStatus(): Promise<{
|
||||
success: boolean
|
||||
exists?: boolean
|
||||
modelPath?: string
|
||||
tokensPath?: string
|
||||
sizeBytes?: number
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
const modelExists = existsSync(modelPath)
|
||||
const tokensExists = existsSync(tokensPath)
|
||||
const exists = modelExists && tokensExists
|
||||
|
||||
if (!exists) {
|
||||
return { success: true, exists: false, modelPath, tokensPath }
|
||||
}
|
||||
|
||||
const modelSize = statSync(modelPath).size
|
||||
const tokensSize = statSync(tokensPath).size
|
||||
const totalSize = modelSize + tokensSize
|
||||
|
||||
return {
|
||||
success: true,
|
||||
exists: true,
|
||||
modelPath,
|
||||
tokensPath,
|
||||
sizeBytes: totalSize
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载模型文件
|
||||
*/
|
||||
async downloadModel(
|
||||
onProgress?: (progress: DownloadProgress) => void
|
||||
): Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> {
|
||||
const cacheKey = 'sensevoice'
|
||||
const pending = this.downloadTasks.get(cacheKey)
|
||||
if (pending) return pending
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
const modelDir = this.resolveModelDir()
|
||||
if (!existsSync(modelDir)) {
|
||||
mkdirSync(modelDir, { recursive: true })
|
||||
}
|
||||
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
|
||||
// 初始进度
|
||||
onProgress?.({
|
||||
modelName: SENSEVOICE_MODEL.name,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||
percent: 0
|
||||
})
|
||||
|
||||
// 下载模型文件 (80% 权重)
|
||||
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
||||
await this.downloadToFile(
|
||||
MODEL_DOWNLOAD_URLS.model,
|
||||
modelPath,
|
||||
'model',
|
||||
(downloaded, total, speed) => {
|
||||
const percent = total ? (downloaded / total) * 80 : 0
|
||||
onProgress?.({
|
||||
modelName: SENSEVOICE_MODEL.name,
|
||||
downloadedBytes: downloaded,
|
||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||
percent,
|
||||
speed
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 下载 tokens 文件 (20% 权重)
|
||||
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
||||
await this.downloadToFile(
|
||||
MODEL_DOWNLOAD_URLS.tokens,
|
||||
tokensPath,
|
||||
'tokens',
|
||||
(downloaded, total, speed) => {
|
||||
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
|
||||
const percent = total ? 80 + (downloaded / total) * 20 : 80
|
||||
onProgress?.({
|
||||
modelName: SENSEVOICE_MODEL.name,
|
||||
downloadedBytes: modelSize + downloaded,
|
||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||
percent,
|
||||
speed
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
console.info('[VoiceTranscribe] 模型下载完成')
|
||||
return { success: true, modelPath, tokensPath }
|
||||
} catch (error) {
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
try {
|
||||
if (existsSync(modelPath)) unlinkSync(modelPath)
|
||||
if (existsSync(tokensPath)) unlinkSync(tokensPath)
|
||||
} catch { }
|
||||
return { success: false, error: String(error) }
|
||||
} finally {
|
||||
this.downloadTasks.delete(cacheKey)
|
||||
}
|
||||
})()
|
||||
|
||||
this.downloadTasks.set(cacheKey, task)
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* 转写 WAV 音频数据
|
||||
*/
|
||||
async transcribeWavBuffer(
|
||||
wavData: Buffer,
|
||||
onPartial?: (text: string) => void,
|
||||
languages?: string[]
|
||||
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
|
||||
if (!existsSync(modelPath) || !existsSync(tokensPath)) {
|
||||
resolve({ success: false, error: '模型文件不存在,请先下载模型' })
|
||||
return
|
||||
}
|
||||
|
||||
let supportedLanguages = languages
|
||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||
supportedLanguages = this.configService.get('transcribeLanguages')
|
||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||
supportedLanguages = ['zh', 'yue']
|
||||
}
|
||||
}
|
||||
|
||||
const { Worker } = require('worker_threads')
|
||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
modelPath,
|
||||
tokensPath,
|
||||
wavData,
|
||||
sampleRate: 16000,
|
||||
languages: supportedLanguages
|
||||
}
|
||||
})
|
||||
|
||||
let finalTranscript = ''
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'partial') {
|
||||
onPartial?.(msg.text)
|
||||
} else if (msg.type === 'final') {
|
||||
finalTranscript = msg.text
|
||||
resolve({ success: true, transcript: finalTranscript })
|
||||
worker.terminate()
|
||||
} else if (msg.type === 'error') {
|
||||
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
||||
resolve({ success: false, error: msg.error })
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
|
||||
worker.on('exit', (code: number) => {
|
||||
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` })
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
resolve({ success: false, error: String(error) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件 (支持多线程)
|
||||
*/
|
||||
private async downloadToFile(
|
||||
url: string,
|
||||
targetPath: string,
|
||||
fileName: string,
|
||||
onProgress?: (downloaded: number, total?: number, speed?: number) => void
|
||||
): Promise<void> {
|
||||
if (existsSync(targetPath)) {
|
||||
unlinkSync(targetPath)
|
||||
}
|
||||
|
||||
console.info(`[VoiceTranscribe] 准备下载 ${fileName}: ${url}`)
|
||||
|
||||
// 1. 探测支持情况
|
||||
let probeResult
|
||||
try {
|
||||
probeResult = await this.probeUrl(url)
|
||||
} catch (err) {
|
||||
console.warn(`[VoiceTranscribe] ${fileName} 探测失败,使用单线程`, err)
|
||||
return this.downloadSingleThread(url, targetPath, fileName, onProgress)
|
||||
}
|
||||
|
||||
const { totalSize, acceptRanges, finalUrl } = probeResult
|
||||
|
||||
// 如果文件太小 (< 2MB) 或者不支持 Range,使用单线程
|
||||
if (totalSize < 2 * 1024 * 1024 || !acceptRanges) {
|
||||
return this.downloadSingleThread(finalUrl, targetPath, fileName, onProgress)
|
||||
}
|
||||
|
||||
console.info(`[VoiceTranscribe] ${fileName} 开始多线程下载 (4 线程), 大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
|
||||
|
||||
const threadCount = 4
|
||||
const chunkSize = Math.ceil(totalSize / threadCount)
|
||||
const fd = openSync(targetPath, 'w')
|
||||
|
||||
let downloadedTotal = 0
|
||||
let lastDownloaded = 0
|
||||
let lastTime = Date.now()
|
||||
let speed = 0
|
||||
|
||||
const speedInterval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const duration = (now - lastTime) / 1000
|
||||
if (duration > 0) {
|
||||
speed = (downloadedTotal - lastDownloaded) / duration
|
||||
lastDownloaded = downloadedTotal
|
||||
lastTime = now
|
||||
onProgress?.(downloadedTotal, totalSize, speed)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
try {
|
||||
const promises = []
|
||||
for (let i = 0; i < threadCount; i++) {
|
||||
const start = i * chunkSize
|
||||
const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1
|
||||
|
||||
promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => {
|
||||
downloadedTotal += bytes
|
||||
}))
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
// Final progress update
|
||||
onProgress?.(totalSize, totalSize, 0)
|
||||
console.info(`[VoiceTranscribe] ${fileName} 多线程下载完成`)
|
||||
} catch (err) {
|
||||
console.error(`[VoiceTranscribe] ${fileName} 多线程下载失败:`, err)
|
||||
throw err
|
||||
} finally {
|
||||
clearInterval(speedInterval)
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
private async probeUrl(url: string, remainingRedirects = 5): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://modelscope.cn/',
|
||||
'Range': 'bytes=0-0'
|
||||
}
|
||||
}
|
||||
|
||||
const req = protocol.get(url, options, (res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode || 0)) {
|
||||
const location = res.headers.location
|
||||
if (location && remainingRedirects > 0) {
|
||||
const nextUrl = new URL(location, url).href
|
||||
this.probeUrl(nextUrl, remainingRedirects - 1).then(resolve).catch(reject)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (res.statusCode !== 206 && res.statusCode !== 200) {
|
||||
reject(new Error(`Probe failed: HTTP ${res.statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
const contentRange = res.headers['content-range']
|
||||
let totalSize = 0
|
||||
if (contentRange) {
|
||||
const parts = contentRange.split('/')
|
||||
totalSize = parseInt(parts[parts.length - 1], 10)
|
||||
} else {
|
||||
totalSize = parseInt(res.headers['content-length'] || '0', 10)
|
||||
}
|
||||
|
||||
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange
|
||||
resolve({ totalSize, acceptRanges, finalUrl: url })
|
||||
res.destroy()
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://modelscope.cn/',
|
||||
'Range': `bytes=${start}-${end}`
|
||||
}
|
||||
}
|
||||
|
||||
const req = protocol.get(url, options, (res) => {
|
||||
if (res.statusCode !== 206) {
|
||||
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
let currentOffset = start
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
try {
|
||||
writeSync(fd, chunk, 0, chunk.length, currentOffset)
|
||||
currentOffset += chunk.length
|
||||
onData(chunk.length)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
res.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
res.on('end', () => resolve())
|
||||
res.on('error', reject)
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
private async downloadSingleThread(url: string, targetPath: string, fileName: string, onProgress?: (downloaded: number, total?: number, speed?: number) => void, remainingRedirects = 5): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://modelscope.cn/'
|
||||
}
|
||||
}
|
||||
|
||||
const request = protocol.get(url, options, (response) => {
|
||||
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0)) {
|
||||
const location = response.headers.location
|
||||
if (location && remainingRedirects > 0) {
|
||||
const nextUrl = new URL(location, url).href
|
||||
this.downloadSingleThread(nextUrl, targetPath, fileName, onProgress, remainingRedirects - 1).then(resolve).catch(reject)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
|
||||
let downloadedBytes = 0
|
||||
let lastDownloaded = 0
|
||||
let lastTime = Date.now()
|
||||
let speed = 0
|
||||
|
||||
const speedInterval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const duration = (now - lastTime) / 1000
|
||||
if (duration > 0) {
|
||||
speed = (downloadedBytes - lastDownloaded) / duration
|
||||
lastDownloaded = downloadedBytes
|
||||
lastTime = now
|
||||
onProgress?.(downloadedBytes, totalBytes, speed)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
const writer = createWriteStream(targetPath)
|
||||
response.on('data', (chunk) => {
|
||||
downloadedBytes += chunk.length
|
||||
})
|
||||
|
||||
writer.on('finish', () => {
|
||||
clearInterval(speedInterval)
|
||||
writer.close()
|
||||
resolve()
|
||||
})
|
||||
|
||||
writer.on('error', (err) => {
|
||||
clearInterval(speedInterval)
|
||||
reject(err)
|
||||
})
|
||||
response.pipe(writer)
|
||||
})
|
||||
request.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.recognizer) {
|
||||
this.recognizer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const voiceTranscribeService = new VoiceTranscribeService()
|
||||
180
electron/services/wasmService.ts
Normal file
180
electron/services/wasmService.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import vm from 'vm';
|
||||
|
||||
let app: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
app = require('electron').app;
|
||||
} catch (e) {
|
||||
app = { isPackaged: false };
|
||||
}
|
||||
|
||||
// This service handles the loading and execution of the WeChat WASM module
|
||||
// to generate the correct Isaac64 keystream for video decryption.
|
||||
export class WasmService {
|
||||
private static instance: WasmService;
|
||||
private module: any = null;
|
||||
private wasmLoaded = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private capturedKeystream: Uint8Array | null = null;
|
||||
|
||||
private constructor() { }
|
||||
|
||||
public static getInstance(): WasmService {
|
||||
if (!WasmService.instance) {
|
||||
WasmService.instance = new WasmService();
|
||||
}
|
||||
return WasmService.instance;
|
||||
}
|
||||
|
||||
private async init(): Promise<void> {
|
||||
if (this.wasmLoaded) return;
|
||||
if (this.initPromise) return this.initPromise;
|
||||
|
||||
this.initPromise = new Promise((resolve, reject) => {
|
||||
try {
|
||||
// For dev, files are in electron/assets/wasm
|
||||
// __dirname in dev (from dist-electron) is .../dist-electron
|
||||
// So we need to go up one level and then into electron/assets/wasm
|
||||
const isDev = !app.isPackaged;
|
||||
const basePath = isDev
|
||||
? path.join(__dirname, '../electron/assets/wasm')
|
||||
: path.join(process.resourcesPath, 'assets/wasm'); // Adjust as needed for production build
|
||||
|
||||
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm');
|
||||
const jsPath = path.join(basePath, 'wasm_video_decode.js');
|
||||
|
||||
|
||||
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
|
||||
throw new Error(`WASM files not found at ${basePath}`);
|
||||
}
|
||||
|
||||
const wasmBinary = fs.readFileSync(wasmPath);
|
||||
|
||||
// Emulate Emscripten environment
|
||||
// We must use 'any' for global mocking
|
||||
const mockGlobal: any = {
|
||||
console: console,
|
||||
Buffer: Buffer,
|
||||
Uint8Array: Uint8Array,
|
||||
Int8Array: Int8Array,
|
||||
Uint16Array: Uint16Array,
|
||||
Int16Array: Int16Array,
|
||||
Uint32Array: Uint32Array,
|
||||
Int32Array: Int32Array,
|
||||
Float32Array: Float32Array,
|
||||
Float64Array: Float64Array,
|
||||
BigInt64Array: BigInt64Array,
|
||||
BigUint64Array: BigUint64Array,
|
||||
Array: Array,
|
||||
Object: Object,
|
||||
Function: Function,
|
||||
String: String,
|
||||
Number: Number,
|
||||
Boolean: Boolean,
|
||||
Error: Error,
|
||||
Promise: Promise,
|
||||
require: require,
|
||||
process: process,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
setInterval: setInterval,
|
||||
clearInterval: clearInterval,
|
||||
};
|
||||
|
||||
// Define Module
|
||||
mockGlobal.Module = {
|
||||
onRuntimeInitialized: () => {
|
||||
this.wasmLoaded = true;
|
||||
resolve();
|
||||
},
|
||||
wasmBinary: wasmBinary,
|
||||
print: (text: string) => console.log('[WASM stdout]', text),
|
||||
printErr: (text: string) => console.error('[WASM stderr]', text)
|
||||
};
|
||||
|
||||
// Define necessary globals for Emscripten loader
|
||||
mockGlobal.self = mockGlobal;
|
||||
mockGlobal.self.location = { href: jsPath };
|
||||
mockGlobal.WorkerGlobalScope = function () { };
|
||||
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`; // Needs a URL, file protocol works in Node context for our mock?
|
||||
|
||||
// Define the callback function that WASM calls to return data
|
||||
// The WASM module calls `wasm_isaac_generate(ptr, size)`
|
||||
mockGlobal.wasm_isaac_generate = (ptr: number, size: number) => {
|
||||
// console.log(`[WasmService] wasm_isaac_generate called: ptr=${ptr}, size=${size}`);
|
||||
const buffer = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, size);
|
||||
// Copy the data because WASM memory might change or be invalidated
|
||||
this.capturedKeystream = new Uint8Array(buffer);
|
||||
};
|
||||
|
||||
// Execute the loader script in the context
|
||||
const jsContent = fs.readFileSync(jsPath, 'utf8');
|
||||
const script = new vm.Script(jsContent, { filename: jsPath });
|
||||
|
||||
// create context
|
||||
const context = vm.createContext(mockGlobal);
|
||||
script.runInContext(context);
|
||||
|
||||
// Store reference to module
|
||||
this.module = mockGlobal.Module;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[WasmService] Failed to initialize WASM:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
public async getKeystream(key: string, size: number = 131072): Promise<Buffer> {
|
||||
// ISAAC-64 uses 8-byte blocks. If size is not a multiple of 8,
|
||||
// the global reverse() will cause a shift in alignment.
|
||||
const alignSize = Math.ceil(size / 8) * 8;
|
||||
const buffer = await this.getRawKeystream(key, alignSize);
|
||||
|
||||
// Reverse the entire aligned buffer
|
||||
const reversed = new Uint8Array(buffer);
|
||||
reversed.reverse();
|
||||
|
||||
// Return exactly the requested size from the beginning of the reversed stream.
|
||||
// Since we reversed the 'aligned' buffer, index 0 is the last byte of the last block.
|
||||
return Buffer.from(reversed).subarray(0, size);
|
||||
}
|
||||
|
||||
public async getRawKeystream(key: string, size: number = 131072): Promise<Buffer> {
|
||||
await this.init();
|
||||
|
||||
if (!this.module || !this.module.WxIsaac64) {
|
||||
if (this.module.asm && this.module.asm.WxIsaac64) {
|
||||
this.module.WxIsaac64 = this.module.asm.WxIsaac64;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.module.WxIsaac64) {
|
||||
throw new Error('[WasmService] WxIsaac64 not found in WASM module');
|
||||
}
|
||||
|
||||
try {
|
||||
this.capturedKeystream = null;
|
||||
const isaac = new this.module.WxIsaac64(key);
|
||||
isaac.generate(size);
|
||||
|
||||
if (isaac.delete) {
|
||||
isaac.delete();
|
||||
}
|
||||
|
||||
if (this.capturedKeystream) {
|
||||
return Buffer.from(this.capturedKeystream);
|
||||
} else {
|
||||
throw new Error('[WasmService] Failed to capture keystream (callback not called)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WasmService] Error generating raw keystream:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
1851
electron/services/wcdbCore.ts
Normal file
1851
electron/services/wcdbCore.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
32
electron/services/windowsHelloService.ts
Normal file
32
electron/services/windowsHelloService.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
export class WindowsHelloService {
|
||||
private verificationPromise: Promise<{ success: boolean; error?: string }> | null = null
|
||||
|
||||
/**
|
||||
* 验证 Windows Hello
|
||||
* @param message 提示信息
|
||||
*/
|
||||
async verify(message: string = '请验证您的身份以解锁 WeFlow', targetWindow?: BrowserWindow): Promise<{ success: boolean; error?: string }> {
|
||||
// Prevent concurrent verification requests
|
||||
if (this.verificationPromise) {
|
||||
return this.verificationPromise
|
||||
}
|
||||
|
||||
// 获取窗口句柄: 优先使用传入的窗口,否则尝试获取焦点窗口,最后兜底主窗口
|
||||
const window = targetWindow || BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
|
||||
const hwndBuffer = window?.getNativeWindowHandle()
|
||||
// Convert buffer to int string for transport
|
||||
const hwndStr = hwndBuffer ? BigInt('0x' + hwndBuffer.toString('hex')).toString() : undefined
|
||||
|
||||
this.verificationPromise = wcdbService.verifyUser(message, hwndStr)
|
||||
.finally(() => {
|
||||
this.verificationPromise = null
|
||||
})
|
||||
|
||||
return this.verificationPromise
|
||||
}
|
||||
}
|
||||
|
||||
export const windowsHelloService = new WindowsHelloService()
|
||||
166
electron/transcribeWorker.ts
Normal file
166
electron/transcribeWorker.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
|
||||
interface WorkerParams {
|
||||
modelPath: string
|
||||
tokensPath: string
|
||||
wavData: Buffer
|
||||
sampleRate: number
|
||||
languages?: string[]
|
||||
}
|
||||
|
||||
// 语言标记映射
|
||||
const LANGUAGE_TAGS: Record<string, string> = {
|
||||
'zh': '<|zh|>',
|
||||
'en': '<|en|>',
|
||||
'ja': '<|ja|>',
|
||||
'ko': '<|ko|>',
|
||||
'yue': '<|yue|>' // 粤语
|
||||
}
|
||||
|
||||
// 技术标签(识别语言、语速、ITN等),需要从最终文本中移除
|
||||
const TECH_TAGS = [
|
||||
'<|zh|>', '<|en|>', '<|ja|>', '<|ko|>', '<|yue|>',
|
||||
'<|nospeech|>', '<|speech|>',
|
||||
'<|itn|>', '<|wo_itn|>',
|
||||
'<|NORMAL|>'
|
||||
]
|
||||
|
||||
// 情感与事件标签映射,转换为直观的 Emoji
|
||||
const RICH_TAG_MAP: Record<string, string> = {
|
||||
'<|HAPPY|>': '😊',
|
||||
'<|SAD|>': '😔',
|
||||
'<|ANGRY|>': '😠',
|
||||
'<|NEUTRAL|>': '', // 中性情感不特别标记
|
||||
'<|FEARFUL|>': '😨',
|
||||
'<|DISGUSTED|>': '🤢',
|
||||
'<|SURPRISED|>': '😮',
|
||||
'<|BGM|>': '🎵',
|
||||
'<|Applause|>': '👏',
|
||||
'<|Laughter|>': '😂',
|
||||
'<|Cry|>': '😭',
|
||||
'<|Cough|>': ' (咳嗽) ',
|
||||
'<|Sneeze|>': ' (喷嚏) ',
|
||||
}
|
||||
|
||||
/**
|
||||
* 富文本后处理:移除技术标签,转换识别出的情感和声音事件
|
||||
*/
|
||||
function richTranscribePostProcess(text: string): string {
|
||||
if (!text) return ''
|
||||
|
||||
let processed = text
|
||||
|
||||
// 1. 转换情感和事件标签
|
||||
for (const [tag, replacement] of Object.entries(RICH_TAG_MAP)) {
|
||||
// 使用正则全局替换,不区分大小写以防不同版本差异
|
||||
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
|
||||
processed = processed.replace(new RegExp(escapedTag, 'gi'), replacement)
|
||||
}
|
||||
|
||||
// 2. 移除所有剩余的技术标签
|
||||
for (const tag of TECH_TAGS) {
|
||||
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
|
||||
processed = processed.replace(new RegExp(escapedTag, 'gi'), '')
|
||||
}
|
||||
|
||||
// 3. 清理多余空格并返回
|
||||
return processed.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// 检查识别结果是否在允许的语言列表中
|
||||
function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
|
||||
if (!result || !result.lang) {
|
||||
// 如果没有语言信息,默认允许(或从文本开头尝试提取)
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果没有指定语言或语言列表为空,默认允许中文和粤语
|
||||
if (!allowedLanguages || allowedLanguages.length === 0) {
|
||||
allowedLanguages = ['zh', 'yue']
|
||||
}
|
||||
|
||||
const langTag = result.lang
|
||||
|
||||
|
||||
// 检查是否在允许的语言列表中
|
||||
for (const lang of allowedLanguages) {
|
||||
if (LANGUAGE_TAGS[lang] === langTag) {
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!parentPort) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
||||
let sherpa: any;
|
||||
try {
|
||||
sherpa = require('sherpa-onnx-node');
|
||||
} catch (requireError) {
|
||||
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||
return;
|
||||
}
|
||||
|
||||
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
|
||||
const wavData = Buffer.from(rawWavData);
|
||||
// 确保有有效的语言列表,默认只允许中文
|
||||
let allowedLanguages = languages || ['zh']
|
||||
if (allowedLanguages.length === 0) {
|
||||
allowedLanguages = ['zh']
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 1. 初始化识别器 (SenseVoiceSmall)
|
||||
const recognizerConfig = {
|
||||
modelConfig: {
|
||||
senseVoice: {
|
||||
model: modelPath,
|
||||
useInverseTextNormalization: 1
|
||||
},
|
||||
tokens: tokensPath,
|
||||
numThreads: 2,
|
||||
debug: 0
|
||||
}
|
||||
}
|
||||
const recognizer = new sherpa.OfflineRecognizer(recognizerConfig)
|
||||
|
||||
// 2. 处理音频数据 (全量识别)
|
||||
const pcmData = wavData.slice(44)
|
||||
const samples = new Float32Array(pcmData.length / 2)
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
samples[i] = pcmData.readInt16LE(i * 2) / 32768.0
|
||||
}
|
||||
|
||||
const stream = recognizer.createStream()
|
||||
stream.acceptWaveform({ sampleRate, samples })
|
||||
recognizer.decode(stream)
|
||||
const result = recognizer.getResult(stream)
|
||||
|
||||
|
||||
|
||||
// 3. 检查语言是否在白名单中
|
||||
if (isLanguageAllowed(result, allowedLanguages)) {
|
||||
const processedText = richTranscribePostProcess(result.text)
|
||||
|
||||
parentPort.postMessage({ type: 'final', text: processedText })
|
||||
} else {
|
||||
|
||||
parentPort.postMessage({ type: 'final', text: '' })
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ type: 'error', error: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
4
electron/types/sherpa-onnx-node.d.ts
vendored
Normal file
4
electron/types/sherpa-onnx-node.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'sherpa-onnx-node' {
|
||||
const content: any;
|
||||
export = content;
|
||||
}
|
||||
22
electron/types/whisper-node.d.ts
vendored
Normal file
22
electron/types/whisper-node.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
declare module 'whisper-node' {
|
||||
export type WhisperSegment = {
|
||||
start: string
|
||||
end: string
|
||||
speech: string
|
||||
}
|
||||
|
||||
export type WhisperOptions = {
|
||||
modelName?: string
|
||||
modelPath?: string
|
||||
whisperOptions?: {
|
||||
language?: string
|
||||
gen_file_txt?: boolean
|
||||
gen_file_subtitle?: boolean
|
||||
gen_file_vtt?: boolean
|
||||
word_timestamps?: boolean
|
||||
timestamp_size?: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function whisper(filePath: string, options?: WhisperOptions): Promise<WhisperSegment[]>
|
||||
}
|
||||
169
electron/wcdbWorker.ts
Normal file
169
electron/wcdbWorker.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import { WcdbCore } from './services/wcdbCore'
|
||||
|
||||
const core = new WcdbCore()
|
||||
|
||||
if (parentPort) {
|
||||
parentPort.on('message', async (msg) => {
|
||||
const { id, type, payload } = msg
|
||||
|
||||
try {
|
||||
let result: any
|
||||
|
||||
switch (type) {
|
||||
case 'setPaths':
|
||||
core.setPaths(payload.resourcesPath, payload.userDataPath)
|
||||
result = { success: true }
|
||||
break
|
||||
case 'setLogEnabled':
|
||||
core.setLogEnabled(payload.enabled)
|
||||
result = { success: true }
|
||||
break
|
||||
case 'setMonitor':
|
||||
core.setMonitor((type, json) => {
|
||||
parentPort!.postMessage({
|
||||
id: -1,
|
||||
type: 'monitor',
|
||||
payload: { type, json }
|
||||
})
|
||||
})
|
||||
result = { success: true }
|
||||
break
|
||||
case 'testConnection':
|
||||
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||
break
|
||||
case 'open':
|
||||
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
|
||||
break
|
||||
case 'close':
|
||||
core.close()
|
||||
result = { success: true }
|
||||
break
|
||||
case 'isConnected':
|
||||
result = core.isConnected()
|
||||
break
|
||||
case 'getSessions':
|
||||
result = await core.getSessions()
|
||||
break
|
||||
case 'getMessages':
|
||||
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
|
||||
break
|
||||
case 'getNewMessages':
|
||||
result = await core.getNewMessages(payload.sessionId, payload.minTime, payload.limit)
|
||||
break
|
||||
case 'getMessageCount':
|
||||
result = await core.getMessageCount(payload.sessionId)
|
||||
break
|
||||
case 'getDisplayNames':
|
||||
result = await core.getDisplayNames(payload.usernames)
|
||||
break
|
||||
case 'getAvatarUrls':
|
||||
result = await core.getAvatarUrls(payload.usernames)
|
||||
break
|
||||
case 'getGroupMemberCount':
|
||||
result = await core.getGroupMemberCount(payload.chatroomId)
|
||||
break
|
||||
case 'getGroupMemberCounts':
|
||||
result = await core.getGroupMemberCounts(payload.chatroomIds)
|
||||
break
|
||||
case 'getGroupMembers':
|
||||
result = await core.getGroupMembers(payload.chatroomId)
|
||||
break
|
||||
case 'getGroupNicknames':
|
||||
result = await core.getGroupNicknames(payload.chatroomId)
|
||||
break
|
||||
case 'getMessageTables':
|
||||
result = await core.getMessageTables(payload.sessionId)
|
||||
break
|
||||
case 'getMessageTableStats':
|
||||
result = await core.getMessageTableStats(payload.sessionId)
|
||||
break
|
||||
case 'getMessageDates':
|
||||
result = await core.getMessageDates(payload.sessionId)
|
||||
break
|
||||
case 'getMessageMeta':
|
||||
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
||||
break
|
||||
case 'getContact':
|
||||
result = await core.getContact(payload.username)
|
||||
break
|
||||
case 'getAggregateStats':
|
||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getAvailableYears':
|
||||
result = await core.getAvailableYears(payload.sessionIds)
|
||||
break
|
||||
case 'getAnnualReportStats':
|
||||
result = await core.getAnnualReportStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getAnnualReportExtras':
|
||||
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
|
||||
break
|
||||
case 'getDualReportStats':
|
||||
result = await core.getDualReportStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getGroupStats':
|
||||
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'openMessageCursor':
|
||||
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'openMessageCursorLite':
|
||||
result = await core.openMessageCursorLite(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'fetchMessageBatch':
|
||||
result = await core.fetchMessageBatch(payload.cursor)
|
||||
break
|
||||
case 'closeMessageCursor':
|
||||
result = await core.closeMessageCursor(payload.cursor)
|
||||
break
|
||||
case 'execQuery':
|
||||
result = await core.execQuery(payload.kind, payload.path, payload.sql)
|
||||
break
|
||||
case 'getEmoticonCdnUrl':
|
||||
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
||||
break
|
||||
case 'listMessageDbs':
|
||||
result = await core.listMessageDbs()
|
||||
break
|
||||
case 'listMediaDbs':
|
||||
result = await core.listMediaDbs()
|
||||
break
|
||||
case 'getMessageById':
|
||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||
break
|
||||
case 'getVoiceData':
|
||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||
if (!result.success) {
|
||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||
}
|
||||
break
|
||||
case 'getSnsTimeline':
|
||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||
break
|
||||
case 'getSnsAnnualStats':
|
||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getLogs':
|
||||
result = await core.getLogs()
|
||||
break
|
||||
case 'verifyUser':
|
||||
result = await core.verifyUser(payload.message, payload.hwnd)
|
||||
break
|
||||
case 'updateMessage':
|
||||
result = await core.updateMessage(payload.sessionId, payload.localId, payload.createTime, payload.newContent)
|
||||
break
|
||||
case 'deleteMessage':
|
||||
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
||||
break
|
||||
|
||||
default:
|
||||
result = { success: false, error: `Unknown method: ${type}` }
|
||||
}
|
||||
|
||||
parentPort!.postMessage({ id, result })
|
||||
} catch (e) {
|
||||
parentPort!.postMessage({ id, error: String(e) })
|
||||
}
|
||||
})
|
||||
}
|
||||
198
electron/windows/notificationWindow.ts
Normal file
198
electron/windows/notificationWindow.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { ConfigService } from '../services/config'
|
||||
|
||||
let notificationWindow: BrowserWindow | null = null
|
||||
let closeTimer: NodeJS.Timeout | null = null
|
||||
|
||||
export function createNotificationWindow() {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow
|
||||
}
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
|
||||
console.log('[NotificationWindow] Creating window...')
|
||||
const width = 344
|
||||
const height = 114
|
||||
|
||||
// Update default creation size
|
||||
notificationWindow = new BrowserWindow({
|
||||
width: width,
|
||||
height: height,
|
||||
type: 'toolbar', // 有助于在某些操作系统上保持置顶
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false, // 不抢占焦点
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist)
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// devTools: true // Enable DevTools
|
||||
}
|
||||
})
|
||||
|
||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透
|
||||
|
||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||
// 实际上,我们希望窗口可点击。
|
||||
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||
|
||||
const loadUrl = isDev
|
||||
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||
: `file://${join(__dirname, '../dist/index.html')}#/notification-window`
|
||||
|
||||
console.log('[NotificationWindow] Loading URL:', loadUrl)
|
||||
notificationWindow.loadURL(loadUrl)
|
||||
|
||||
notificationWindow.on('closed', () => {
|
||||
notificationWindow = null
|
||||
})
|
||||
|
||||
return notificationWindow
|
||||
}
|
||||
|
||||
export async function showNotification(data: any) {
|
||||
// 先检查配置
|
||||
const config = ConfigService.getInstance()
|
||||
const enabled = await config.get('notificationEnabled')
|
||||
if (enabled === false) return // 默认为 true
|
||||
|
||||
// 检查会话过滤
|
||||
const filterMode = config.get('notificationFilterMode') || 'all'
|
||||
const filterList = config.get('notificationFilterList') || []
|
||||
const sessionId = data.sessionId
|
||||
|
||||
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
|
||||
const isInList = filterList.includes(sessionId)
|
||||
if (filterMode === 'whitelist' && !isInList) {
|
||||
// 白名单模式:不在列表中则不显示
|
||||
return
|
||||
}
|
||||
if (filterMode === 'blacklist' && isInList) {
|
||||
// 黑名单模式:在列表中则不显示
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let win = notificationWindow
|
||||
if (!win || win.isDestroyed()) {
|
||||
win = createNotificationWindow()
|
||||
}
|
||||
|
||||
if (!win) return
|
||||
|
||||
// 确保加载完成
|
||||
if (win.webContents.isLoading()) {
|
||||
win.once('ready-to-show', () => {
|
||||
showAndSend(win!, data)
|
||||
})
|
||||
} else {
|
||||
showAndSend(win, data)
|
||||
}
|
||||
}
|
||||
|
||||
let lastNotificationData: any = null
|
||||
|
||||
async function showAndSend(win: BrowserWindow, data: any) {
|
||||
lastNotificationData = data
|
||||
const config = ConfigService.getInstance()
|
||||
const position = (await config.get('notificationPosition')) || 'top-right'
|
||||
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||
const winWidth = 344
|
||||
const winHeight = 114
|
||||
const padding = 20
|
||||
|
||||
let x = 0
|
||||
let y = 0
|
||||
|
||||
switch (position) {
|
||||
case 'top-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = padding
|
||||
break
|
||||
case 'bottom-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = screenHeight - winHeight - padding
|
||||
break
|
||||
case 'top-left':
|
||||
x = padding
|
||||
y = padding
|
||||
break
|
||||
case 'bottom-left':
|
||||
x = padding
|
||||
y = screenHeight - winHeight - padding
|
||||
break
|
||||
}
|
||||
|
||||
win.setPosition(Math.floor(x), Math.floor(y))
|
||||
win.setSize(winWidth, winHeight) // 确保尺寸
|
||||
|
||||
// 设为可交互
|
||||
win.setIgnoreMouseEvents(false)
|
||||
win.showInactive() // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||
|
||||
win.webContents.send('notification:show', data)
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
}
|
||||
|
||||
export function registerNotificationHandlers() {
|
||||
ipcMain.handle('notification:show', (_, data) => {
|
||||
showNotification(data)
|
||||
})
|
||||
|
||||
ipcMain.handle('notification:close', () => {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
notificationWindow.hide()
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Handle renderer ready event (fix race condition)
|
||||
ipcMain.on('notification:ready', (event) => {
|
||||
console.log('[NotificationWindow] Renderer ready, checking cached data')
|
||||
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
console.log('[NotificationWindow] Re-sending cached data')
|
||||
notificationWindow.webContents.send('notification:show', lastNotificationData)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle resize request from renderer
|
||||
ipcMain.on('notification:resize', (event, { width, height }) => {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
// Enforce max-height if needed, or trust renderer
|
||||
// Ensure it doesn't go off screen bottom?
|
||||
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
|
||||
// If we resize, we should re-calculate position to keep it anchored?
|
||||
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
|
||||
// If bottom-right, growing down pushes it off screen.
|
||||
|
||||
// Simple version: just setSize. For V1 we assume Top-Right.
|
||||
// But wait, the config supports bottom-right.
|
||||
// We can re-call setPosition or just let it be.
|
||||
// If bottom-right, y needs to prevent overflow.
|
||||
|
||||
// Ideally we get current config position
|
||||
const bounds = notificationWindow.getBounds()
|
||||
// Check if we need to adjust Y?
|
||||
// For now, let's just set the size as requested.
|
||||
notificationWindow.setSize(Math.round(width), Math.round(height))
|
||||
}
|
||||
})
|
||||
|
||||
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "WordFunc.nsh"
|
||||
!include "nsDialogs.nsh"
|
||||
|
||||
!macro customInit
|
||||
; 设置 DPI 感知
|
||||
@@ -16,3 +17,49 @@ ManifestDPIAware true
|
||||
StrCpy $INSTDIR "$INSTDIR\WeFlow"
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
; 安装完成后检测并安装 VC++ Redistributable
|
||||
!macro customInstall
|
||||
; 检查 VC++ 2015-2022 x64 是否已安装
|
||||
ReadRegStr $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
|
||||
${If} $0 != "1"
|
||||
; 未安装,显示提示并下载
|
||||
MessageBox MB_YESNO|MB_ICONQUESTION "检测到系统缺少 Visual C++ 运行库,这可能导致程序无法正常运行。$\n$\n是否立即下载并安装?(约 24MB)" IDYES downloadVC IDNO skipVC
|
||||
|
||||
downloadVC:
|
||||
DetailPrint "正在下载 Visual C++ Redistributable..."
|
||||
SetOutPath "$TEMP"
|
||||
|
||||
; 从微软官方下载 VC++ Redistributable x64
|
||||
inetc::get /TIMEOUT=30000 /CAPTION "下载 Visual C++ 运行库" /BANNER "正在下载,请稍候..." \
|
||||
"https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe" /END
|
||||
Pop $0
|
||||
|
||||
${If} $0 == "OK"
|
||||
DetailPrint "下载完成,正在安装..."
|
||||
; 使用 ShellExecute 以管理员权限运行
|
||||
ExecShell "runas" '"$TEMP\vc_redist.x64.exe"' "/install /quiet /norestart" SW_HIDE
|
||||
; 等待安装完成
|
||||
Sleep 5000
|
||||
; 检查是否安装成功
|
||||
ReadRegStr $1 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
|
||||
${If} $1 == "1"
|
||||
DetailPrint "Visual C++ Redistributable 安装成功"
|
||||
MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!"
|
||||
${Else}
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,你可能需要手动安装。"
|
||||
${EndIf}
|
||||
Delete "$TEMP\vc_redist.x64.exe"
|
||||
${Else}
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n你可以稍后手动下载安装 Visual C++ Redistributable。"
|
||||
${EndIf}
|
||||
Goto doneVC
|
||||
|
||||
skipVC:
|
||||
DetailPrint "用户跳过 Visual C++ Redistributable 安装"
|
||||
|
||||
doneVC:
|
||||
${Else}
|
||||
DetailPrint "Visual C++ Redistributable 已安装"
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
3975
package-lock.json
generated
3975
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -1,16 +1,22 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.0.4",
|
||||
"description": "WeFlow - 微信聊天记录查看工具",
|
||||
"version": "2.1.0",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "cc",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hicccc77/WeFlow"
|
||||
},
|
||||
"//": "二改不应改变此处的作者与应用信息",
|
||||
"scripts": {
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"rebuild": "electron-rebuild",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "vite --mode electron",
|
||||
"electron:build": "npm run build",
|
||||
"postinstall": "electron-rebuild"
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
@@ -18,15 +24,23 @@
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^10.0.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"exceljs": "^4.4.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"fzstd": "^0.1.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jieba-wasm": "^2.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"node-llama-cpp": "^3.15.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
@@ -49,6 +63,8 @@
|
||||
"appId": "com.WeFlow.app",
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "hicccc77",
|
||||
"repo": "WeFlow",
|
||||
"releaseType": "release"
|
||||
},
|
||||
"productName": "WeFlow",
|
||||
@@ -91,11 +107,38 @@
|
||||
{
|
||||
"from": "public/icon.ico",
|
||||
"to": "icon.ico"
|
||||
},
|
||||
{
|
||||
"from": "electron/assets/wasm/",
|
||||
"to": "assets/wasm/"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/silk-wasm/**/*",
|
||||
"node_modules/sherpa-onnx-node/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
resources/SDL2.dll
Normal file
BIN
resources/SDL2.dll
Normal file
Binary file not shown.
BIN
resources/msvcp140.dll
Normal file
BIN
resources/msvcp140.dll
Normal file
Binary file not shown.
BIN
resources/msvcp140_1.dll
Normal file
BIN
resources/msvcp140_1.dll
Normal file
Binary file not shown.
Binary file not shown.
BIN
resources/vcruntime140.dll
Normal file
BIN
resources/vcruntime140.dll
Normal file
Binary file not shown.
BIN
resources/vcruntime140_1.dll
Normal file
BIN
resources/vcruntime140_1.dll
Normal file
Binary file not shown.
Binary file not shown.
11
src/App.scss
11
src/App.scss
@@ -6,6 +6,17 @@
|
||||
animation: appFadeIn 0.35s ease-out;
|
||||
}
|
||||
|
||||
.window-drag-region {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 150px; // 预留系统最小化/最大化/关闭按钮区域
|
||||
height: 40px;
|
||||
-webkit-app-region: drag;
|
||||
pointer-events: auto;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
235
src/App.tsx
235
src/App.tsx
@@ -7,44 +7,80 @@ import WelcomePage from './pages/WelcomePage'
|
||||
import HomePage from './pages/HomePage'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import AnalyticsPage from './pages/AnalyticsPage'
|
||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||
import AnnualReportPage from './pages/AnnualReportPage'
|
||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||
import DualReportPage from './pages/DualReportPage'
|
||||
import DualReportWindow from './pages/DualReportWindow'
|
||||
import AgreementPage from './pages/AgreementPage'
|
||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||
import DataManagementPage from './pages/DataManagementPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ExportPage from './pages/ExportPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
import ImageWindow from './pages/ImageWindow'
|
||||
import SnsPage from './pages/SnsPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
import AIChatPage from './pages/AIChatPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||
import * as configService from './services/config'
|
||||
import { Download, X, Shield } from 'lucide-react'
|
||||
import './App.scss'
|
||||
|
||||
import UpdateDialog from './components/UpdateDialog'
|
||||
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||
import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { setDbConnected } = useAppStore()
|
||||
|
||||
const {
|
||||
setDbConnected,
|
||||
updateInfo,
|
||||
setUpdateInfo,
|
||||
isDownloading,
|
||||
setIsDownloading,
|
||||
downloadProgress,
|
||||
setDownloadProgress,
|
||||
showUpdateDialog,
|
||||
setShowUpdateDialog,
|
||||
setUpdateError,
|
||||
isLocked,
|
||||
setLocked
|
||||
} = useAppStore()
|
||||
|
||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||
const isAgreementWindow = location.pathname === '/agreement-window'
|
||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
||||
const isNotificationWindow = location.pathname === '/notification-window'
|
||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||
|
||||
// 锁定状态
|
||||
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||
const [lockAvatar, setLockAvatar] = useState<string | undefined>(
|
||||
localStorage.getItem('app_lock_avatar') || undefined
|
||||
)
|
||||
const [lockUseHello, setLockUseHello] = useState(false)
|
||||
|
||||
// 协议同意状态
|
||||
const [showAgreement, setShowAgreement] = useState(false)
|
||||
const [agreementChecked, setAgreementChecked] = useState(false)
|
||||
const [agreementLoading, setAgreementLoading] = useState(true)
|
||||
|
||||
// 更新提示状态
|
||||
const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const body = document.body
|
||||
const appRoot = document.getElementById('app')
|
||||
|
||||
if (isOnboardingWindow) {
|
||||
if (isOnboardingWindow || isNotificationWindow) {
|
||||
root.style.background = 'transparent'
|
||||
body.style.background = 'transparent'
|
||||
body.style.overflow = 'hidden'
|
||||
@@ -70,10 +106,10 @@ function App() {
|
||||
|
||||
// 更新窗口控件颜色以适配主题
|
||||
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
||||
if (!isOnboardingWindow) {
|
||||
if (!isOnboardingWindow && !isNotificationWindow) {
|
||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
||||
}
|
||||
}, [currentTheme, themeMode, isOnboardingWindow])
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
||||
|
||||
// 读取已保存的主题设置
|
||||
useEffect(() => {
|
||||
@@ -143,26 +179,48 @@ function App() {
|
||||
|
||||
// 监听启动时的更新通知
|
||||
useEffect(() => {
|
||||
const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info) => {
|
||||
setUpdateInfo(info)
|
||||
if (isNotificationWindow) return // Skip updates in notification window
|
||||
|
||||
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||
// 发现新版本时自动打开更新弹窗
|
||||
if (info) {
|
||||
setUpdateInfo({ ...info, hasUpdate: true })
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
})
|
||||
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
|
||||
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||
setDownloadProgress(progress)
|
||||
})
|
||||
return () => {
|
||||
removeUpdateListener?.()
|
||||
removeProgressListener?.()
|
||||
}
|
||||
}, [])
|
||||
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||
|
||||
const handleUpdateNow = async () => {
|
||||
setShowUpdateDialog(false)
|
||||
setIsDownloading(true)
|
||||
setDownloadProgress(0)
|
||||
setDownloadProgress({ percent: 0 })
|
||||
try {
|
||||
await window.electronAPI.app.downloadAndInstall()
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('更新失败:', e)
|
||||
setIsDownloading(false)
|
||||
// Extract clean error message if possible
|
||||
const errorMsg = e.message || String(e)
|
||||
setUpdateError(errorMsg.includes('暂时禁用') ? '自动更新已暂时禁用' : errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIgnoreUpdate = async () => {
|
||||
if (!updateInfo || !updateInfo.version) return
|
||||
|
||||
try {
|
||||
await window.electronAPI.app.ignoreUpdate(updateInfo.version)
|
||||
setShowUpdateDialog(false)
|
||||
setUpdateInfo(null)
|
||||
} catch (e: any) {
|
||||
console.error('忽略更新失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,34 +238,81 @@ function App() {
|
||||
const decryptKey = await configService.getDecryptKey()
|
||||
const wxid = await configService.getMyWxid()
|
||||
const onboardingDone = await configService.getOnboardingDone()
|
||||
const wxidConfig = wxid ? await configService.getWxidConfig(wxid) : null
|
||||
const effectiveDecryptKey = wxidConfig?.decryptKey || decryptKey
|
||||
|
||||
if (wxidConfig?.decryptKey && wxidConfig.decryptKey !== decryptKey) {
|
||||
await configService.setDecryptKey(wxidConfig.decryptKey)
|
||||
}
|
||||
|
||||
// 如果配置完整,自动测试连接
|
||||
if (dbPath && decryptKey && wxid) {
|
||||
if (dbPath && effectiveDecryptKey && wxid) {
|
||||
if (!onboardingDone) {
|
||||
await configService.setOnboardingDone(true)
|
||||
}
|
||||
console.log('检测到已保存的配置,正在自动连接...')
|
||||
|
||||
const result = await window.electronAPI.chat.connect()
|
||||
|
||||
if (result.success) {
|
||||
console.log('自动连接成功')
|
||||
|
||||
setDbConnected(true, dbPath)
|
||||
// 如果当前在欢迎页,跳转到首页
|
||||
if (window.location.hash === '#/' || window.location.hash === '') {
|
||||
navigate('/home')
|
||||
}
|
||||
} else {
|
||||
console.log('自动连接失败:', result.error)
|
||||
|
||||
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
||||
// 其他错误可能需要重新配置
|
||||
const errorMsg = result.error || ''
|
||||
if (errorMsg.includes('Visual C++') ||
|
||||
errorMsg.includes('DLL') ||
|
||||
errorMsg.includes('Worker') ||
|
||||
errorMsg.includes('126') ||
|
||||
errorMsg.includes('模块')) {
|
||||
console.warn('检测到可能的运行时依赖问题:', errorMsg)
|
||||
// 不清除配置,让用户安装 VC++ 后重试
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('自动连接出错:', e)
|
||||
// 捕获异常但不清除配置,防止循环重新引导
|
||||
}
|
||||
}
|
||||
|
||||
autoConnect()
|
||||
}, [isAgreementWindow, isOnboardingWindow, navigate, setDbConnected])
|
||||
|
||||
// 检查应用锁
|
||||
useEffect(() => {
|
||||
if (isAgreementWindow || isOnboardingWindow || isVideoPlayerWindow) return
|
||||
|
||||
const checkLock = async () => {
|
||||
// 并行获取配置,减少等待
|
||||
const [enabled, useHello] = await Promise.all([
|
||||
configService.getAuthEnabled(),
|
||||
configService.getAuthUseHello()
|
||||
])
|
||||
|
||||
if (enabled) {
|
||||
setLockUseHello(useHello)
|
||||
setLocked(true)
|
||||
// 尝试获取头像
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMyAvatarUrl()
|
||||
if (result && result.success && result.avatarUrl) {
|
||||
setLockAvatar(result.avatarUrl)
|
||||
localStorage.setItem('app_lock_avatar', result.avatarUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取锁屏头像失败', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
checkLock()
|
||||
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
|
||||
|
||||
// 独立协议窗口
|
||||
if (isAgreementWindow) {
|
||||
return <AgreementPage />
|
||||
@@ -217,11 +322,49 @@ function App() {
|
||||
return <WelcomePage standalone />
|
||||
}
|
||||
|
||||
// 独立视频播放窗口
|
||||
if (isVideoPlayerWindow) {
|
||||
return <VideoWindow />
|
||||
}
|
||||
|
||||
// 独立图片查看窗口
|
||||
const isImageViewerWindow = location.pathname === '/image-viewer-window'
|
||||
if (isImageViewerWindow) {
|
||||
return <ImageWindow />
|
||||
}
|
||||
|
||||
// 独立聊天记录窗口
|
||||
if (isChatHistoryWindow) {
|
||||
return <ChatHistoryPage />
|
||||
}
|
||||
|
||||
// 独立通知窗口
|
||||
if (isNotificationWindow) {
|
||||
return <NotificationWindow />
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
return (
|
||||
<div className="app-container">
|
||||
<div className="window-drag-region" aria-hidden="true" />
|
||||
{isLocked && (
|
||||
<LockScreen
|
||||
onUnlock={() => setLocked(false)}
|
||||
avatar={lockAvatar}
|
||||
useHello={lockUseHello}
|
||||
/>
|
||||
)}
|
||||
<TitleBar />
|
||||
|
||||
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
||||
<UpdateProgressCapsule />
|
||||
|
||||
{/* 全局会话监听与通知 */}
|
||||
<GlobalSessionMonitor />
|
||||
|
||||
{/* 全局批量转写进度浮窗 */}
|
||||
<BatchTranscribeGlobal />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
<div className="agreement-overlay">
|
||||
@@ -243,13 +386,13 @@ function App() {
|
||||
</div>
|
||||
<div className="agreement-text">
|
||||
<h4>1. 数据安全</h4>
|
||||
<p>本软件所有数据处理均在本地完成,不会上传任何聊天记录、个人信息到服务器。您的数据完全由您自己掌控。</p>
|
||||
<p>本软件所有数据处理均在本地完成,不会上传任何聊天记录、个人信息到服务器。你的数据完全由你自己掌控。</p>
|
||||
|
||||
<h4>2. 使用须知</h4>
|
||||
<p>本软件仅供个人学习研究使用,请勿用于任何非法用途。使用本软件解密、查看、分析的数据应为您本人所有或已获得授权。</p>
|
||||
<p>本软件仅供个人学习研究使用,请勿用于任何非法用途。使用本软件解密、查看、分析的数据应为你本人所有或已获得授权。</p>
|
||||
|
||||
<h4>3. 免责声明</h4>
|
||||
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保您的使用行为符合当地法律法规。</p>
|
||||
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p>
|
||||
|
||||
<h4>4. 隐私保护</h4>
|
||||
<p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||
@@ -273,31 +416,16 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 更新提示条 */}
|
||||
{updateInfo && (
|
||||
<div className="update-banner">
|
||||
<span className="update-text">
|
||||
发现新版本 <strong>v{updateInfo.version}</strong>
|
||||
</span>
|
||||
{isDownloading ? (
|
||||
<div className="update-progress">
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span>{downloadProgress.toFixed(0)}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button className="update-btn" onClick={handleUpdateNow}>
|
||||
<Download size={14} /> 立即更新
|
||||
</button>
|
||||
<button className="dismiss-btn" onClick={dismissUpdate}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 更新提示对话框 */}
|
||||
<UpdateDialog
|
||||
open={showUpdateDialog}
|
||||
updateInfo={updateInfo}
|
||||
onClose={() => setShowUpdateDialog(false)}
|
||||
onUpdate={handleUpdateNow}
|
||||
onIgnore={handleIgnoreUpdate}
|
||||
isDownloading={isDownloading}
|
||||
progress={downloadProgress}
|
||||
/>
|
||||
|
||||
<div className="main-layout">
|
||||
<Sidebar />
|
||||
@@ -307,13 +435,20 @@ function App() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
<Route path="/data-management" element={<DataManagementPage />} />
|
||||
<Route path="/dual-report" element={<DualReportPage />} />
|
||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
|
||||
73
src/components/AnimatedStreamingText.tsx
Normal file
73
src/components/AnimatedStreamingText.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { memo, useEffect, useState, useRef } from 'react'
|
||||
|
||||
interface AnimatedStreamingTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export const AnimatedStreamingText = memo(({ text, className, loading }: AnimatedStreamingTextProps) => {
|
||||
const [displayedSegments, setDisplayedSegments] = useState<string[]>([])
|
||||
const prevTextRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
const currentText = (text || '').trim()
|
||||
const prevText = prevTextRef.current
|
||||
|
||||
if (currentText === prevText) return
|
||||
if (!currentText.startsWith(prevText) && prevText !== '') {
|
||||
// 如果不是追加而是全新的文本(比如重新识别),则重置
|
||||
setDisplayedSegments([currentText])
|
||||
prevTextRef.current = currentText
|
||||
return
|
||||
}
|
||||
|
||||
const newPart = currentText.slice(prevText.length)
|
||||
if (newPart) {
|
||||
// 将新部分作为单独的段加入,以触发动画
|
||||
setDisplayedSegments(prev => [...prev, newPart])
|
||||
}
|
||||
prevTextRef.current = currentText
|
||||
}, [text])
|
||||
|
||||
// 处理 loading 状态的显示
|
||||
if (loading && !text) {
|
||||
return <span className={className}>转写中<span className="dot-flashing">...</span></span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{displayedSegments.map((segment, index) => (
|
||||
<span key={index} className="fade-in-text">
|
||||
{segment}
|
||||
</span>
|
||||
))}
|
||||
<style>{`
|
||||
.fade-in-text {
|
||||
animation: premiumFadeIn 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
opacity: 0;
|
||||
display: inline-block;
|
||||
filter: blur(4px);
|
||||
}
|
||||
@keyframes premiumFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
filter: blur(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
.dot-flashing {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
AnimatedStreamingText.displayName = 'AnimatedStreamingText'
|
||||
79
src/components/Avatar.scss
Normal file
79
src/components/Avatar.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
.avatar-component {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-tertiary, #f5f5f5);
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
/* Default radius */
|
||||
|
||||
&.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.rounded {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Image styling */
|
||||
img.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
border-radius: inherit;
|
||||
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.instant {
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Placeholder/Letter styling */
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #666);
|
||||
background-color: var(--bg-tertiary, #e0e0e0);
|
||||
font-size: 1.2em;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Loading Skeleton */
|
||||
.avatar-skeleton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg,
|
||||
var(--bg-tertiary, #f0f0f0) 25%,
|
||||
var(--bg-secondary, #e0e0e0) 50%,
|
||||
var(--bg-tertiary, #f0f0f0) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/components/Avatar.tsx
Normal file
129
src/components/Avatar.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { User } from 'lucide-react'
|
||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||
import './Avatar.scss'
|
||||
|
||||
// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画
|
||||
const loadedAvatarCache = new Set<string>()
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string
|
||||
name?: string
|
||||
size?: number | string
|
||||
shape?: 'circle' | 'square' | 'rounded'
|
||||
className?: string
|
||||
lazy?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export const Avatar = React.memo(function Avatar({
|
||||
src,
|
||||
name,
|
||||
size = 48,
|
||||
shape = 'rounded',
|
||||
className = '',
|
||||
lazy = true,
|
||||
onClick
|
||||
}: AvatarProps) {
|
||||
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
||||
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
||||
const [imageLoaded, setImageLoaded] = useState(isCached)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
||||
const [isInQueue, setIsInQueue] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const getAvatarLetter = (): string => {
|
||||
if (!name) return '?'
|
||||
const chars = [...name]
|
||||
return chars[0] || '?'
|
||||
}
|
||||
|
||||
// Intersection Observer for lazy loading
|
||||
useEffect(() => {
|
||||
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && !isInQueue) {
|
||||
setIsInQueue(true)
|
||||
avatarLoadQueue.enqueue(src).then(() => {
|
||||
setShouldLoad(true)
|
||||
}).catch(() => {
|
||||
// 加载失败不要立刻显示错误,让浏览器渲染去报错
|
||||
setShouldLoad(true)
|
||||
}).finally(() => {
|
||||
setIsInQueue(false)
|
||||
})
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
},
|
||||
{ rootMargin: '100px' }
|
||||
)
|
||||
|
||||
observer.observe(containerRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [src, lazy, shouldLoad, isInQueue, isCached])
|
||||
|
||||
// Reset state when src changes
|
||||
useEffect(() => {
|
||||
const cached = src ? loadedAvatarCache.has(src) : false
|
||||
setImageLoaded(cached)
|
||||
setImageError(false)
|
||||
if (lazy && !cached) {
|
||||
setShouldLoad(false)
|
||||
setIsInQueue(false)
|
||||
} else {
|
||||
setShouldLoad(true)
|
||||
}
|
||||
}, [src, lazy])
|
||||
|
||||
// Check if image is already cached/loaded
|
||||
useEffect(() => {
|
||||
if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
||||
setImageLoaded(true)
|
||||
}
|
||||
}, [src, shouldLoad])
|
||||
|
||||
const style = {
|
||||
width: typeof size === 'number' ? `${size}px` : size,
|
||||
height: typeof size === 'number' ? `${size}px` : size,
|
||||
}
|
||||
|
||||
const hasValidUrl = !!src && !imageError && shouldLoad
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`avatar-component ${shape} ${className}`}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{hasValidUrl ? (
|
||||
<>
|
||||
{!imageLoaded && <div className="avatar-skeleton" />}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={name || 'avatar'}
|
||||
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
||||
onLoad={() => {
|
||||
if (src) loadedAvatarCache.add(src)
|
||||
setImageLoaded(true)
|
||||
}}
|
||||
onError={() => setImageError(true)}
|
||||
loading={lazy ? "lazy" : "eager"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
147
src/components/BatchTranscribeGlobal.tsx
Normal file
147
src/components/BatchTranscribeGlobal.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock } from 'lucide-react'
|
||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||
import '../styles/batchTranscribe.scss'
|
||||
|
||||
/**
|
||||
* 全局批量转写进度浮窗 + 结果弹窗
|
||||
* 挂载在 App 层,切换页面时不会消失
|
||||
*/
|
||||
export const BatchTranscribeGlobal: React.FC = () => {
|
||||
const {
|
||||
isBatchTranscribing,
|
||||
progress,
|
||||
showToast,
|
||||
showResult,
|
||||
result,
|
||||
sessionName,
|
||||
startTime,
|
||||
setShowToast,
|
||||
setShowResult
|
||||
} = useBatchTranscribeStore()
|
||||
|
||||
const [eta, setEta] = useState<string>('')
|
||||
|
||||
// 计算剩余时间
|
||||
useEffect(() => {
|
||||
if (!isBatchTranscribing || !startTime || progress.current === 0) {
|
||||
setEta('')
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const elapsed = now - startTime
|
||||
const rate = progress.current / elapsed // ms per item
|
||||
const remainingItems = progress.total - progress.current
|
||||
|
||||
if (remainingItems <= 0) {
|
||||
setEta('')
|
||||
return
|
||||
}
|
||||
|
||||
const remainingTimeMs = remainingItems / rate
|
||||
const remainingSeconds = Math.ceil(remainingTimeMs / 1000)
|
||||
|
||||
if (remainingSeconds < 60) {
|
||||
setEta(`${remainingSeconds}秒`)
|
||||
} else {
|
||||
const minutes = Math.floor(remainingSeconds / 60)
|
||||
const seconds = remainingSeconds % 60
|
||||
setEta(`${minutes}分${seconds}秒`)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [isBatchTranscribing, startTime, progress.current, progress.total])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 批量转写进度浮窗(非阻塞) */}
|
||||
{showToast && isBatchTranscribing && createPortal(
|
||||
<div className="batch-progress-toast">
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<Loader2 size={14} className="spin" />
|
||||
<span>批量转写中{sessionName ? `(${sessionName})` : ''}</span>
|
||||
</div>
|
||||
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="batch-progress-toast-body">
|
||||
<div className="progress-info-row">
|
||||
<div className="progress-text">
|
||||
<span>{progress.current} / {progress.total}</span>
|
||||
<span className="progress-percent">
|
||||
{progress.total > 0
|
||||
? Math.round((progress.current / progress.total) * 100)
|
||||
: 0}%
|
||||
</span>
|
||||
</div>
|
||||
{eta && (
|
||||
<div className="progress-eta">
|
||||
<Clock size={12} />
|
||||
<span>剩余 {eta}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{
|
||||
width: `${progress.total > 0
|
||||
? (progress.current / progress.total) * 100
|
||||
: 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 批量转写结果对话框 */}
|
||||
{showResult && createPortal(
|
||||
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
|
||||
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="batch-modal-header">
|
||||
<CheckCircle size={20} />
|
||||
<h3>转写完成</h3>
|
||||
</div>
|
||||
<div className="batch-modal-body">
|
||||
<div className="result-summary">
|
||||
<div className="result-item success">
|
||||
<CheckCircle size={18} />
|
||||
<span className="label">成功:</span>
|
||||
<span className="value">{result.success} 条</span>
|
||||
</div>
|
||||
{result.fail > 0 && (
|
||||
<div className="result-item fail">
|
||||
<XCircle size={18} />
|
||||
<span className="label">失败:</span>
|
||||
<span className="value">{result.fail} 条</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{result.fail > 0 && (
|
||||
<div className="result-tip">
|
||||
<AlertCircle size={16} />
|
||||
<span>部分语音转写失败,可能是语音文件损坏或网络问题</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="batch-modal-footer">
|
||||
<button className="btn-primary" onClick={() => setShowResult(false)}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -70,6 +70,7 @@
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
@@ -144,6 +145,7 @@
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: auto repeat(6, 32px);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@@ -156,7 +158,6 @@
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
271
src/components/GlobalSessionMonitor.tsx
Normal file
271
src/components/GlobalSessionMonitor.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import type { ChatSession } from '../types/models'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export function GlobalSessionMonitor() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
sessions,
|
||||
setSessions,
|
||||
currentSessionId,
|
||||
appendMessages,
|
||||
messages
|
||||
} = useChatStore()
|
||||
|
||||
const sessionsRef = useRef(sessions)
|
||||
|
||||
// 保持 ref 同步
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions
|
||||
}, [sessions])
|
||||
|
||||
// 去重辅助函数:获取消息 key
|
||||
const getMessageKey = (msg: any) => {
|
||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||
}
|
||||
|
||||
// 处理数据库变更
|
||||
useEffect(() => {
|
||||
const handleDbChange = (_event: any, data: { type: string; json: string }) => {
|
||||
try {
|
||||
const payload = JSON.parse(data.json)
|
||||
const tableName = payload.table
|
||||
|
||||
// 只关注 Session 表
|
||||
if (tableName === 'Session' || tableName === 'session') {
|
||||
refreshSessions()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析数据库变更失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (window.electronAPI.chat.onWcdbChange) {
|
||||
const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange)
|
||||
return () => {
|
||||
removeListener()
|
||||
}
|
||||
}
|
||||
return () => { }
|
||||
}, []) // 空依赖数组 - 主要是静态的
|
||||
|
||||
const refreshSessions = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getSessions()
|
||||
if (result.success && result.sessions && Array.isArray(result.sessions)) {
|
||||
const newSessions = result.sessions as ChatSession[]
|
||||
const oldSessions = sessionsRef.current
|
||||
|
||||
// 1. 检测变更并通知
|
||||
checkForNewMessages(oldSessions, newSessions)
|
||||
|
||||
// 2. 更新 store
|
||||
setSessions(newSessions)
|
||||
|
||||
// 3. 如果在活跃会话中,增量刷新消息
|
||||
const currentId = useChatStore.getState().currentSessionId
|
||||
if (currentId) {
|
||||
const currentSessionNew = newSessions.find(s => s.username === currentId)
|
||||
const currentSessionOld = oldSessions.find(s => s.username === currentId)
|
||||
|
||||
if (currentSessionNew && (!currentSessionOld || currentSessionNew.lastTimestamp > currentSessionOld.lastTimestamp)) {
|
||||
void handleActiveSessionRefresh(currentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('全局会话刷新失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const checkForNewMessages = async (oldSessions: ChatSession[], newSessions: ChatSession[]) => {
|
||||
if (!oldSessions || oldSessions.length === 0) {
|
||||
console.log('[NotificationFilter] Skipping check on initial load (empty baseline)')
|
||||
return
|
||||
}
|
||||
|
||||
const oldMap = new Map(oldSessions.map(s => [s.username, s]))
|
||||
|
||||
for (const newSession of newSessions) {
|
||||
const oldSession = oldMap.get(newSession.username)
|
||||
|
||||
// 条件: 新会话或时间戳更新
|
||||
const isCurrentSession = newSession.username === useChatStore.getState().currentSessionId
|
||||
|
||||
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
||||
// 这是新消息事件
|
||||
|
||||
// 1. 群聊过滤自己发送的消息
|
||||
if (newSession.username.includes('@chatroom')) {
|
||||
// 如果是自己发的消息,不弹通知
|
||||
// 注意:lastMsgSender 需要后端支持返回
|
||||
// 使用宽松比较以处理 wxid_ 前缀差异
|
||||
if (newSession.lastMsgSender && newSession.selfWxid) {
|
||||
const sender = newSession.lastMsgSender.replace(/^wxid_/, '');
|
||||
const self = newSession.selfWxid.replace(/^wxid_/, '');
|
||||
|
||||
// 使用主进程日志打印,方便用户查看
|
||||
const debugInfo = {
|
||||
type: 'NotificationFilter',
|
||||
username: newSession.username,
|
||||
lastMsgSender: newSession.lastMsgSender,
|
||||
selfWxid: newSession.selfWxid,
|
||||
senderClean: sender,
|
||||
selfClean: self,
|
||||
match: sender === self
|
||||
};
|
||||
|
||||
if (window.electronAPI.log?.debug) {
|
||||
window.electronAPI.log.debug(debugInfo);
|
||||
} else {
|
||||
console.log('[NotificationFilter]', debugInfo);
|
||||
}
|
||||
|
||||
if (sender === self) {
|
||||
if (window.electronAPI.log?.debug) {
|
||||
window.electronAPI.log.debug('[NotificationFilter] Filtered own message');
|
||||
} else {
|
||||
console.log('[NotificationFilter] Filtered own message');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const missingInfo = {
|
||||
type: 'NotificationFilter Missing info',
|
||||
lastMsgSender: newSession.lastMsgSender,
|
||||
selfWxid: newSession.selfWxid
|
||||
};
|
||||
if (window.electronAPI.log?.debug) {
|
||||
window.electronAPI.log.debug(missingInfo);
|
||||
} else {
|
||||
console.log('[NotificationFilter] Missing info:', missingInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:如果未读数量没有增加,说明可能是自己在其他设备回复(或者已读),不弹通知
|
||||
const oldUnread = oldSession ? oldSession.unreadCount : 0
|
||||
const newUnread = newSession.unreadCount
|
||||
if (newUnread <= oldUnread) {
|
||||
// 仅仅是状态同步(如自己在手机上发消息 or 已读),跳过通知
|
||||
continue
|
||||
}
|
||||
|
||||
let title = newSession.displayName || newSession.username
|
||||
let avatarUrl = newSession.avatarUrl
|
||||
let content = newSession.summary || '[新消息]'
|
||||
|
||||
if (newSession.username.includes('@chatroom')) {
|
||||
// 1. 群聊过滤自己发送的消息
|
||||
// 辅助函数:清理 wxid 后缀 (如 _8602)
|
||||
const cleanWxid = (id: string) => {
|
||||
if (!id) return '';
|
||||
const trimmed = id.trim();
|
||||
// 仅移除末尾的 _xxxx (4位字母数字)
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/);
|
||||
return suffixMatch ? suffixMatch[1] : trimmed;
|
||||
}
|
||||
|
||||
if (newSession.lastMsgSender && newSession.selfWxid) {
|
||||
const senderClean = cleanWxid(newSession.lastMsgSender);
|
||||
const selfClean = cleanWxid(newSession.selfWxid);
|
||||
const match = senderClean === selfClean;
|
||||
|
||||
if (match) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 群聊显示发送者名字 (放在内容中: "Name: Message")
|
||||
// 标题保持为群聊名称 (title 变量)
|
||||
if (newSession.lastSenderDisplayName) {
|
||||
content = `${newSession.lastSenderDisplayName}: ${content}`
|
||||
}
|
||||
}
|
||||
|
||||
// 修复 "Random User" 的逻辑 (缺少具体信息)
|
||||
// 如果标题看起来像 wxid 或没有头像,尝试获取信息
|
||||
const needsEnrichment = !newSession.displayName || !newSession.avatarUrl || newSession.displayName === newSession.username
|
||||
|
||||
if (needsEnrichment && newSession.username) {
|
||||
try {
|
||||
// 尝试丰富或获取联系人详情
|
||||
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
||||
if (contact) {
|
||||
if (contact.remark || contact.nickname) {
|
||||
title = contact.remark || contact.nickname
|
||||
}
|
||||
if (contact.avatarUrl) {
|
||||
avatarUrl = contact.avatarUrl
|
||||
}
|
||||
} else {
|
||||
// 如果不在缓存/数据库中
|
||||
const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo([newSession.username])
|
||||
if (enrichResult.success && enrichResult.contacts) {
|
||||
const enrichedContact = enrichResult.contacts[newSession.username]
|
||||
if (enrichedContact) {
|
||||
if (enrichedContact.displayName) {
|
||||
title = enrichedContact.displayName
|
||||
}
|
||||
if (enrichedContact.avatarUrl) {
|
||||
avatarUrl = enrichedContact.avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果仍然没有有效名称,再尝试一次获取
|
||||
if (title === newSession.username || title.startsWith('wxid_')) {
|
||||
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
||||
if (retried) {
|
||||
title = retried.remark || retried.nickname || title
|
||||
avatarUrl = retried.avatarUrl || avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('获取通知的联系人信息失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 最终检查:如果标题仍是 wxid 格式,则跳过通知(避免显示乱跳用户)
|
||||
// 群聊例外,因为群聊 username 包含 @chatroom
|
||||
const isGroupChat = newSession.username.includes('@chatroom')
|
||||
const isWxidTitle = title.startsWith('wxid_') && title === newSession.username
|
||||
if (isWxidTitle && !isGroupChat) {
|
||||
console.warn('[NotificationFilter] 跳过无法识别的用户通知:', newSession.username)
|
||||
continue
|
||||
}
|
||||
|
||||
// 调用 IPC 以显示独立窗口通知
|
||||
window.electronAPI.notification?.show({
|
||||
title: title,
|
||||
content: content,
|
||||
avatarUrl: avatarUrl,
|
||||
sessionId: newSession.username
|
||||
})
|
||||
|
||||
// 我们不再为 Toast 设置本地状态
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleActiveSessionRefresh = async (sessionId: string) => {
|
||||
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
||||
const state = useChatStore.getState()
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
const minTime = lastMsg?.createTime || 0
|
||||
|
||||
try {
|
||||
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
||||
if (result.success && result.messages && result.messages.length > 0) {
|
||||
appendMessages(result.messages, false) // 追加到末尾
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('后台活跃会话刷新失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 此组件不再渲染 UI
|
||||
return null
|
||||
}
|
||||
90
src/components/ImagePreview.scss
Normal file
90
src/components/ImagePreview.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
.image-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
transition: transform 0.15s ease-out;
|
||||
|
||||
&.dragging {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.image-preview-close {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.live-photo-btn {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.2s;
|
||||
z-index: 10000;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--accent-color, #007aff);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
149
src/components/ImagePreview.tsx
Normal file
149
src/components/ImagePreview.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { LivePhotoIcon } from './LivePhotoIcon'
|
||||
import { createPortal } from 'react-dom'
|
||||
import './ImagePreview.scss'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
src: string
|
||||
isVideo?: boolean
|
||||
liveVideoPath?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, isVideo, liveVideoPath, onClose }) => {
|
||||
const [scale, setScale] = useState(1)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [showLive, setShowLive] = useState(false)
|
||||
const dragStart = useRef({ x: 0, y: 0 })
|
||||
const positionStart = useRef({ x: 0, y: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 滚轮缩放
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (showLive) return // 播放实况时禁止缩放? 或者支持缩放? 暂定禁止以简化
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
|
||||
}, [showLive])
|
||||
|
||||
// 开始拖动
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (showLive || scale <= 1) return
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
dragStart.current = { x: e.clientX, y: e.clientY }
|
||||
positionStart.current = { ...position }
|
||||
}, [scale, position, showLive])
|
||||
|
||||
// 拖动中
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
const dx = e.clientX - dragStart.current.x
|
||||
const dy = e.clientY - dragStart.current.y
|
||||
setPosition({
|
||||
x: positionStart.current.x + dx,
|
||||
y: positionStart.current.y + dy
|
||||
})
|
||||
}, [isDragging])
|
||||
|
||||
// 结束拖动
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
// 双击重置
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
setScale(1)
|
||||
setPosition({ x: 0, y: 0 })
|
||||
}, [])
|
||||
|
||||
// 点击背景关闭
|
||||
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === containerRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="image-preview-overlay"
|
||||
onClick={handleOverlayClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className="preview-content"
|
||||
style={{
|
||||
position: 'relative',
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
width: 'fit-content',
|
||||
height: 'fit-content'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(isVideo || showLive) ? (
|
||||
<video
|
||||
src={showLive ? liveVideoPath : src}
|
||||
controls={!showLive}
|
||||
autoPlay
|
||||
loop={showLive}
|
||||
className="preview-image"
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
maxHeight: '90vh',
|
||||
maxWidth: '90vw'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt="图片预览"
|
||||
className={`preview-image ${isDragging ? 'dragging' : ''}`}
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
maxHeight: '90vh',
|
||||
maxWidth: '90vw',
|
||||
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{liveVideoPath && !isVideo && (
|
||||
<button
|
||||
className={`live-photo-btn ${showLive ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowLive(!showLive)
|
||||
}}
|
||||
title={showLive ? "显示照片" : "播放实况"}
|
||||
>
|
||||
<LivePhotoIcon size={20} />
|
||||
<span>实况</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="image-preview-close" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
299
src/components/JumpToDateDialog.scss
Normal file
299
src/components/JumpToDateDialog.scss
Normal file
@@ -0,0 +1,299 @@
|
||||
.jump-date-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.jump-date-modal {
|
||||
background: var(--card-bg);
|
||||
width: 340px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.jump-date-header {
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.title-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-view {
|
||||
padding: 20px;
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.current-month {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
position: relative;
|
||||
|
||||
&.loading {
|
||||
|
||||
.weekdays,
|
||||
.days {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
|
||||
.spin {
|
||||
color: var(--primary);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
margin-bottom: 8px;
|
||||
|
||||
.weekday {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: repeat(6, 36px);
|
||||
gap: 4px;
|
||||
|
||||
.day-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&.empty {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:not(.empty):not(.no-message):hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.today:not(.selected) {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
// 无消息的日期 - 灰显且不可点击
|
||||
&.no-message {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 有消息的日期指示器小圆点
|
||||
.message-dot {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&.selected .message-dot {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 20px 16px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modalSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
208
src/components/JumpToDateDialog.tsx
Normal file
208
src/components/JumpToDateDialog.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
|
||||
import './JumpToDateDialog.scss'
|
||||
|
||||
interface JumpToDateDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSelect: (date: Date) => void
|
||||
currentDate?: Date
|
||||
/** 有消息的日期集合,格式为 YYYY-MM-DD */
|
||||
messageDates?: Set<string>
|
||||
/** 是否正在加载消息日期 */
|
||||
loadingDates?: boolean
|
||||
}
|
||||
|
||||
const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
currentDate = new Date(),
|
||||
messageDates,
|
||||
loadingDates = false
|
||||
}) => {
|
||||
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
||||
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
const getFirstDayOfMonth = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
return new Date(year, month, 1).getDay()
|
||||
}
|
||||
|
||||
const generateCalendar = () => {
|
||||
const daysInMonth = getDaysInMonth(calendarDate)
|
||||
const firstDay = getFirstDayOfMonth(calendarDate)
|
||||
const days: (number | null)[] = []
|
||||
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(null)
|
||||
}
|
||||
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i)
|
||||
}
|
||||
|
||||
return days
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某天是否有消息
|
||||
*/
|
||||
const hasMessage = (day: number): boolean => {
|
||||
if (!messageDates || messageDates.size === 0) return true // 未加载时默认全部可点击
|
||||
const year = calendarDate.getFullYear()
|
||||
const month = calendarDate.getMonth() + 1
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return messageDates.has(dateStr)
|
||||
}
|
||||
|
||||
const handleDateClick = (day: number) => {
|
||||
// 如果已加载日期数据且该日期无消息,则不可点击
|
||||
if (messageDates && messageDates.size > 0 && !hasMessage(day)) return
|
||||
const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||
setSelectedDate(newDate)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSelect(selectedDate)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const isToday = (day: number) => {
|
||||
const today = new Date()
|
||||
return day === today.getDate() &&
|
||||
calendarDate.getMonth() === today.getMonth() &&
|
||||
calendarDate.getFullYear() === today.getFullYear()
|
||||
}
|
||||
|
||||
const isSelected = (day: number) => {
|
||||
return day === selectedDate.getDate() &&
|
||||
calendarDate.getMonth() === selectedDate.getMonth() &&
|
||||
calendarDate.getFullYear() === selectedDate.getFullYear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某天的 CSS 类名
|
||||
*/
|
||||
const getDayClassName = (day: number | null): string => {
|
||||
if (day === null) return 'day-cell empty'
|
||||
|
||||
const classes = ['day-cell']
|
||||
if (isSelected(day)) classes.push('selected')
|
||||
if (isToday(day)) classes.push('today')
|
||||
|
||||
// 仅在已加载消息日期数据时区分有/无消息
|
||||
if (messageDates && messageDates.size > 0) {
|
||||
if (hasMessage(day)) {
|
||||
classes.push('has-message')
|
||||
} else {
|
||||
classes.push('no-message')
|
||||
}
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
|
||||
return (
|
||||
<div className="jump-date-overlay" onClick={onClose}>
|
||||
<div className="jump-date-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="jump-date-header">
|
||||
<div className="title-area">
|
||||
<CalendarIcon size={18} />
|
||||
<h3>跳转到日期</h3>
|
||||
</div>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="calendar-view">
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="current-month">
|
||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||
</span>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
||||
{loadingDates && (
|
||||
<div className="calendar-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>正在加载...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="weekdays" style={{ visibility: loadingDates ? 'hidden' : 'visible' }}>
|
||||
{weekdays.map(d => <div key={d} className="weekday">{d}</div>)}
|
||||
</div>
|
||||
<div className="days" style={{ visibility: loadingDates ? 'hidden' : 'visible' }}>
|
||||
{days.map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={getDayClassName(day)}
|
||||
style={{ visibility: loadingDates ? 'hidden' : 'visible' }}
|
||||
onClick={() => day !== null && handleDateClick(day)}
|
||||
>
|
||||
{day}
|
||||
{day !== null && messageDates && messageDates.size > 0 && hasMessage(day) && (
|
||||
<span className="message-dot" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-options">
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
}}>今天</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 7)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
}}>一周前</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
}}>一月前</button>
|
||||
</div>
|
||||
|
||||
<div className="dialog-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>取消</button>
|
||||
<button className="confirm-btn" onClick={handleConfirm}>跳转</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default JumpToDateDialog
|
||||
29
src/components/LivePhotoIcon.tsx
Normal file
29
src/components/LivePhotoIcon.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LivePhotoIconProps {
|
||||
size?: number | string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const LivePhotoIcon: React.FC<LivePhotoIconProps> = ({ size = 24, className = '', style = {} }) => {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round">
|
||||
<g stroke="currentColor" strokeWidth="2">
|
||||
<circle fill="currentColor" stroke="none" cx="12" cy="12" r="2.5"></circle>
|
||||
<circle cx="12" cy="12" r="5.5"></circle>
|
||||
<circle cx="12" cy="12" r="9" strokeDasharray="1 3.7"></circle>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
185
src/components/LockScreen.scss
Normal file
185
src/components/LockScreen.scss
Normal file
@@ -0,0 +1,185 @@
|
||||
.lock-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: var(--bg-primary);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
backdrop-filter: blur(25px) saturate(180%);
|
||||
background-color: var(--bg-primary);
|
||||
// 让背景带一点透明度以增强毛玻璃效果
|
||||
opacity: 1;
|
||||
|
||||
&.unlocked {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(0) saturate(100%);
|
||||
transform: scale(1.02);
|
||||
|
||||
.lock-content {
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lock-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 320px;
|
||||
-webkit-app-region: no-drag;
|
||||
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
.lock-avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border: 4px solid var(--bg-total);
|
||||
background-color: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.lock-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.lock-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.input-group {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
padding-right: 48px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-color-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hello-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lock-error {
|
||||
margin-top: 16px;
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
10%,
|
||||
30%,
|
||||
50%,
|
||||
70%,
|
||||
90% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
169
src/components/LockScreen.tsx
Normal file
169
src/components/LockScreen.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import * as configService from '../services/config'
|
||||
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
||||
import './LockScreen.scss'
|
||||
|
||||
interface LockScreenProps {
|
||||
onUnlock: () => void
|
||||
avatar?: string
|
||||
useHello?: boolean
|
||||
}
|
||||
|
||||
async function sha256(message: string) {
|
||||
const msgBuffer = new TextEncoder().encode(message)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
return hashHex
|
||||
}
|
||||
|
||||
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
const [isUnlocked, setIsUnlocked] = useState(false)
|
||||
const [showHello, setShowHello] = useState(false)
|
||||
const [helloAvailable, setHelloAvailable] = useState(false)
|
||||
|
||||
// 用于取消 WebAuthn 请求
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 快速检查配置并启动
|
||||
quickStartHello()
|
||||
inputRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
// 组件卸载时取消请求
|
||||
abortControllerRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleUnlock = () => {
|
||||
setIsUnlocked(true)
|
||||
setTimeout(() => {
|
||||
onUnlock()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const quickStartHello = async () => {
|
||||
try {
|
||||
// 如果父组件已经告诉我们要用 Hello,直接开始,不等待 IPC
|
||||
let shouldUseHello = useHello
|
||||
|
||||
// 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config
|
||||
if (!shouldUseHello) {
|
||||
shouldUseHello = await configService.getAuthUseHello()
|
||||
}
|
||||
|
||||
if (shouldUseHello) {
|
||||
// 标记为可用,显示按钮
|
||||
setHelloAvailable(true)
|
||||
setShowHello(true)
|
||||
// 立即执行验证 (0延迟)
|
||||
verifyHello()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Quick start hello failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
const verifyHello = async () => {
|
||||
if (isVerifying || isUnlocked) return
|
||||
|
||||
setIsVerifying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.auth.hello()
|
||||
|
||||
if (result.success) {
|
||||
handleUnlock()
|
||||
} else {
|
||||
console.error('Hello verification failed:', result.error)
|
||||
setError(result.error || '验证失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Hello verification error:', e)
|
||||
setError(`验证失败: ${e.message || String(e)}`)
|
||||
} finally {
|
||||
setIsVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!password || isUnlocked) return
|
||||
|
||||
// 如果正在进行 Hello 验证,它会自动失败或被取代,UI上不用特意取消
|
||||
// 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
|
||||
|
||||
// 不再检查 isVerifying,因为我们允许打断 Hello
|
||||
setIsVerifying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const storedHash = await configService.getAuthPassword()
|
||||
const inputHash = await sha256(password)
|
||||
|
||||
if (inputHash === storedHash) {
|
||||
handleUnlock()
|
||||
} else {
|
||||
setError('密码错误')
|
||||
setPassword('')
|
||||
setIsVerifying(false)
|
||||
// 如果密码错误,是否重新触发 Hello?
|
||||
// 用户可能想重试密码,暂时不自动触发
|
||||
}
|
||||
} catch (e) {
|
||||
setError('验证失败')
|
||||
setIsVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`lock-screen ${isUnlocked ? 'unlocked' : ''}`}>
|
||||
<div className="lock-content">
|
||||
<div className="lock-avatar">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="User" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
|
||||
) : (
|
||||
<Lock size={40} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="lock-title">WeFlow 已锁定</h2>
|
||||
|
||||
<form className="lock-form" onSubmit={handlePasswordSubmit}>
|
||||
<div className="input-group">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
placeholder="输入应用密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
// 移除 disabled,允许用户随时输入
|
||||
/>
|
||||
<button type="submit" className="submit-btn" disabled={!password}>
|
||||
<ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showHello && (
|
||||
<button
|
||||
type="button"
|
||||
className={`hello-btn ${isVerifying ? 'loading' : ''}`}
|
||||
onClick={verifyHello}
|
||||
>
|
||||
<Fingerprint size={20} />
|
||||
{isVerifying ? '验证中...' : '使用 Windows Hello 解锁'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{error && <div className="lock-error">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/components/MessageBubble.tsx
Normal file
36
src/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import { Bot, User } from 'lucide-react'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'ai';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化后的消息气泡组件
|
||||
* 使用 React.memo 避免不必要的重新渲染
|
||||
*/
|
||||
export const MessageBubble = React.memo<MessageBubbleProps>(({ message }) => {
|
||||
return (
|
||||
<div className={`message-row ${message.role}`}>
|
||||
<div className="avatar">
|
||||
{message.role === 'ai' ? <Bot size={24} /> : <User size={24} />}
|
||||
</div>
|
||||
<div className="bubble">
|
||||
<div className="content">{message.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数:只有内容或ID变化时才重新渲染
|
||||
return prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.id === nextProps.message.id
|
||||
})
|
||||
|
||||
MessageBubble.displayName = 'MessageBubble'
|
||||
200
src/components/NotificationToast.scss
Normal file
200
src/components/NotificationToast.scss
Normal file
@@ -0,0 +1,200 @@
|
||||
.notification-toast-container {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
width: 320px;
|
||||
background: var(--bg-secondary);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
pointer-events: none; // Allow clicking through when hidden
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&.static {
|
||||
position: relative !important;
|
||||
width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey
|
||||
height: auto !important; // Fits content
|
||||
min-height: 0;
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
transform: none !important;
|
||||
margin: 2px !important; // 2px centered margin
|
||||
border-radius: 12px !important; // Rounded corners
|
||||
|
||||
|
||||
// Disable backdrop filter
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
|
||||
// Ensure background is solid
|
||||
background: var(--bg-secondary, #2c2c2c);
|
||||
color: var(--text-primary, #ffffff);
|
||||
|
||||
box-shadow: none !important; // NO SHADOW
|
||||
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
||||
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
padding-right: 32px; // Make space for close button
|
||||
box-sizing: border-box;
|
||||
|
||||
// Force close button to be visible but transparent background
|
||||
.notification-close {
|
||||
opacity: 1 !important;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: transparent !important; // Transparent per user request
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect
|
||||
}
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
top: 24px; // Match padding
|
||||
right: 40px; // Left of close button (12px + 20px + 8px)
|
||||
}
|
||||
}
|
||||
|
||||
// Position variants
|
||||
&.bottom-right {
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
transform: translate(0, 20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.top-right {
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
transform: translate(0, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.bottom-left {
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
transform: translate(0, 20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.top-left {
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
transform: translate(0, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notification-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.notification-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%; // 允许缩放
|
||||
flex: 1; // 占据剩余空间
|
||||
min-width: 0; // 关键:允许 flex 子项收缩到内容以下
|
||||
margin-right: 60px; // Make space for absolute time + close button
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 36px; // Left of close button (8px + 20px + 8px)
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .notification-close {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
108
src/components/NotificationToast.tsx
Normal file
108
src/components/NotificationToast.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { Avatar } from './Avatar'
|
||||
import './NotificationToast.scss'
|
||||
|
||||
export interface NotificationData {
|
||||
id: string
|
||||
sessionId: string
|
||||
avatarUrl?: string
|
||||
title: string
|
||||
content: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface NotificationToastProps {
|
||||
data: NotificationData | null
|
||||
onClose: () => void
|
||||
onClick: (sessionId: string) => void
|
||||
duration?: number
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
isStatic?: boolean
|
||||
initialVisible?: boolean
|
||||
}
|
||||
|
||||
export function NotificationToast({
|
||||
data,
|
||||
onClose,
|
||||
onClick,
|
||||
duration = 5000,
|
||||
position = 'top-right',
|
||||
isStatic = false,
|
||||
initialVisible = false
|
||||
}: NotificationToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(initialVisible)
|
||||
const [currentData, setCurrentData] = useState<NotificationData | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setCurrentData(data)
|
||||
setIsVisible(true)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
// clean up data after animation
|
||||
setTimeout(onClose, 300)
|
||||
}, duration)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
setIsVisible(false)
|
||||
}
|
||||
}, [data, duration, onClose])
|
||||
|
||||
if (!currentData) return null
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setIsVisible(false)
|
||||
setTimeout(onClose, 300)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setIsVisible(false)
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
onClick(currentData.sessionId)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`notification-toast-container ${position} ${isVisible ? 'visible' : ''} ${isStatic ? 'static' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="notification-content">
|
||||
<div className="notification-avatar">
|
||||
<Avatar
|
||||
src={currentData.avatarUrl}
|
||||
name={currentData.title}
|
||||
size={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="notification-text">
|
||||
<div className="notification-header">
|
||||
<span className="notification-title">{currentData.title}</span>
|
||||
<span className="notification-time">
|
||||
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="notification-body">
|
||||
{currentData.content}
|
||||
</div>
|
||||
</div>
|
||||
<button className="notification-close" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isStatic) {
|
||||
return content
|
||||
}
|
||||
|
||||
// Portal to document.body to ensure it's on top
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
142
src/components/ReportComponents.scss
Normal file
142
src/components/ReportComponents.scss
Normal file
@@ -0,0 +1,142 @@
|
||||
// Shared styles for Report components (Heatmap, WordCloud)
|
||||
|
||||
// --- Heatmap ---
|
||||
.heatmap-wrapper {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 3px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--ar-text-sub); // Assumes --ar-text-sub is defined in parent context or globally
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.time-labels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.heatmap-week-col {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(7, 1fr);
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--ar-text-sub);
|
||||
}
|
||||
|
||||
.week-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.h-cell {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 2px;
|
||||
min-height: 10px;
|
||||
transition: transform 0.15s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.3);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Word Cloud ---
|
||||
.word-cloud-wrapper {
|
||||
margin: 24px auto 0;
|
||||
padding: 0;
|
||||
max-width: 520px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
--cloud-scale: clamp(0.72, 80vw / 520, 1);
|
||||
}
|
||||
|
||||
.word-cloud-inner {
|
||||
position: relative;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
transform: scale(var(--cloud-scale));
|
||||
transform-origin: center;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -6%;
|
||||
background:
|
||||
radial-gradient(circle at 35% 45%, rgba(var(--ar-primary-rgb, 7, 193, 96), 0.12), transparent 55%),
|
||||
radial-gradient(circle at 65% 50%, rgba(var(--ar-accent-rgb, 242, 170, 0), 0.10), transparent 58%),
|
||||
radial-gradient(circle at 50% 65%, var(--bg-tertiary, rgba(0, 0, 0, 0.04)), transparent 60%);
|
||||
filter: blur(18px);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.word-tag {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
transition: transform 0.2s ease, color 0.2s ease;
|
||||
cursor: default;
|
||||
color: var(--ar-text-main);
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
animation: wordPopIn 0.55s ease forwards;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
|
||||
&:hover {
|
||||
transform: translate(-50%, -50%) scale(1.08);
|
||||
color: var(--ar-primary);
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wordPopIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: var(--final-opacity, 1);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.word-cloud-note {
|
||||
margin-top: 24px;
|
||||
font-size: 14px !important;
|
||||
color: var(--ar-text-sub) !important;
|
||||
text-align: center;
|
||||
}
|
||||
51
src/components/ReportHeatmap.tsx
Normal file
51
src/components/ReportHeatmap.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
import './ReportComponents.scss'
|
||||
|
||||
interface ReportHeatmapProps {
|
||||
data: number[][]
|
||||
}
|
||||
|
||||
const ReportHeatmap: React.FC<ReportHeatmapProps> = ({ data }) => {
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const maxHeat = Math.max(...data.flat())
|
||||
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
|
||||
return (
|
||||
<div className="heatmap-wrapper">
|
||||
<div className="heatmap-header">
|
||||
<div></div>
|
||||
<div className="time-labels">
|
||||
{[0, 6, 12, 18].map(h => (
|
||||
<span key={h} style={{ gridColumn: h + 1 }}>{h}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="heatmap">
|
||||
<div className="heatmap-week-col">
|
||||
{weekLabels.map(w => <div key={w} className="week-label">{w}</div>)}
|
||||
</div>
|
||||
<div className="heatmap-grid">
|
||||
{data.map((row, wi) =>
|
||||
row.map((val, hi) => {
|
||||
const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1'
|
||||
return (
|
||||
<div
|
||||
key={`${wi}-${hi}`}
|
||||
className="h-cell"
|
||||
style={{
|
||||
backgroundColor: 'var(--primary)',
|
||||
opacity: alpha
|
||||
}}
|
||||
title={`${weekLabels[wi]} ${hi}:00 - ${val}条`}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReportHeatmap
|
||||
113
src/components/ReportWordCloud.tsx
Normal file
113
src/components/ReportWordCloud.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from 'react'
|
||||
import './ReportComponents.scss'
|
||||
|
||||
interface ReportWordCloudProps {
|
||||
words: { phrase: string; count: number }[]
|
||||
}
|
||||
|
||||
const ReportWordCloud: React.FC<ReportWordCloudProps> = ({ words }) => {
|
||||
if (!words || words.length === 0) return null
|
||||
|
||||
const maxCount = words.length > 0 ? words[0].count : 1
|
||||
const topWords = words.slice(0, 32)
|
||||
const baseSize = 520
|
||||
|
||||
// 使用确定性随机数生成器
|
||||
const seededRandom = (seed: number) => {
|
||||
const x = Math.sin(seed) * 10000
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
// 计算词云位置
|
||||
const placedItems: { x: number; y: number; w: number; h: number }[] = []
|
||||
|
||||
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
|
||||
const halfW = w / 2
|
||||
const halfH = h / 2
|
||||
const dx = x - 50
|
||||
const dy = y - 50
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const maxR = 49 - Math.max(halfW, halfH)
|
||||
if (dist > maxR) return false
|
||||
|
||||
const pad = 1.8
|
||||
for (const p of placedItems) {
|
||||
if ((x - halfW - pad) < (p.x + p.w / 2) &&
|
||||
(x + halfW + pad) > (p.x - p.w / 2) &&
|
||||
(y - halfH - pad) < (p.y + p.h / 2) &&
|
||||
(y + halfH + pad) > (p.y - p.h / 2)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const wordItems = topWords.map((item, i) => {
|
||||
const ratio = item.count / maxCount
|
||||
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
|
||||
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
|
||||
const delay = (i * 0.04).toFixed(2)
|
||||
|
||||
// 计算词语宽度
|
||||
const charCount = Math.max(1, item.phrase.length)
|
||||
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
|
||||
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
|
||||
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
|
||||
const widthPx = fontSize * (charCount * widthFactor)
|
||||
const heightPx = fontSize * 1.1
|
||||
const widthPct = (widthPx / baseSize) * 100
|
||||
const heightPct = (heightPx / baseSize) * 100
|
||||
|
||||
// 寻找位置
|
||||
let x = 50, y = 50
|
||||
let placedOk = false
|
||||
const tries = i === 0 ? 1 : 420
|
||||
|
||||
for (let t = 0; t < tries; t++) {
|
||||
if (i === 0) {
|
||||
x = 50
|
||||
y = 50
|
||||
} else {
|
||||
const idx = i + t * 0.28
|
||||
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
|
||||
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
|
||||
x = 50 + radius * Math.cos(angle)
|
||||
y = 50 + radius * Math.sin(angle)
|
||||
}
|
||||
if (canPlace(x, y, widthPct, heightPct)) {
|
||||
placedOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!placedOk) return null
|
||||
placedItems.push({ x, y, w: widthPct, h: heightPct })
|
||||
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="word-tag"
|
||||
style={{
|
||||
'--final-opacity': opacity,
|
||||
left: `${x.toFixed(2)}%`,
|
||||
top: `${y.toFixed(2)}%`,
|
||||
fontSize: `${fontSize}px`,
|
||||
animationDelay: `${delay}s`,
|
||||
} as React.CSSProperties}
|
||||
title={`${item.phrase} (出现 ${item.count} 次)`}
|
||||
>
|
||||
{item.phrase}
|
||||
</span>
|
||||
)
|
||||
}).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="word-cloud-wrapper">
|
||||
<div className="word-cloud-inner">
|
||||
{wordItems}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReportWordCloud
|
||||
@@ -6,8 +6,7 @@ interface RouteGuardProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// 不需要数据库连接的页面
|
||||
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/data-management']
|
||||
const PUBLIC_ROUTES = ['/', '/home', '/settings']
|
||||
|
||||
function RouteGuard({ children }: RouteGuardProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
width: 220px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
@@ -32,14 +32,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 9999px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
@@ -49,7 +49,6 @@
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
@@ -77,7 +76,7 @@
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0 8px;
|
||||
padding: 0 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import * as configService from '../services/config'
|
||||
import './Sidebar.scss'
|
||||
|
||||
function Sidebar() {
|
||||
const location = useLocation()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
const setLocked = useAppStore(state => state.setLocked)
|
||||
|
||||
useEffect(() => {
|
||||
configService.getAuthEnabled().then(setAuthEnabled)
|
||||
}, [])
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
@@ -34,6 +42,26 @@ function Sidebar() {
|
||||
<span className="nav-label">聊天</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 朋友圈 */}
|
||||
<NavLink
|
||||
to="/sns"
|
||||
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
||||
title={collapsed ? '朋友圈' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Aperture size={20} /></span>
|
||||
<span className="nav-label">朋友圈</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 通讯录 */}
|
||||
<NavLink
|
||||
to="/contacts"
|
||||
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
|
||||
title={collapsed ? '通讯录' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><UserCircle size={20} /></span>
|
||||
<span className="nav-label">通讯录</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 私聊分析 */}
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
@@ -74,18 +102,21 @@ function Sidebar() {
|
||||
<span className="nav-label">导出</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 数据管理 */}
|
||||
<NavLink
|
||||
to="/data-management"
|
||||
className={`nav-item ${isActive('/data-management') ? 'active' : ''}`}
|
||||
title={collapsed ? '数据管理' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Database size={20} /></span>
|
||||
<span className="nav-label">数据管理</span>
|
||||
</NavLink>
|
||||
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{authEnabled && (
|
||||
<button
|
||||
className="nav-item"
|
||||
onClick={() => setLocked(true)}
|
||||
title={collapsed ? '锁定' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Lock size={20} /></span>
|
||||
<span className="nav-label">锁定</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
||||
|
||||
185
src/components/Sns/SnsFilterPanel.tsx
Normal file
185
src/components/Sns/SnsFilterPanel.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
|
||||
import { Avatar } from '../Avatar'
|
||||
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
|
||||
|
||||
interface Contact {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
interface SnsFilterPanelProps {
|
||||
searchKeyword: string
|
||||
setSearchKeyword: (val: string) => void
|
||||
jumpTargetDate?: Date
|
||||
setJumpTargetDate: (date?: Date) => void
|
||||
onOpenJumpDialog: () => void
|
||||
selectedUsernames: string[]
|
||||
setSelectedUsernames: (val: string[]) => void
|
||||
contacts: Contact[]
|
||||
contactSearch: string
|
||||
setContactSearch: (val: string) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
searchKeyword,
|
||||
setSearchKeyword,
|
||||
jumpTargetDate,
|
||||
setJumpTargetDate,
|
||||
onOpenJumpDialog,
|
||||
selectedUsernames,
|
||||
setSelectedUsernames,
|
||||
contacts,
|
||||
contactSearch,
|
||||
setContactSearch,
|
||||
loading
|
||||
}) => {
|
||||
|
||||
const filteredContacts = contacts.filter(c =>
|
||||
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||
)
|
||||
|
||||
const toggleUserSelection = (username: string) => {
|
||||
if (selectedUsernames.includes(username)) {
|
||||
setSelectedUsernames(selectedUsernames.filter(u => u !== username))
|
||||
} else {
|
||||
setJumpTargetDate(undefined) // Reset date jump when selecting user
|
||||
setSelectedUsernames([...selectedUsernames, username])
|
||||
}
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchKeyword('')
|
||||
setSelectedUsernames([])
|
||||
setJumpTargetDate(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sns-filter-panel">
|
||||
<div className="filter-header">
|
||||
<h3>筛选条件</h3>
|
||||
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && (
|
||||
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="filter-widgets">
|
||||
{/* Search Widget */}
|
||||
<div className="filter-widget search-widget">
|
||||
<div className="widget-header">
|
||||
<Search size={14} />
|
||||
<span>关键词搜索</span>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索动态内容..."
|
||||
value={searchKeyword}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
{searchKeyword && (
|
||||
<button className="clear-input-btn" onClick={() => setSearchKeyword('')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Widget */}
|
||||
<div className="filter-widget date-widget">
|
||||
<div className="widget-header">
|
||||
<Calendar size={14} />
|
||||
<span>时间跳转</span>
|
||||
</div>
|
||||
<button
|
||||
className={`date-picker-trigger ${jumpTargetDate ? 'active' : ''}`}
|
||||
onClick={onOpenJumpDialog}
|
||||
>
|
||||
<span className="date-text">
|
||||
{jumpTargetDate
|
||||
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: '选择日期...'}
|
||||
</span>
|
||||
{jumpTargetDate && (
|
||||
<div
|
||||
className="clear-date-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setJumpTargetDate(undefined)
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Contact Widget */}
|
||||
<div className="filter-widget contact-widget">
|
||||
<div className="widget-header">
|
||||
<User size={14} />
|
||||
<span>联系人</span>
|
||||
{selectedUsernames.length > 0 && (
|
||||
<span className="badge">{selectedUsernames.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="contact-search-bar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="查找好友..."
|
||||
value={contactSearch}
|
||||
onChange={e => setContactSearch(e.target.value)}
|
||||
/>
|
||||
<Search size={14} className="search-icon" />
|
||||
{contactSearch && (
|
||||
<X size={14} className="clear-icon" onClick={() => setContactSearch('')} style={{ right: 8, top: 8, position: 'absolute', cursor: 'pointer', color: 'var(--text-tertiary)' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="contact-list-scroll">
|
||||
{filteredContacts.map(contact => (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
|
||||
onClick={() => toggleUserSelection(contact.username)}
|
||||
>
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
</div>
|
||||
))}
|
||||
{filteredContacts.length === 0 && (
|
||||
<div className="empty-state">没有找到联系人</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function RefreshCw({ size, className }: { size?: number, className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M23 4v6h-6"></path>
|
||||
<path d="M1 20v-6h6"></path>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
327
src/components/Sns/SnsMediaGrid.tsx
Normal file
327
src/components/Sns/SnsMediaGrid.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Play, Lock, Download } from 'lucide-react'
|
||||
import { LivePhotoIcon } from '../../components/LivePhotoIcon'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
interface SnsMedia {
|
||||
url: string
|
||||
thumb: string
|
||||
md5?: string
|
||||
token?: string
|
||||
key?: string
|
||||
encIdx?: string
|
||||
livePhoto?: {
|
||||
url: string
|
||||
thumb: string
|
||||
token?: string
|
||||
key?: string
|
||||
encIdx?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SnsMediaGridProps {
|
||||
mediaList: SnsMedia[]
|
||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||
}
|
||||
|
||||
const isSnsVideoUrl = (url?: string): boolean => {
|
||||
if (!url) return false
|
||||
const lower = url.toLowerCase()
|
||||
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
|
||||
}
|
||||
|
||||
const extractVideoFrame = async (videoPath: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'auto'
|
||||
video.src = videoPath
|
||||
video.muted = true
|
||||
video.currentTime = 0 // Initial reset
|
||||
// video.crossOrigin = 'anonymous' // Not needed for file:// usually
|
||||
|
||||
const onSeeked = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.8)
|
||||
resolve(dataUrl)
|
||||
} else {
|
||||
reject(new Error('Canvas context failed'))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
} finally {
|
||||
// Cleanup
|
||||
video.removeEventListener('seeked', onSeeked)
|
||||
video.src = ''
|
||||
video.load()
|
||||
}
|
||||
}
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
if (video.duration === Infinity || isNaN(video.duration)) {
|
||||
// Determine duration failed, try a fixed small offset
|
||||
video.currentTime = 1
|
||||
} else {
|
||||
video.currentTime = Math.max(0.1, video.duration / 2)
|
||||
}
|
||||
}
|
||||
|
||||
video.onseeked = onSeeked
|
||||
|
||||
video.onerror = (e) => {
|
||||
reject(new Error('Video load failed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => {
|
||||
const [error, setError] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [thumbSrc, setThumbSrc] = useState<string>('')
|
||||
const [videoPath, setVideoPath] = useState<string>('')
|
||||
const [liveVideoPath, setLiveVideoPath] = useState<string>('')
|
||||
const [isDecrypting, setIsDecrypting] = useState(false)
|
||||
const [isGeneratingCover, setIsGeneratingCover] = useState(false)
|
||||
|
||||
const isVideo = isSnsVideoUrl(media.url)
|
||||
const isLive = !!media.livePhoto
|
||||
const targetUrl = media.thumb || media.url
|
||||
|
||||
// Simple effect to load image/decrypt
|
||||
// Simple effect to load image/decrypt
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
if (!isVideo) {
|
||||
// For images, we proxy to get the local path/base64
|
||||
const result = await window.electronAPI.sns.proxyImage({
|
||||
url: targetUrl,
|
||||
key: media.key
|
||||
})
|
||||
if (cancelled) return
|
||||
|
||||
if (result.success) {
|
||||
if (result.dataUrl) setThumbSrc(result.dataUrl)
|
||||
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
|
||||
} else {
|
||||
setThumbSrc(targetUrl)
|
||||
}
|
||||
|
||||
// Pre-load live photo video if needed
|
||||
if (isLive && media.livePhoto?.url) {
|
||||
window.electronAPI.sns.proxyImage({
|
||||
url: media.livePhoto.url,
|
||||
key: media.livePhoto.key || media.key
|
||||
}).then((res: any) => {
|
||||
if (!cancelled && res.success && res.videoPath) {
|
||||
setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`)
|
||||
}
|
||||
}).catch(() => { })
|
||||
}
|
||||
setLoading(false)
|
||||
} else {
|
||||
// Video logic: Decrypt -> Extract Frame
|
||||
setIsGeneratingCover(true)
|
||||
|
||||
// First check if we already have it decryptable?
|
||||
// Usually we need to call proxyImage with the video URL to decrypt it to cache
|
||||
const result = await window.electronAPI.sns.proxyImage({
|
||||
url: media.url,
|
||||
key: media.key
|
||||
})
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (result.success && result.videoPath) {
|
||||
const localPath = `file://${result.videoPath.replace(/\\/g, '/')}`
|
||||
setVideoPath(localPath)
|
||||
|
||||
try {
|
||||
const coverDataUrl = await extractVideoFrame(localPath)
|
||||
if (!cancelled) setThumbSrc(coverDataUrl)
|
||||
} catch (err) {
|
||||
console.error('Frame extraction failed', err)
|
||||
// Fallback to video path if extraction fails, though it might be black
|
||||
// Only set thumbSrc if extraction fails, so we don't override the generated one
|
||||
}
|
||||
} else {
|
||||
console.error('Video decryption for cover failed')
|
||||
}
|
||||
|
||||
setIsGeneratingCover(false)
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
if (!cancelled) {
|
||||
setThumbSrc(targetUrl)
|
||||
setLoading(false)
|
||||
setIsGeneratingCover(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [media, isVideo, isLive, targetUrl])
|
||||
|
||||
const handlePreview = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isVideo) {
|
||||
// Decrypt video on demand if not already
|
||||
if (!videoPath) {
|
||||
setIsDecrypting(true)
|
||||
try {
|
||||
const res = await window.electronAPI.sns.proxyImage({
|
||||
url: media.url,
|
||||
key: media.key
|
||||
})
|
||||
if (res.success && res.videoPath) {
|
||||
const local = `file://${res.videoPath.replace(/\\/g, '/')}`
|
||||
setVideoPath(local)
|
||||
onPreview(local, true, undefined)
|
||||
} else {
|
||||
alert('视频解密失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setIsDecrypting(false)
|
||||
}
|
||||
} else {
|
||||
onPreview(videoPath, true, undefined)
|
||||
}
|
||||
} else {
|
||||
onPreview(thumbSrc || targetUrl, false, liveVideoPath)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.sns.proxyImage({
|
||||
url: media.url,
|
||||
key: media.key
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
const link = document.createElement('a')
|
||||
link.download = `sns_media_${Date.now()}.${isVideo ? 'mp4' : 'jpg'}`
|
||||
|
||||
if (result.dataUrl) {
|
||||
link.href = result.dataUrl
|
||||
} else if (result.videoPath) {
|
||||
// For local video files, we need to fetch as blob to force download behavior
|
||||
// or just use the file protocol url if the browser supports it
|
||||
try {
|
||||
const response = await fetch(`file://${result.videoPath}`)
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.href = url
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||
} catch (err) {
|
||||
console.error('Video fetch failed, falling back to direct link', err)
|
||||
link.href = `file://${result.videoPath}`
|
||||
}
|
||||
}
|
||||
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} else {
|
||||
alert('下载失败: 无法获取资源')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Download error:', e)
|
||||
alert('下载出错')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
{(thumbSrc && !thumbSrc.startsWith('data:') && (thumbSrc.toLowerCase().endsWith('.mp4') || thumbSrc.includes('video'))) ? (
|
||||
<video
|
||||
key={thumbSrc}
|
||||
src={`${thumbSrc}#t=0.1`}
|
||||
className="media-image"
|
||||
preload="auto"
|
||||
muted
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
onLoadedMetadata={(e) => {
|
||||
e.currentTarget.currentTime = 0.1
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={thumbSrc || targetUrl}
|
||||
className="media-image"
|
||||
loading="lazy"
|
||||
onError={() => setError(true)}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGeneratingCover && (
|
||||
<div className="media-decrypting-mask">
|
||||
<RefreshCw className="spin" size={24} />
|
||||
<span>解密中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideo && (
|
||||
<div className="media-badge video">
|
||||
{/* If we have a cover, show Play. If decrypting for preview, show spin. Generating cover has its own mask. */}
|
||||
{isDecrypting ? <RefreshCw className="spin" size={16} /> : <Play size={16} fill="currentColor" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLive && !isVideo && (
|
||||
<div className="media-badge live">
|
||||
<LivePhotoIcon size={16} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="media-download-btn" onClick={handleDownload} title="下载">
|
||||
<Download size={16} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview }) => {
|
||||
if (!mediaList || mediaList.length === 0) return null
|
||||
|
||||
const count = mediaList.length
|
||||
let gridClass = ''
|
||||
|
||||
if (count === 1) gridClass = 'grid-1'
|
||||
else if (count === 2) gridClass = 'grid-2'
|
||||
else if (count === 3) gridClass = 'grid-3'
|
||||
else if (count === 4) gridClass = 'grid-4' // 2x2
|
||||
else if (count <= 6) gridClass = 'grid-6' // 3 cols
|
||||
else gridClass = 'grid-9' // 3x3
|
||||
|
||||
return (
|
||||
<div className={`sns-media-grid ${gridClass}`}>
|
||||
{mediaList.map((media, idx) => (
|
||||
<MediaItem key={idx} media={media} onPreview={onPreview} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
263
src/components/Sns/SnsPostItem.tsx
Normal file
263
src/components/Sns/SnsPostItem.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal } from 'lucide-react'
|
||||
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
||||
import { Avatar } from '../Avatar'
|
||||
import { SnsMediaGrid } from './SnsMediaGrid'
|
||||
|
||||
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
||||
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
||||
const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle']
|
||||
const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload']
|
||||
|
||||
const isSnsVideoUrl = (url?: string): boolean => {
|
||||
if (!url) return false
|
||||
const lower = url.toLowerCase()
|
||||
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
|
||||
}
|
||||
|
||||
const decodeHtmlEntities = (text: string): string => {
|
||||
if (!text) return ''
|
||||
return text
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.trim()
|
||||
}
|
||||
|
||||
const normalizeUrlCandidate = (raw: string): string | null => {
|
||||
const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim()
|
||||
if (!value) return null
|
||||
if (!/^https?:\/\//i.test(value)) return null
|
||||
return value
|
||||
}
|
||||
|
||||
const simplifyUrlForCompare = (value: string): string => {
|
||||
const normalized = value.trim().toLowerCase().replace(/^https?:\/\//, '')
|
||||
const [withoutQuery] = normalized.split('?')
|
||||
return withoutQuery.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const getXmlTagValues = (xml: string, tags: string[]): string[] => {
|
||||
if (!xml) return []
|
||||
const results: string[] = []
|
||||
for (const tag of tags) {
|
||||
const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig')
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = reg.exec(xml)) !== null) {
|
||||
if (match[1]) results.push(match[1])
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
const getUrlLikeStrings = (text: string): string[] => {
|
||||
if (!text) return []
|
||||
return text.match(/https?:\/\/[^\s<>"']+/gi) || []
|
||||
}
|
||||
|
||||
const isLikelyMediaAssetUrl = (url: string): boolean => {
|
||||
const lower = url.toLowerCase()
|
||||
return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint))
|
||||
}
|
||||
|
||||
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||
if (hasVideoMedia) return null
|
||||
|
||||
const mediaValues = post.media
|
||||
.flatMap((item) => [item.url, item.thumb])
|
||||
.filter((value): value is string => Boolean(value))
|
||||
const mediaSet = new Set(mediaValues.map((value) => simplifyUrlForCompare(value)))
|
||||
|
||||
const urlCandidates: string[] = [
|
||||
post.linkUrl || '',
|
||||
...getXmlTagValues(post.rawXml || '', LINK_XML_URL_TAGS),
|
||||
...getUrlLikeStrings(post.rawXml || ''),
|
||||
...getUrlLikeStrings(post.contentDesc || '')
|
||||
]
|
||||
|
||||
const normalizedCandidates = urlCandidates
|
||||
.map(normalizeUrlCandidate)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
|
||||
const dedupedCandidates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const candidate of normalizedCandidates) {
|
||||
if (seen.has(candidate)) continue
|
||||
seen.add(candidate)
|
||||
dedupedCandidates.push(candidate)
|
||||
}
|
||||
|
||||
const linkUrl = dedupedCandidates.find((candidate) => {
|
||||
const simplified = simplifyUrlForCompare(candidate)
|
||||
if (mediaSet.has(simplified)) return false
|
||||
if (isLikelyMediaAssetUrl(candidate)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (!linkUrl) return null
|
||||
|
||||
const titleCandidates = [
|
||||
post.linkTitle || '',
|
||||
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||
post.contentDesc || ''
|
||||
]
|
||||
|
||||
const title = titleCandidates
|
||||
.map((value) => decodeHtmlEntities(value))
|
||||
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value))
|
||||
|
||||
return {
|
||||
url: linkUrl,
|
||||
title: title || '网页链接',
|
||||
thumb: post.media[0]?.thumb || post.media[0]?.url
|
||||
}
|
||||
}
|
||||
|
||||
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
||||
const [thumbFailed, setThumbFailed] = useState(false)
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(card.url).hostname.replace(/^www\./i, '')
|
||||
} catch {
|
||||
return card.url
|
||||
}
|
||||
}, [card.url])
|
||||
|
||||
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await window.electronAPI.shell.openExternal(card.url)
|
||||
} catch (error) {
|
||||
console.error('[SnsLinkCard] openExternal failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="post-link-card" onClick={handleClick}>
|
||||
<div className="link-thumb">
|
||||
{card.thumb && !thumbFailed ? (
|
||||
<img
|
||||
src={card.thumb}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
onError={() => setThumbFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="link-thumb-fallback">
|
||||
<ImageIcon size={18} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="link-meta">
|
||||
<div className="link-title">{card.title}</div>
|
||||
<div className="link-url">{hostname}</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="link-arrow" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface SnsPostItemProps {
|
||||
post: SnsPost
|
||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||
onDebug: (post: SnsPost) => void
|
||||
}
|
||||
|
||||
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
|
||||
const linkCard = buildLinkCardData(post)
|
||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
||||
const showMediaGrid = post.media.length > 0 && !showLinkCard
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
const date = new Date(ts * 1000)
|
||||
const isCurrentYear = date.getFullYear() === new Date().getFullYear()
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: isCurrentYear ? undefined : 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Add extra class for media-only posts (no text) to adjust spacing?
|
||||
// Not strictly needed but good to know
|
||||
|
||||
return (
|
||||
<div className="sns-post-item">
|
||||
<div className="post-avatar-col">
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
name={post.nickname}
|
||||
size={48}
|
||||
shape="rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="post-content-col">
|
||||
<div className="post-header-row">
|
||||
<div className="post-author-info">
|
||||
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
||||
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||
</div>
|
||||
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDebug(post);
|
||||
}} title="查看原始数据">
|
||||
<Code size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{post.contentDesc && (
|
||||
<div className="post-text">{decodeHtmlEntities(post.contentDesc)}</div>
|
||||
)}
|
||||
|
||||
{showLinkCard && linkCard && (
|
||||
<SnsLinkCard card={linkCard} />
|
||||
)}
|
||||
|
||||
{showMediaGrid && (
|
||||
<div className="post-media-container">
|
||||
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(post.likes.length > 0 || post.comments.length > 0) && (
|
||||
<div className="post-interactions">
|
||||
{post.likes.length > 0 && (
|
||||
<div className="likes-block">
|
||||
<Heart size={14} className="like-icon" />
|
||||
<span className="likes-text">{post.likes.join('、')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post.comments.length > 0 && (
|
||||
<div className="comments-block">
|
||||
{post.comments.map((c, idx) => (
|
||||
<div key={idx} className="comment-row">
|
||||
<span className="comment-user">{c.nickname}</span>
|
||||
{c.refNickname && (
|
||||
<>
|
||||
<span className="reply-text">回复</span>
|
||||
<span className="comment-user">{c.refNickname}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="comment-colon">:</span>
|
||||
<span className="comment-content">{c.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import './TitleBar.scss'
|
||||
|
||||
function TitleBar() {
|
||||
interface TitleBarProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
function TitleBar({ title }: TitleBarProps = {}) {
|
||||
return (
|
||||
<div className="title-bar">
|
||||
<img src="./logo.png" alt="WeFlow" className="title-logo" />
|
||||
<span className="titles">WeFlow</span>
|
||||
<span className="titles">{title || 'WeFlow'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
274
src/components/UpdateDialog.scss
Normal file
274
src/components/UpdateDialog.scss
Normal file
@@ -0,0 +1,274 @@
|
||||
.update-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
|
||||
.update-dialog {
|
||||
width: 680px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Top Section (White/Gradient) */
|
||||
.dialog-header {
|
||||
background: #ffffff;
|
||||
padding: 40px 20px 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
|
||||
/* Subtle radial gradient effect in top left as seen in image */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%);
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
background: #f0eee9;
|
||||
color: #8c7b6e;
|
||||
padding: 4px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #333333;
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 15px;
|
||||
color: #999999;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
/* Content Section (Light Gray) */
|
||||
.dialog-content {
|
||||
background: #f2f2f2;
|
||||
padding: 24px 40px 40px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.update-notes-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.icon-box {
|
||||
background: #fbfbfb; // Beige-ish white
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
color: #8c7b6e;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
|
||||
|
||||
svg {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.text-box {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 8px 0 0 18px;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: 30px;
|
||||
|
||||
.progress-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
height: 6px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: #000000;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
.btn-ignore {
|
||||
background: transparent;
|
||||
color: #666666;
|
||||
border: 1px solid #d0d0d0;
|
||||
padding: 16px 32px;
|
||||
border-radius: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #999999;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-update {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 16px 48px;
|
||||
border-radius: 20px; // Pill shape
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: #333;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
139
src/components/UpdateDialog.tsx
Normal file
139
src/components/UpdateDialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Quote, X } from 'lucide-react'
|
||||
import './UpdateDialog.scss'
|
||||
|
||||
interface UpdateInfo {
|
||||
version?: string
|
||||
releaseNotes?: string
|
||||
}
|
||||
|
||||
interface UpdateDialogProps {
|
||||
open: boolean
|
||||
updateInfo: UpdateInfo | null
|
||||
onClose: () => void
|
||||
onUpdate: () => void
|
||||
onIgnore?: () => void
|
||||
isDownloading: boolean
|
||||
progress: number | {
|
||||
percent: number
|
||||
bytesPerSecond?: number
|
||||
transferred?: number
|
||||
total?: number
|
||||
remaining?: number // seconds
|
||||
}
|
||||
}
|
||||
|
||||
const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
||||
open,
|
||||
updateInfo,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onIgnore,
|
||||
isDownloading,
|
||||
progress
|
||||
}) => {
|
||||
if (!open || !updateInfo) return null
|
||||
|
||||
// Safe normalize progress
|
||||
const safeProgress = typeof progress === 'number' ? { percent: progress } : (progress || { percent: 0 })
|
||||
const percent = safeProgress.percent || 0
|
||||
const bytesPerSecond = safeProgress.bytesPerSecond
|
||||
const total = safeProgress.total
|
||||
const transferred = safeProgress.transferred
|
||||
const remaining = safeProgress.remaining
|
||||
|
||||
// Format bytes
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (!Number.isFinite(bytes) || bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
const unitIndex = Math.max(0, Math.min(i, sizes.length - 1))
|
||||
return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex]
|
||||
}
|
||||
|
||||
// Format speed
|
||||
const formatSpeed = (bytesPerSecond: number) => {
|
||||
return `${formatBytes(bytesPerSecond)}/s`
|
||||
}
|
||||
|
||||
// Format time
|
||||
const formatTime = (seconds: number) => {
|
||||
if (!Number.isFinite(seconds)) return '计算中...'
|
||||
if (seconds < 60) return `${Math.ceil(seconds)} 秒`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.ceil(seconds % 60)
|
||||
return `${minutes} 分 ${remainingSeconds} 秒`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="update-dialog-overlay">
|
||||
<div className="update-dialog">
|
||||
{!isDownloading && (
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="dialog-header">
|
||||
<div className="version-tag">
|
||||
新版本 {updateInfo.version}
|
||||
</div>
|
||||
<h2>欢迎体验全新的 WeFlow</h2>
|
||||
<div className="subtitle">我们带来了一些改进</div>
|
||||
</div>
|
||||
|
||||
<div className="dialog-content">
|
||||
<div className="update-notes-container">
|
||||
<div className="icon-box">
|
||||
<Quote size={20} />
|
||||
</div>
|
||||
<div className="text-box">
|
||||
<h3>优化</h3>
|
||||
{updateInfo.releaseNotes ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
|
||||
) : (
|
||||
<p>修复了一些已知问题,提升了稳定性。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDownloading ? (
|
||||
<div className="progress-section">
|
||||
<div className="progress-info-row">
|
||||
<span>{bytesPerSecond ? formatSpeed(bytesPerSecond) : '下载中...'}</span>
|
||||
<span>{total ? `${formatBytes(transferred || 0)} / ${formatBytes(total)}` : `${percent.toFixed(1)}%`}</span>
|
||||
{remaining !== undefined && <span>剩余 {formatTime(remaining)}</span>}
|
||||
</div>
|
||||
|
||||
<div className="progress-bar-bg">
|
||||
<div
|
||||
className="progress-bar-fill"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fallback status text if detailed info is missing */}
|
||||
{(!bytesPerSecond && !total) && (
|
||||
<div className="status-text">{percent.toFixed(0)}% 已下载</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="actions">
|
||||
{onIgnore && (
|
||||
<button className="btn-ignore" onClick={onIgnore}>
|
||||
忽略本次更新
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-update" onClick={onUpdate}>
|
||||
开启新旅程
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdateDialog
|
||||
192
src/components/UpdateProgressCapsule.scss
Normal file
192
src/components/UpdateProgressCapsule.scss
Normal file
@@ -0,0 +1,192 @@
|
||||
.update-progress-capsule {
|
||||
position: fixed;
|
||||
top: 38px; // Just below title bar
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9998;
|
||||
cursor: pointer;
|
||||
animation: capsuleSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
.capsule-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.capsule-content {
|
||||
background: var(--bg-primary);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
padding: 8px 18px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
|
||||
.download-icon {
|
||||
animation: capsulePulse 2s infinite ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.info-wrapper {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
z-index: 1;
|
||||
|
||||
.percent-text {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.speed-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 15px;
|
||||
color: #ff4d4f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.available-text {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bg {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.capsule-close {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
margin-left: -4px;
|
||||
margin-right: -8px;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// State Modifiers
|
||||
&.state-available {
|
||||
.capsule-content {
|
||||
background: var(--primary);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
|
||||
.icon-wrapper {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.info-wrapper {
|
||||
.available-text {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.capsule-close {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.state-downloading {
|
||||
.capsule-content {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.state-error {
|
||||
.capsule-content {
|
||||
background: #fff1f0;
|
||||
border-color: #ffa39e;
|
||||
|
||||
.icon-wrapper {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.info-wrapper .error-text {
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.capsule-close {
|
||||
color: #cf1322;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes capsuleSlideDown {
|
||||
from {
|
||||
transform: translate(-50%, -40px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(-50%, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes capsulePulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(2px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
118
src/components/UpdateProgressCapsule.tsx
Normal file
118
src/components/UpdateProgressCapsule.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { Download, X, AlertCircle, Info } from 'lucide-react'
|
||||
import './UpdateProgressCapsule.scss'
|
||||
|
||||
const UpdateProgressCapsule: React.FC = () => {
|
||||
const {
|
||||
isDownloading,
|
||||
downloadProgress,
|
||||
showUpdateDialog,
|
||||
setShowUpdateDialog,
|
||||
updateInfo,
|
||||
setUpdateInfo,
|
||||
updateError,
|
||||
setUpdateError
|
||||
} = useAppStore()
|
||||
|
||||
// Control visibility
|
||||
// If dialog is open, we usually hide the capsule UNLESS we want it as a mini-indicator
|
||||
// For now, let's hide it if the dialog is open
|
||||
if (showUpdateDialog) return null
|
||||
|
||||
// State mapping
|
||||
const hasError = !!updateError
|
||||
const hasUpdate = !!updateInfo && updateInfo.hasUpdate
|
||||
|
||||
if (!hasError && !isDownloading && !hasUpdate) return null
|
||||
|
||||
// Safe normalize progress
|
||||
const safeProgress = typeof downloadProgress === 'number' ? { percent: downloadProgress } : (downloadProgress || { percent: 0 })
|
||||
const percent = safeProgress.percent || 0
|
||||
const bytesPerSecond = safeProgress.bytesPerSecond
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (!Number.isFinite(bytes) || bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
const unitIndex = Math.max(0, Math.min(i, sizes.length - 1))
|
||||
return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex]
|
||||
}
|
||||
|
||||
const formatSpeed = (bps: number) => {
|
||||
return `${formatBytes(bps)}/s`
|
||||
}
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (hasError) {
|
||||
setUpdateError(null)
|
||||
} else if (hasUpdate && !isDownloading) {
|
||||
setUpdateInfo(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine appearance class and content
|
||||
let capsuleClass = 'update-progress-capsule'
|
||||
let content = null
|
||||
|
||||
if (hasError) {
|
||||
capsuleClass += ' state-error'
|
||||
content = (
|
||||
<>
|
||||
<div className="icon-wrapper">
|
||||
<AlertCircle size={14} />
|
||||
</div>
|
||||
<div className="info-wrapper">
|
||||
<span className="error-text">更新失败: {updateError}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
} else if (isDownloading) {
|
||||
capsuleClass += ' state-downloading'
|
||||
content = (
|
||||
<>
|
||||
<div className="icon-wrapper">
|
||||
<Download size={14} className="download-icon" />
|
||||
</div>
|
||||
<div className="info-wrapper">
|
||||
<span className="percent-text">{percent.toFixed(0)}%</span>
|
||||
{bytesPerSecond > 0 && (
|
||||
<span className="speed-text">{formatSpeed(bytesPerSecond)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="progress-bg">
|
||||
<div className="progress-fill" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
} else if (hasUpdate) {
|
||||
capsuleClass += ' state-available'
|
||||
content = (
|
||||
<>
|
||||
<div className="icon-wrapper">
|
||||
<Info size={14} />
|
||||
</div>
|
||||
<div className="info-wrapper">
|
||||
<span className="available-text">发现新版本 v{updateInfo?.version}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={capsuleClass} onClick={() => setShowUpdateDialog(true)}>
|
||||
<div className="capsule-content">
|
||||
{content}
|
||||
{!isDownloading && (
|
||||
<button className="capsule-close" onClick={handleClose}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdateProgressCapsule
|
||||
262
src/components/VoiceTranscribeDialog.scss
Normal file
262
src/components/VoiceTranscribeDialog.scss
Normal file
@@ -0,0 +1,262 @@
|
||||
.voice-transcribe-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.voice-transcribe-dialog {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
|
||||
.info-icon {
|
||||
color: var(--primary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
width: 100%;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.model-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.download-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
|
||||
.download-icon {
|
||||
.downloading-icon {
|
||||
color: var(--primary);
|
||||
animation: bounce 1s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.download-text {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary-gradient);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.download-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 8px 0 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.complete-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
|
||||
.complete-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.complete-text {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
&.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
160
src/components/VoiceTranscribeDialog.tsx
Normal file
160
src/components/VoiceTranscribeDialog.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Download, X, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import './VoiceTranscribeDialog.scss'
|
||||
|
||||
interface VoiceTranscribeDialogProps {
|
||||
onClose: () => void
|
||||
onDownloadComplete: () => void
|
||||
}
|
||||
|
||||
export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
|
||||
onClose,
|
||||
onDownloadComplete
|
||||
}) => {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 监听下载进度
|
||||
if (!window.electronAPI?.whisper?.onDownloadProgress) {
|
||||
console.warn('[VoiceTranscribeDialog] whisper API 不可用')
|
||||
return
|
||||
}
|
||||
|
||||
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
|
||||
if (payload.percent !== undefined) {
|
||||
setDownloadProgress(payload.percent)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!window.electronAPI?.whisper?.downloadModel) {
|
||||
setDownloadError('语音转文字功能不可用')
|
||||
return
|
||||
}
|
||||
|
||||
setIsDownloading(true)
|
||||
setDownloadError(null)
|
||||
setDownloadProgress(0)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.whisper.downloadModel()
|
||||
|
||||
if (result?.success) {
|
||||
setIsComplete(true)
|
||||
setDownloadProgress(100)
|
||||
|
||||
// 延迟关闭弹窗并触发转写
|
||||
setTimeout(() => {
|
||||
onDownloadComplete()
|
||||
}, 1000)
|
||||
} else {
|
||||
setDownloadError(result?.error || '下载失败')
|
||||
setIsDownloading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
setDownloadError(String(error))
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (!isDownloading && !isComplete) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voice-transcribe-dialog-overlay" onClick={handleCancel}>
|
||||
<div className="voice-transcribe-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>语音转文字</h3>
|
||||
{!isDownloading && !isComplete && (
|
||||
<button className="close-button" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dialog-content">
|
||||
{!isDownloading && !isComplete && (
|
||||
<>
|
||||
<div className="info-section">
|
||||
<AlertCircle size={48} className="info-icon" />
|
||||
<p className="info-text">
|
||||
首次使用语音转文字功能需要下载 AI 模型
|
||||
</p>
|
||||
<div className="model-info">
|
||||
<div className="model-item">
|
||||
<span className="label">模型名称:</span>
|
||||
<span className="value">SenseVoiceSmall</span>
|
||||
</div>
|
||||
<div className="model-item">
|
||||
<span className="label">文件大小:</span>
|
||||
<span className="value">约 240 MB</span>
|
||||
</div>
|
||||
<div className="model-item">
|
||||
<span className="label">支持语言:</span>
|
||||
<span className="value">中文、粤语、英文、日文、韩文</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{downloadError && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
<span>{downloadError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button className="btn-secondary" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleDownload}>
|
||||
<Download size={16} />
|
||||
<span>立即下载</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isDownloading && !isComplete && (
|
||||
<div className="download-section">
|
||||
<div className="download-icon">
|
||||
<Download size={48} className="downloading-icon" />
|
||||
</div>
|
||||
<p className="download-text">
|
||||
{downloadProgress < 1 ? '正在连接服务器...' : '正在下载模型...'}
|
||||
</p>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="progress-text">{downloadProgress.toFixed(1)}%</p>
|
||||
{downloadProgress < 1 && (
|
||||
<p className="download-hint">首次连接可能需要较长时间,请耐心等待</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isComplete && (
|
||||
<div className="complete-section">
|
||||
<CheckCircle size={48} className="complete-icon" />
|
||||
<p className="complete-text">下载完成!正在转写语音...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
552
src/pages/AIChatPage.scss
Normal file
552
src/pages/AIChatPage.scss
Normal file
@@ -0,0 +1,552 @@
|
||||
// AI 对话页面 - 简约大气风格
|
||||
.ai-chat-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-gradient);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ========== 顶部 Header - 已移除 ==========
|
||||
// 模型选择器现已集成到输入框
|
||||
|
||||
|
||||
|
||||
// ========== 聊天区域 ==========
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// 空状态
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息列表
|
||||
.messages-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 80%;
|
||||
animation: messageIn 0.3s ease-out;
|
||||
|
||||
// 用户消息
|
||||
&.user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.avatar {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: var(--primary-gradient);
|
||||
color: white;
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
box-shadow: 0 2px 10px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
|
||||
.content {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI 消息
|
||||
&.ai {
|
||||
align-self: flex-start;
|
||||
|
||||
.avatar {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 12px 16px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.content,
|
||||
.markdown-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
// Markdown 样式
|
||||
.markdown-content {
|
||||
p {
|
||||
margin: 0 0 0.8em;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 0.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 0.8em 0;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 12px;
|
||||
margin: 0.8em 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.8em 0;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-spacer {
|
||||
height: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 输入区域
|
||||
.input-area {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(100% - 64px);
|
||||
max-width: 800px;
|
||||
z-index: 10;
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 10px 14px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
max-height: 120px;
|
||||
padding: 8px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
resize: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
// 模型选择器
|
||||
.model-selector {
|
||||
position: relative;
|
||||
|
||||
.model-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: auto;
|
||||
height: 36px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
|
||||
&.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--text-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.loaded {
|
||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.model-dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
animation: dropdownIn 0.2s ease-out;
|
||||
min-width: 140px;
|
||||
|
||||
.model-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
|
||||
.check {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.check {
|
||||
margin-left: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary-gradient);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 35%, transparent);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes messageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdownIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
391
src/pages/AIChatPage.tsx
Normal file
391
src/pages/AIChatPage.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Send, Bot, User, Cpu, ChevronDown, Loader2 } from 'lucide-react'
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
||||
import { engineService, PRESET_MODELS, ModelInfo } from '../services/EngineService'
|
||||
import { MessageBubble } from '../components/MessageBubble'
|
||||
import './AIChatPage.scss'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'ai';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// 消息数量限制,避免内存过载
|
||||
const MAX_MESSAGES = 200
|
||||
|
||||
export default function AIChatPage() {
|
||||
const [input, setInput] = useState('')
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [models, setModels] = useState<ModelInfo[]>([...PRESET_MODELS])
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null)
|
||||
const [modelLoaded, setModelLoaded] = useState(false)
|
||||
const [loadingModel, setLoadingModel] = useState(false)
|
||||
const [isThinkingMode, setIsThinkingMode] = useState(true)
|
||||
const [showModelDropdown, setShowModelDropdown] = useState(false)
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 流式渲染优化:使用 ref 缓存内容,使用 RAF 批量更新
|
||||
const streamingContentRef = useRef('')
|
||||
const streamingMessageIdRef = useRef<string | null>(null)
|
||||
const rafIdRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
checkModelsStatus()
|
||||
|
||||
// 初始化Llama服务(延迟初始化,用户进入此页面时启动)
|
||||
const initLlama = async () => {
|
||||
try {
|
||||
await window.electronAPI.llama?.init()
|
||||
console.log('[AIChatPage] Llama service initialized')
|
||||
} catch (e) {
|
||||
console.error('[AIChatPage] Failed to initialize Llama:', e)
|
||||
}
|
||||
}
|
||||
initLlama()
|
||||
|
||||
// 清理函数:组件卸载时释放所有资源
|
||||
return () => {
|
||||
// 取消未完成的 RAF
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
rafIdRef.current = null
|
||||
}
|
||||
|
||||
// 清理 engine service 的回调引用
|
||||
engineService.clearCallbacks()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 监听页面卸载事件,确保资源释放
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
// 清理回调和监听器
|
||||
engineService.dispose()
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [])
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowModelDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
// 使用 virtuoso 的 scrollToIndex 方法滚动到底部
|
||||
if (virtuosoRef.current && messages.length > 0) {
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index: messages.length - 1,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}, [messages.length])
|
||||
|
||||
const checkModelsStatus = async () => {
|
||||
const updatedModels = await Promise.all(models.map(async (m) => {
|
||||
const exists = await engineService.checkModelExists(m.path)
|
||||
return { ...m, downloaded: exists }
|
||||
}))
|
||||
setModels(updatedModels)
|
||||
|
||||
// Auto-select first available model
|
||||
if (!selectedModel) {
|
||||
const available = updatedModels.find(m => m.downloaded)
|
||||
if (available) {
|
||||
setSelectedModel(available.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自动加载模型
|
||||
const handleLoadModel = async (modelPath?: string) => {
|
||||
const pathToLoad = modelPath || selectedModel
|
||||
if (!pathToLoad) return false
|
||||
|
||||
setLoadingModel(true)
|
||||
try {
|
||||
await engineService.loadModel(pathToLoad)
|
||||
// Initialize session with system prompt
|
||||
await engineService.createSession("You are a helpful AI assistant.")
|
||||
setModelLoaded(true)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error("Load failed", e)
|
||||
alert("模型加载失败: " + String(e))
|
||||
return false
|
||||
} finally {
|
||||
setLoadingModel(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 选择模型(如果有多个)
|
||||
const handleSelectModel = (modelPath: string) => {
|
||||
setSelectedModel(modelPath)
|
||||
setShowModelDropdown(false)
|
||||
}
|
||||
|
||||
// 获取可用的已下载模型
|
||||
const availableModels = models.filter(m => m.downloaded)
|
||||
const selectedModelInfo = models.find(m => m.path === selectedModel)
|
||||
|
||||
// 优化的流式更新函数:使用 RAF 批量更新
|
||||
const updateStreamingMessage = useCallback(() => {
|
||||
if (!streamingMessageIdRef.current) return
|
||||
|
||||
setMessages(prev => prev.map(msg =>
|
||||
msg.id === streamingMessageIdRef.current
|
||||
? { ...msg, content: streamingContentRef.current }
|
||||
: msg
|
||||
))
|
||||
|
||||
rafIdRef.current = null
|
||||
}, [])
|
||||
|
||||
// Token 回调:使用 RAF 批量更新 UI
|
||||
const handleToken = useCallback((token: string) => {
|
||||
streamingContentRef.current += token
|
||||
|
||||
// 使用 requestAnimationFrame 批量更新,避免频繁渲染
|
||||
if (rafIdRef.current === null) {
|
||||
rafIdRef.current = requestAnimationFrame(updateStreamingMessage)
|
||||
}
|
||||
}, [updateStreamingMessage])
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isTyping) return
|
||||
|
||||
// 如果模型未加载,先自动加载
|
||||
if (!modelLoaded) {
|
||||
if (!selectedModel) {
|
||||
alert("请先下载模型(设置页面)")
|
||||
return
|
||||
}
|
||||
const loaded = await handleLoadModel()
|
||||
if (!loaded) return
|
||||
}
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev, userMsg]
|
||||
// 限制消息数量,避免内存过载
|
||||
return newMessages.length > MAX_MESSAGES
|
||||
? newMessages.slice(-MAX_MESSAGES)
|
||||
: newMessages
|
||||
})
|
||||
setInput('')
|
||||
setIsTyping(true)
|
||||
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
|
||||
const aiMsgId = (Date.now() + 1).toString()
|
||||
streamingContentRef.current = ''
|
||||
streamingMessageIdRef.current = aiMsgId
|
||||
|
||||
// Optimistic update for AI message start
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev, {
|
||||
id: aiMsgId,
|
||||
role: 'ai' as const,
|
||||
content: '',
|
||||
timestamp: Date.now()
|
||||
}]
|
||||
return newMessages.length > MAX_MESSAGES
|
||||
? newMessages.slice(-MAX_MESSAGES)
|
||||
: newMessages
|
||||
})
|
||||
|
||||
// Append thinking command based on mode
|
||||
const msgWithSuffix = input + (isThinkingMode ? " /think" : " /no_think")
|
||||
|
||||
try {
|
||||
await engineService.chat(msgWithSuffix, handleToken, { thinking: isThinkingMode })
|
||||
} catch (e) {
|
||||
console.error("Chat failed", e)
|
||||
setMessages(prev => [...prev, {
|
||||
id: Date.now().toString(),
|
||||
role: 'ai',
|
||||
content: "❌ Error: Failed to get response from AI.",
|
||||
timestamp: Date.now()
|
||||
}])
|
||||
} finally {
|
||||
setIsTyping(false)
|
||||
streamingMessageIdRef.current = null
|
||||
|
||||
// 确保最终状态同步
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
updateStreamingMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染模型选择按钮(集成在输入框作为下拉项)
|
||||
const renderModelSelector = () => {
|
||||
// 没有可用模型
|
||||
if (availableModels.length === 0) {
|
||||
return (
|
||||
<button
|
||||
className="model-btn disabled"
|
||||
title="请先在设置页面下载模型"
|
||||
>
|
||||
<Bot size={16} />
|
||||
<span>无模型</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// 只有一个模型,直接显示
|
||||
if (availableModels.length === 1) {
|
||||
return (
|
||||
<button
|
||||
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
|
||||
title={modelLoaded ? "模型已就绪" : "发送消息时自动加载"}
|
||||
>
|
||||
{loadingModel ? (
|
||||
<Loader2 size={16} className="spin" />
|
||||
) : (
|
||||
<Bot size={16} />
|
||||
)}
|
||||
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '模型'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// 多个模型,显示下拉选择
|
||||
return (
|
||||
<div className="model-selector" ref={dropdownRef}>
|
||||
<button
|
||||
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
|
||||
onClick={() => !loadingModel && setShowModelDropdown(!showModelDropdown)}
|
||||
title="点击选择模型"
|
||||
>
|
||||
{loadingModel ? (
|
||||
<Loader2 size={16} className="spin" />
|
||||
) : (
|
||||
<Bot size={16} />
|
||||
)}
|
||||
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '选择模型'}</span>
|
||||
<ChevronDown size={13} className={showModelDropdown ? 'rotate' : ''} />
|
||||
</button>
|
||||
|
||||
{showModelDropdown && (
|
||||
<div className="model-dropdown">
|
||||
{availableModels.map(model => (
|
||||
<div
|
||||
key={model.path}
|
||||
className={`model-option ${selectedModel === model.path ? 'active' : ''}`}
|
||||
onClick={() => handleSelectModel(model.path)}
|
||||
>
|
||||
<span>{model.name}</span>
|
||||
{selectedModel === model.path && (
|
||||
<span className="check">✓</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-chat-page">
|
||||
<div className="chat-main">
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="icon">
|
||||
<Bot size={40} />
|
||||
</div>
|
||||
<h2>AI 为你服务</h2>
|
||||
<p>
|
||||
{availableModels.length === 0
|
||||
? "请先在设置页面下载模型"
|
||||
: "输入消息开始对话,模型将自动加载"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={messages}
|
||||
className="messages-list"
|
||||
initialTopMostItemIndex={messages.length - 1}
|
||||
followOutput="smooth"
|
||||
itemContent={(index, message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
)}
|
||||
components={{
|
||||
Footer: () => <div className="list-spacer" />
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="input-area">
|
||||
<div className="input-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => {
|
||||
setInput(e.target.value)
|
||||
e.target.style.height = 'auto'
|
||||
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
// Reset height after send
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}}
|
||||
placeholder={availableModels.length === 0 ? "请先下载模型..." : "输入消息..."}
|
||||
disabled={availableModels.length === 0 || loadingModel}
|
||||
rows={1}
|
||||
/>
|
||||
<div className="input-actions">
|
||||
{renderModelSelector()}
|
||||
<button
|
||||
className={`mode-toggle ${isThinkingMode ? 'active' : ''}`}
|
||||
onClick={() => setIsThinkingMode(!isThinkingMode)}
|
||||
title={isThinkingMode ? "深度思考模式已开启" : "深度思考模式已关闭"}
|
||||
disabled={availableModels.length === 0}
|
||||
>
|
||||
<Cpu size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || availableModels.length === 0 || isTyping || loadingModel}
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ function AgreementPage() {
|
||||
<h2>用户协议</h2>
|
||||
|
||||
<h3>一、总则</h3>
|
||||
<p>欢迎使用WeFlow(WeFlow)软件。请在使用本软件前仔细阅读本协议。一旦您开始使用本软件,即表示您已充分理解并同意本协议的全部内容。</p>
|
||||
<p>欢迎使用WeFlow(WeFlow)软件。请在使用本软件前仔细阅读本协议。一旦你开始使用本软件,即表示你已充分理解并同意本协议的全部内容。</p>
|
||||
|
||||
<h3>二、软件说明</h3>
|
||||
<p>WeFlow是一款本地化的微信聊天记录查看与分析工具,所有数据处理均在用户本地设备上完成。</p>
|
||||
@@ -35,7 +35,7 @@ function AgreementPage() {
|
||||
<p>本软件不收集、不上传、不存储任何用户个人信息或聊天数据。所有数据处理均在本地完成。</p>
|
||||
|
||||
<h3>二、数据安全</h3>
|
||||
<p>您的聊天记录和个人数据完全存储在您的本地设备上,本软件不会将任何数据传输至外部服务器。</p>
|
||||
<p>你的聊天记录和个人数据完全存储在你的本地设备上,本软件不会将任何数据传输至外部服务器。</p>
|
||||
|
||||
<h3>三、网络请求</h3>
|
||||
<p>本软件仅在检查更新时会访问更新服务器获取版本信息,不涉及任何用户数据的传输。</p>
|
||||
|
||||
@@ -45,6 +45,30 @@
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@@ -293,3 +317,214 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排除好友弹窗
|
||||
.exclude-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.exclude-modal {
|
||||
width: 560px;
|
||||
max-width: calc(100vw - 48px);
|
||||
background: var(--card-bg);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.exclude-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.exclude-modal-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
padding: 2px;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.exclude-modal-body {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.exclude-loading,
|
||||
.exclude-error,
|
||||
.exclude-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
padding: 24px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.exclude-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.exclude-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
background: var(--bg-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: rgba(7, 193, 96, 0.4);
|
||||
background: rgba(7, 193, 96, 0.08);
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.exclude-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exclude-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.exclude-name {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exclude-username {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exclude-modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.exclude-footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.exclude-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.exclude-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import './AnalyticsPage.scss'
|
||||
import './DataManagementPage.scss'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
|
||||
interface ExcludeCandidate {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
wechatId?: string
|
||||
}
|
||||
|
||||
const normalizeUsername = (value: string) => value.trim().toLowerCase()
|
||||
|
||||
function AnalyticsPage() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingStatus, setLoadingStatus] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [isExcludeDialogOpen, setIsExcludeDialogOpen] = useState(false)
|
||||
const [excludeCandidates, setExcludeCandidates] = useState<ExcludeCandidate[]>([])
|
||||
const [excludeQuery, setExcludeQuery] = useState('')
|
||||
const [excludeLoading, setExcludeLoading] = useState(false)
|
||||
const [excludeError, setExcludeError] = useState<string | null>(null)
|
||||
const [excludedUsernames, setExcludedUsernames] = useState<Set<string>>(new Set())
|
||||
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(new Set())
|
||||
|
||||
const themeMode = useThemeStore((state) => state.themeMode)
|
||||
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
|
||||
const loadData = async (forceRefresh = false) => {
|
||||
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore()
|
||||
|
||||
const loadExcludedUsernames = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.analytics.getExcludedUsernames()
|
||||
if (result.success && result.data) {
|
||||
setExcludedUsernames(new Set(result.data.map(normalizeUsername)))
|
||||
} else {
|
||||
setExcludedUsernames(new Set())
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载排除名单失败', e)
|
||||
setExcludedUsernames(new Set())
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadData = useCallback(async (forceRefresh = false) => {
|
||||
if (isLoaded && !forceRefresh) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
@@ -28,7 +60,7 @@ function AnalyticsPage() {
|
||||
|
||||
try {
|
||||
setLoadingStatus('正在统计消息数据...')
|
||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics()
|
||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatistics(statsResult.data)
|
||||
} else {
|
||||
@@ -53,11 +85,128 @@ function AnalyticsPage() {
|
||||
setIsLoading(false)
|
||||
if (removeListener) removeListener()
|
||||
}
|
||||
}
|
||||
}, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution])
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const force = location.state?.forceRefresh === true
|
||||
loadData(force)
|
||||
}, [location.state, loadData])
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
loadExcludedUsernames()
|
||||
loadData(true)
|
||||
}
|
||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||
}, [loadData, loadExcludedUsernames])
|
||||
|
||||
useEffect(() => {
|
||||
loadExcludedUsernames()
|
||||
}, [loadExcludedUsernames])
|
||||
|
||||
const handleRefresh = () => loadData(true)
|
||||
const isNoSessionError = error?.includes('未找到消息会话') ?? false
|
||||
|
||||
const loadExcludeCandidates = useCallback(async () => {
|
||||
setExcludeLoading(true)
|
||||
setExcludeError(null)
|
||||
try {
|
||||
const result = await window.electronAPI.analytics.getExcludeCandidates()
|
||||
if (result.success && result.data) {
|
||||
setExcludeCandidates(result.data)
|
||||
} else {
|
||||
setExcludeError(result.error || '加载好友列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
setExcludeError(String(e))
|
||||
} finally {
|
||||
setExcludeLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openExcludeDialog = async () => {
|
||||
setExcludeQuery('')
|
||||
setDraftExcluded(new Set(excludedUsernames))
|
||||
setIsExcludeDialogOpen(true)
|
||||
await loadExcludeCandidates()
|
||||
}
|
||||
|
||||
const toggleExcluded = (username: string) => {
|
||||
const key = normalizeUsername(username)
|
||||
setDraftExcluded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleInvertSelection = () => {
|
||||
setDraftExcluded((prev) => {
|
||||
const allUsernames = new Set(excludeCandidates.map(c => normalizeUsername(c.username)))
|
||||
const inverted = new Set<string>()
|
||||
for (const u of allUsernames) {
|
||||
if (!prev.has(u)) inverted.add(u)
|
||||
}
|
||||
return inverted
|
||||
})
|
||||
}
|
||||
|
||||
const handleApplyExcluded = async () => {
|
||||
const payload = Array.from(draftExcluded)
|
||||
setIsExcludeDialogOpen(false)
|
||||
try {
|
||||
const result = await window.electronAPI.analytics.setExcludedUsernames(payload)
|
||||
if (!result.success) {
|
||||
alert(result.error || '更新排除名单失败')
|
||||
return
|
||||
}
|
||||
setExcludedUsernames(new Set((result.data || payload).map(normalizeUsername)))
|
||||
clearCache()
|
||||
await window.electronAPI.cache.clearAnalytics()
|
||||
await loadData(true)
|
||||
} catch (e) {
|
||||
alert(`更新排除名单失败:${String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetExcluded = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.analytics.setExcludedUsernames([])
|
||||
if (!result.success) {
|
||||
setError(result.error || '重置排除好友失败')
|
||||
return
|
||||
}
|
||||
setExcludedUsernames(new Set())
|
||||
setDraftExcluded(new Set())
|
||||
clearCache()
|
||||
await window.electronAPI.cache.clearAnalytics()
|
||||
await loadData(true)
|
||||
} catch (e) {
|
||||
setError(`重置排除好友失败: ${String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleExcludeCandidates = excludeCandidates
|
||||
.filter((candidate) => {
|
||||
const query = excludeQuery.trim().toLowerCase()
|
||||
if (!query) return true
|
||||
const wechatId = candidate.wechatId || ''
|
||||
const haystack = `${candidate.displayName} ${candidate.username} ${wechatId}`.toLowerCase()
|
||||
return haystack.includes(query)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aSelected = draftExcluded.has(normalizeUsername(a.username))
|
||||
const bSelected = draftExcluded.has(normalizeUsername(b.username))
|
||||
if (aSelected !== bSelected) return aSelected ? -1 : 1
|
||||
return a.displayName.localeCompare(b.displayName, 'zh')
|
||||
})
|
||||
|
||||
const formatDate = (timestamp: number | null) => {
|
||||
if (!timestamp) return '-'
|
||||
@@ -224,6 +373,22 @@ function AnalyticsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<p>{error}</p>
|
||||
<div className="error-actions">
|
||||
<button className="btn btn-secondary" onClick={handleResetExcluded}>
|
||||
重置排除好友
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => loadData(true)}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !isLoaded) {
|
||||
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
||||
}
|
||||
@@ -233,10 +398,16 @@ function AnalyticsPage() {
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>私聊分析</h1>
|
||||
<div className="header-actions">
|
||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||
{isLoading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={openExcludeDialog}>
|
||||
<UserMinus size={16} />
|
||||
排除好友{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-scroll">
|
||||
<section className="page-section">
|
||||
@@ -289,7 +460,7 @@ function AnalyticsPage() {
|
||||
<div key={contact.username} className="ranking-item">
|
||||
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||
<div className="contact-avatar">
|
||||
{contact.avatarUrl ? <img src={contact.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} />
|
||||
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
@@ -302,6 +473,89 @@ function AnalyticsPage() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{isExcludeDialogOpen && (
|
||||
<div className="exclude-modal-overlay" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||
<div className="exclude-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="exclude-modal-header">
|
||||
<h3>选择不统计的好友</h3>
|
||||
<button className="modal-close" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="exclude-modal-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索好友"
|
||||
value={excludeQuery}
|
||||
onChange={e => setExcludeQuery(e.target.value)}
|
||||
disabled={excludeLoading}
|
||||
/>
|
||||
{excludeQuery && (
|
||||
<button className="clear-search" onClick={() => setExcludeQuery('')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="exclude-modal-body">
|
||||
{excludeLoading && (
|
||||
<div className="exclude-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>正在加载好友列表...</span>
|
||||
</div>
|
||||
)}
|
||||
{!excludeLoading && excludeError && (
|
||||
<div className="exclude-error">{excludeError}</div>
|
||||
)}
|
||||
{!excludeLoading && !excludeError && (
|
||||
<div className="exclude-list">
|
||||
{visibleExcludeCandidates.map((candidate) => {
|
||||
const isChecked = draftExcluded.has(normalizeUsername(candidate.username))
|
||||
const wechatId = candidate.wechatId?.trim() || candidate.username
|
||||
return (
|
||||
<label key={candidate.username} className={`exclude-item ${isChecked ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleExcluded(candidate.username)}
|
||||
/>
|
||||
<div className="exclude-avatar">
|
||||
<Avatar src={candidate.avatarUrl} name={candidate.displayName} size={32} />
|
||||
</div>
|
||||
<div className="exclude-info">
|
||||
<span className="exclude-name">{candidate.displayName}</span>
|
||||
<span className="exclude-username">{wechatId}</span>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{visibleExcludeCandidates.length === 0 && (
|
||||
<div className="exclude-empty">
|
||||
{excludeQuery.trim() ? '未找到匹配好友' : '暂无可选好友'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="exclude-modal-footer">
|
||||
<div className="exclude-footer-left">
|
||||
<span className="exclude-count">已排除 {draftExcluded.size} 人</span>
|
||||
<button className="btn btn-text" onClick={toggleInvertSelection} disabled={excludeLoading}>
|
||||
反选
|
||||
</button>
|
||||
</div>
|
||||
<div className="exclude-actions">
|
||||
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleApplyExcluded} disabled={excludeLoading}>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
119
src/pages/AnalyticsWelcomePage.scss
Normal file
119
src/pages/AnalyticsWelcomePage.scss
Normal file
@@ -0,0 +1,119 @@
|
||||
.analytics-welcome-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 40px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
|
||||
.icon-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 24px;
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #07c160;
|
||||
|
||||
svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 30px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
border-color: #07c160;
|
||||
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.1);
|
||||
|
||||
.card-icon {
|
||||
color: #07c160;
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
63
src/pages/AnalyticsWelcomePage.tsx
Normal file
63
src/pages/AnalyticsWelcomePage.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { BarChart2, History, RefreshCcw } from 'lucide-react'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import './AnalyticsWelcomePage.scss'
|
||||
|
||||
function AnalyticsWelcomePage() {
|
||||
const navigate = useNavigate()
|
||||
// 检查是否有任何缓存数据加载或基本的存储状态表明它已准备好。
|
||||
// 实际上,如果 store 没有持久化,`isLoaded` 可能会在应用刷新时重置。
|
||||
// 如果用户点击“加载缓存”但缓存为空,AnalyticsPage 的逻辑(loadData 不带 force)将尝试从后端缓存加载。
|
||||
// 如果后端缓存也为空,则会重新计算。
|
||||
|
||||
// 我们也可以检查 `lastLoadTime` 来显示“上次更新:xxx”(如果已持久化)。
|
||||
const { lastLoadTime } = useAnalyticsStore()
|
||||
|
||||
const handleLoadCache = () => {
|
||||
navigate('/analytics/view')
|
||||
}
|
||||
|
||||
const handleNewAnalysis = () => {
|
||||
navigate('/analytics/view', { state: { forceRefresh: true } })
|
||||
}
|
||||
|
||||
const formatLastTime = (ts: number | null) => {
|
||||
if (!ts) return '无记录'
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="analytics-welcome-container">
|
||||
<div className="welcome-content">
|
||||
<div className="icon-wrapper">
|
||||
<BarChart2 size={40} />
|
||||
</div>
|
||||
<h1>私聊数据分析</h1>
|
||||
<p>
|
||||
WeFlow 可以分析你的聊天记录,生成详细的统计报表。<br />
|
||||
你可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
|
||||
</p>
|
||||
|
||||
<div className="action-cards">
|
||||
<button onClick={handleLoadCache}>
|
||||
<div className="card-icon">
|
||||
<History size={24} />
|
||||
</div>
|
||||
<h3>加载缓存</h3>
|
||||
<span>查看上次分析结果<br />(上次更新: {formatLastTime(lastLoadTime)})</span>
|
||||
</button>
|
||||
|
||||
<button onClick={handleNewAnalysis}>
|
||||
<div className="card-icon">
|
||||
<RefreshCcw size={24} />
|
||||
</div>
|
||||
<h3>新的分析</h3>
|
||||
<span>重新扫描并计算数据<br />(可能需要几分钟)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnalyticsWelcomePage
|
||||
@@ -5,6 +5,7 @@
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
@@ -25,6 +26,63 @@
|
||||
margin: 0 0 48px;
|
||||
}
|
||||
|
||||
.report-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
width: min(760px, 100%);
|
||||
}
|
||||
|
||||
.report-section {
|
||||
width: 100%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.section-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.year-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -34,6 +92,12 @@
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.report-section .year-grid {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.year-card {
|
||||
width: 120px;
|
||||
height: 100px;
|
||||
@@ -104,6 +168,13 @@
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.spin {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Calendar, Loader2, Sparkles } from 'lucide-react'
|
||||
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||
import './AnnualReportPage.scss'
|
||||
|
||||
type YearOption = number | 'all'
|
||||
|
||||
function AnnualReportPage() {
|
||||
const navigate = useNavigate()
|
||||
const [availableYears, setAvailableYears] = useState<number[]>([])
|
||||
const [selectedYear, setSelectedYear] = useState<number | null>(null)
|
||||
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
|
||||
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
@@ -22,7 +25,8 @@ function AnnualReportPage() {
|
||||
const result = await window.electronAPI.annualReport.getAvailableYears()
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
setAvailableYears(result.data)
|
||||
setSelectedYear(result.data[0])
|
||||
setSelectedYear((prev) => prev ?? result.data[0])
|
||||
setSelectedPairYear((prev) => prev ?? result.data[0])
|
||||
} else if (!result.success) {
|
||||
setLoadError(result.error || '加载年度数据失败')
|
||||
}
|
||||
@@ -35,10 +39,11 @@ function AnnualReportPage() {
|
||||
}
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (!selectedYear) return
|
||||
if (selectedYear === null) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
navigate(`/annual-report/view?year=${selectedYear}`)
|
||||
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||
navigate(`/annual-report/view?year=${yearParam}`)
|
||||
} catch (e) {
|
||||
console.error('生成报告失败:', e)
|
||||
} finally {
|
||||
@@ -46,6 +51,12 @@ function AnnualReportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateDualReport = () => {
|
||||
if (selectedPairYear === null) return
|
||||
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
|
||||
navigate(`/dual-report?year=${yearParam}`)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
@@ -67,21 +78,39 @@ function AnnualReportPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const yearOptions: YearOption[] = availableYears.length > 0
|
||||
? ['all', ...availableYears]
|
||||
: []
|
||||
|
||||
const getYearLabel = (value: YearOption | null) => {
|
||||
if (!value) return ''
|
||||
return value === 'all' ? '全部时间' : `${value} 年`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Sparkles size={32} className="header-icon" />
|
||||
<h1 className="page-title">年度报告</h1>
|
||||
<p className="page-desc">选择年份,生成你的微信聊天年度回顾</p>
|
||||
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||||
|
||||
<div className="report-sections">
|
||||
<section className="report-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2 className="section-title">总年度报告</h2>
|
||||
<p className="section-desc">包含所有会话与消息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="year-grid">
|
||||
{availableYears.map(year => (
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={year}
|
||||
className={`year-card ${selectedYear === year ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(year)}
|
||||
key={option}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(option)}
|
||||
>
|
||||
<span className="year-number">{year}</span>
|
||||
<span className="year-label">年</span>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -99,10 +128,48 @@ function AnnualReportPage() {
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={20} />
|
||||
<span>生成 {selectedYear} 年度报告</span>
|
||||
<span>生成 {getYearLabel(selectedYear)} 年度报告</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="report-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2 className="section-title">双人年度报告</h2>
|
||||
<p className="section-desc">选择一位好友,只看你们的私聊</p>
|
||||
</div>
|
||||
<div className="section-badge">
|
||||
<Users size={16} />
|
||||
<span>私聊</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="year-grid">
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={`pair-${option}`}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPairYear(option)}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="generate-btn secondary"
|
||||
onClick={handleGenerateDualReport}
|
||||
disabled={!selectedPairYear}
|
||||
>
|
||||
<Users size={20} />
|
||||
<span>选择好友并生成报告</span>
|
||||
</button>
|
||||
<p className="section-hint">从聊天排行中选择好友生成双人报告</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
.annual-report-window {
|
||||
// 使用全局主题变量,带回退值
|
||||
--ar-primary: var(--primary, #07C160);
|
||||
--ar-primary-rgb: var(--primary-rgb, 7, 193, 96);
|
||||
--ar-accent: var(--accent, #F2AA00);
|
||||
--ar-accent-rgb: 242, 170, 0;
|
||||
--ar-text-main: var(--text-primary, #222222);
|
||||
--ar-text-sub: var(--text-secondary, #555555);
|
||||
--ar-bg-color: var(--bg-primary, #F9F8F6);
|
||||
@@ -43,7 +45,7 @@
|
||||
|
||||
// 背景装饰圆点 - 毛玻璃效果
|
||||
.bg-decoration {
|
||||
position: absolute; // Changed from fixed
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
@@ -53,10 +55,10 @@
|
||||
.deco-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
background: rgba(var(--ar-primary-rgb), 0.03);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
&.c1 {
|
||||
width: 280px;
|
||||
@@ -243,6 +245,7 @@
|
||||
}
|
||||
|
||||
.exporting-snapshot {
|
||||
|
||||
.hero-title,
|
||||
.label-text,
|
||||
.hero-desc,
|
||||
@@ -253,6 +256,11 @@
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.deco-circle {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -1279,3 +1287,135 @@
|
||||
color: var(--ar-text-sub) !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 曾经的好朋友 视觉效果
|
||||
.lost-friend-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
margin: 64px auto 48px;
|
||||
position: relative;
|
||||
max-width: 480px;
|
||||
|
||||
.avatar-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 2;
|
||||
|
||||
.avatar-label {
|
||||
font-size: 13px;
|
||||
color: var(--ar-text-sub);
|
||||
font-weight: 500;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.sender {
|
||||
animation: fadeInRight 1s ease-out backwards;
|
||||
}
|
||||
|
||||
&.receiver {
|
||||
animation: fadeInLeft 1s ease-out backwards;
|
||||
}
|
||||
}
|
||||
|
||||
.fading-line {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.line-path {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to right,
|
||||
var(--ar-primary) 0%,
|
||||
rgba(var(--ar-primary-rgb), 0.4) 50%,
|
||||
rgba(var(--ar-primary-rgb), 0.05) 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.line-glow {
|
||||
position: absolute;
|
||||
inset: -4px 0;
|
||||
background: linear-gradient(to right,
|
||||
rgba(var(--ar-primary-rgb), 0.2) 0%,
|
||||
transparent 100%);
|
||||
filter: blur(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.flow-particle {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, var(--ar-primary), transparent);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
animation: flowAcross 4s infinite linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-desc.fading {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
font-size: 16px;
|
||||
margin-top: 32px;
|
||||
line-height: 1.8;
|
||||
letter-spacing: 0.05em;
|
||||
animation: fadeIn 1.5s ease-out 0.5s backwards;
|
||||
}
|
||||
|
||||
@keyframes flowAcross {
|
||||
0% {
|
||||
left: -20%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
90% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 120%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Loader2, Download, Image, Check, X } from 'lucide-react'
|
||||
import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import './AnnualReportWindow.scss'
|
||||
@@ -71,6 +71,20 @@ interface AnnualReportData {
|
||||
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
|
||||
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
|
||||
topPhrases?: { phrase: string; count: number }[]
|
||||
snsStats?: {
|
||||
totalPosts: number
|
||||
typeCounts?: Record<string, number>
|
||||
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
}
|
||||
lostFriend: {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
earlyCount: number
|
||||
lateCount: number
|
||||
periodDesc: string
|
||||
} | null
|
||||
}
|
||||
|
||||
interface SectionInfo {
|
||||
@@ -95,148 +109,8 @@ const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?:
|
||||
)
|
||||
}
|
||||
|
||||
// 热力图组件
|
||||
const Heatmap = ({ data }: { data: number[][] }) => {
|
||||
const maxHeat = Math.max(...data.flat())
|
||||
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
|
||||
return (
|
||||
<div className="heatmap-wrapper">
|
||||
<div className="heatmap-header">
|
||||
<div></div>
|
||||
<div className="time-labels">
|
||||
{[0, 6, 12, 18].map(h => (
|
||||
<span key={h} style={{ gridColumn: h + 1 }}>{h}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="heatmap">
|
||||
<div className="heatmap-week-col">
|
||||
{weekLabels.map(w => <div key={w} className="week-label">{w}</div>)}
|
||||
</div>
|
||||
<div className="heatmap-grid">
|
||||
{data.map((row, wi) =>
|
||||
row.map((val, hi) => {
|
||||
const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1'
|
||||
return (
|
||||
<div
|
||||
key={`${wi}-${hi}`}
|
||||
className="h-cell"
|
||||
style={{ background: `rgba(7, 193, 96, ${alpha})` }}
|
||||
title={`${weekLabels[wi]} ${hi}:00 - ${val}条`}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 词云组件
|
||||
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
|
||||
const maxCount = words.length > 0 ? words[0].count : 1
|
||||
const topWords = words.slice(0, 32)
|
||||
const baseSize = 520
|
||||
|
||||
// 使用确定性随机数生成器
|
||||
const seededRandom = (seed: number) => {
|
||||
const x = Math.sin(seed) * 10000
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
// 计算词云位置
|
||||
const placedItems: { x: number; y: number; w: number; h: number }[] = []
|
||||
|
||||
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
|
||||
const halfW = w / 2
|
||||
const halfH = h / 2
|
||||
const dx = x - 50
|
||||
const dy = y - 50
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const maxR = 49 - Math.max(halfW, halfH)
|
||||
if (dist > maxR) return false
|
||||
|
||||
const pad = 1.8
|
||||
for (const p of placedItems) {
|
||||
if ((x - halfW - pad) < (p.x + p.w / 2) &&
|
||||
(x + halfW + pad) > (p.x - p.w / 2) &&
|
||||
(y - halfH - pad) < (p.y + p.h / 2) &&
|
||||
(y + halfH + pad) > (p.y - p.h / 2)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const wordItems = topWords.map((item, i) => {
|
||||
const ratio = item.count / maxCount
|
||||
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
|
||||
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
|
||||
const delay = (i * 0.04).toFixed(2)
|
||||
|
||||
// 计算词语宽度
|
||||
const charCount = Math.max(1, item.phrase.length)
|
||||
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
|
||||
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
|
||||
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
|
||||
const widthPx = fontSize * (charCount * widthFactor)
|
||||
const heightPx = fontSize * 1.1
|
||||
const widthPct = (widthPx / baseSize) * 100
|
||||
const heightPct = (heightPx / baseSize) * 100
|
||||
|
||||
// 寻找位置
|
||||
let x = 50, y = 50
|
||||
let placedOk = false
|
||||
const tries = i === 0 ? 1 : 420
|
||||
|
||||
for (let t = 0; t < tries; t++) {
|
||||
if (i === 0) {
|
||||
x = 50
|
||||
y = 50
|
||||
} else {
|
||||
const idx = i + t * 0.28
|
||||
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
|
||||
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
|
||||
x = 50 + radius * Math.cos(angle)
|
||||
y = 50 + radius * Math.sin(angle)
|
||||
}
|
||||
if (canPlace(x, y, widthPct, heightPct)) {
|
||||
placedOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!placedOk) return null
|
||||
placedItems.push({ x, y, w: widthPct, h: heightPct })
|
||||
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="word-tag"
|
||||
style={{
|
||||
'--final-opacity': opacity,
|
||||
left: `${x.toFixed(2)}%`,
|
||||
top: `${y.toFixed(2)}%`,
|
||||
fontSize: `${fontSize}px`,
|
||||
animationDelay: `${delay}s`,
|
||||
} as React.CSSProperties}
|
||||
title={`${item.phrase} (出现 ${item.count} 次)`}
|
||||
>
|
||||
{item.phrase}
|
||||
</span>
|
||||
)
|
||||
}).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="word-cloud-wrapper">
|
||||
<div className="word-cloud-inner">
|
||||
{wordItems}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import Heatmap from '../components/ReportHeatmap'
|
||||
import WordCloud from '../components/ReportWordCloud'
|
||||
|
||||
function AnnualReportWindow() {
|
||||
const [reportData, setReportData] = useState<AnnualReportData | null>(null)
|
||||
@@ -249,6 +123,7 @@ function AnnualReportWindow() {
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [loadingStage, setLoadingStage] = useState('正在初始化...')
|
||||
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
|
||||
|
||||
const { currentTheme, themeMode } = useThemeStore()
|
||||
|
||||
@@ -273,6 +148,8 @@ function AnnualReportWindow() {
|
||||
responseSpeed: useRef<HTMLElement>(null),
|
||||
topPhrases: useRef<HTMLElement>(null),
|
||||
ranking: useRef<HTMLElement>(null),
|
||||
sns: useRef<HTMLElement>(null),
|
||||
lostFriend: useRef<HTMLElement>(null),
|
||||
ending: useRef<HTMLElement>(null),
|
||||
}
|
||||
|
||||
@@ -281,7 +158,8 @@ function AnnualReportWindow() {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const yearParam = params.get('year')
|
||||
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
|
||||
const parsedYear = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear()
|
||||
const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear
|
||||
generateReport(year)
|
||||
}, [])
|
||||
|
||||
@@ -336,6 +214,11 @@ function AnnualReportWindow() {
|
||||
return `${Math.round(seconds / 3600)}小时`
|
||||
}
|
||||
|
||||
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
|
||||
if (value === 0) return '历史以来'
|
||||
return withSuffix ? `${value}年` : `${value}`
|
||||
}
|
||||
|
||||
// 获取可用的板块列表
|
||||
const getAvailableSections = (): SectionInfo[] => {
|
||||
if (!reportData) return []
|
||||
@@ -366,10 +249,16 @@ function AnnualReportWindow() {
|
||||
if (reportData.responseSpeed) {
|
||||
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed })
|
||||
}
|
||||
if (reportData.lostFriend) {
|
||||
sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend })
|
||||
}
|
||||
if (reportData.topPhrases && reportData.topPhrases.length > 0) {
|
||||
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
|
||||
}
|
||||
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
|
||||
if (reportData.snsStats && reportData.snsStats.totalPosts > 0) {
|
||||
sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns })
|
||||
}
|
||||
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
||||
return sections
|
||||
}
|
||||
@@ -490,7 +379,7 @@ function AnnualReportWindow() {
|
||||
}
|
||||
|
||||
// 导出整个报告为长图
|
||||
const exportFullReport = async () => {
|
||||
const exportFullReport = async (filterIds?: Set<string>) => {
|
||||
if (!containerRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -516,6 +405,16 @@ function AnnualReportWindow() {
|
||||
el.style.padding = '40px 0'
|
||||
})
|
||||
|
||||
// 如果有筛选,隐藏未选中的板块
|
||||
if (filterIds) {
|
||||
const available = getAvailableSections()
|
||||
available.forEach(s => {
|
||||
if (!filterIds.has(s.id) && s.ref.current) {
|
||||
s.ref.current.style.display = 'none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 修复词云导出问题
|
||||
const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement
|
||||
const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
||||
@@ -584,7 +483,8 @@ function AnnualReportWindow() {
|
||||
|
||||
const dataUrl = outputCanvas.toDataURL('image/png')
|
||||
const link = document.createElement('a')
|
||||
link.download = `${reportData?.year}年度报告.png`
|
||||
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||
link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png`
|
||||
link.href = dataUrl
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
@@ -607,6 +507,13 @@ function AnnualReportWindow() {
|
||||
return
|
||||
}
|
||||
|
||||
if (exportMode === 'long') {
|
||||
setShowExportModal(false)
|
||||
await exportFullReport(selectedSections)
|
||||
setSelectedSections(new Set())
|
||||
return
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
setShowExportModal(false)
|
||||
|
||||
@@ -640,11 +547,12 @@ function AnnualReportWindow() {
|
||||
}
|
||||
|
||||
setExportProgress('正在写入文件...')
|
||||
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||
baseDir: dirResult.filePaths[0],
|
||||
folderName: `${reportData?.year}年度报告_分模块`,
|
||||
folderName: `${yearFilePrefix}年度报告_分模块`,
|
||||
images: exportedImages.map((img) => ({
|
||||
name: `${reportData?.year}年度报告_${img.name}.png`,
|
||||
name: `${yearFilePrefix}年度报告_${img.name}.png`,
|
||||
dataUrl: img.data
|
||||
}))
|
||||
})
|
||||
@@ -715,10 +623,14 @@ function AnnualReportWindow() {
|
||||
)
|
||||
}
|
||||
|
||||
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData
|
||||
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData
|
||||
const topFriend = coreFriends[0]
|
||||
const mostActive = getMostActiveTime(activityHeatmap.data)
|
||||
const socialStoryName = topFriend?.displayName || '好友'
|
||||
const yearTitle = formatYearLabel(year, true)
|
||||
const yearTitleShort = formatYearLabel(year, false)
|
||||
const monthlyTitle = year === 0 ? '历史以来月度好友' : `${year}年月度好友`
|
||||
const phrasesTitle = year === 0 ? '你在历史以来的常用语' : `你在${year}年的年度常用语`
|
||||
|
||||
return (
|
||||
<div className="annual-report-window">
|
||||
@@ -735,9 +647,12 @@ function AnnualReportWindow() {
|
||||
|
||||
{/* 浮动操作按钮 */}
|
||||
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); setShowExportModal(true) }} title="分模块导出">
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('separate'); setShowExportModal(true) }} title="分模块导出">
|
||||
<Image size={18} />
|
||||
</button>
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('long'); setShowExportModal(true) }} title="自定义导出长图">
|
||||
<SlidersHorizontal size={18} />
|
||||
</button>
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图">
|
||||
<Download size={18} />
|
||||
</button>
|
||||
@@ -765,7 +680,7 @@ function AnnualReportWindow() {
|
||||
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
|
||||
<div className="export-modal section-selector" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>选择要导出的板块</h3>
|
||||
<h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
|
||||
<button className="close-btn" onClick={() => setShowExportModal(false)}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -793,7 +708,7 @@ function AnnualReportWindow() {
|
||||
onClick={exportSelectedSections}
|
||||
disabled={selectedSections.size === 0}
|
||||
>
|
||||
导出 {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
|
||||
{exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -806,7 +721,7 @@ function AnnualReportWindow() {
|
||||
{/* 封面 */}
|
||||
<section className="section" ref={sectionRefs.cover}>
|
||||
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
|
||||
<h1 className="hero-title">{year}年<br />微信聊天报告</h1>
|
||||
<h1 className="hero-title">{yearTitle}<br />微信聊天报告</h1>
|
||||
<hr className="divider" />
|
||||
<p className="hero-desc">每一条消息背后<br />都藏着一段独特的故事</p>
|
||||
</section>
|
||||
@@ -838,7 +753,7 @@ function AnnualReportWindow() {
|
||||
你发出 <span className="hl">{formatNumber(topFriend.sentCount)}</span> 条 ·
|
||||
TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span> 条
|
||||
</p>
|
||||
<br/>
|
||||
<br />
|
||||
<p className="hero-desc">
|
||||
在一起,就可以
|
||||
</p>
|
||||
@@ -848,7 +763,7 @@ function AnnualReportWindow() {
|
||||
{/* 月度好友 */}
|
||||
<section className="section" ref={sectionRefs.monthlyFriends}>
|
||||
<div className="label-text">月度好友</div>
|
||||
<h2 className="hero-title">{year}年月度好友</h2>
|
||||
<h2 className="hero-title">{monthlyTitle}</h2>
|
||||
<p className="hero-desc">根据12个月的聊天习惯</p>
|
||||
<div className="monthly-orbit">
|
||||
{monthlyTopFriends.map((m, i) => (
|
||||
@@ -862,7 +777,7 @@ function AnnualReportWindow() {
|
||||
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="hero-desc">只要你想<br />我一直在</p>
|
||||
<p className="hero-desc">你只管说<br />我一直在</p>
|
||||
</section>
|
||||
|
||||
{/* 双向奔赴 */}
|
||||
@@ -962,15 +877,15 @@ function AnnualReportWindow() {
|
||||
{midnightKing && (
|
||||
<section className="section" ref={sectionRefs.midnightKing}>
|
||||
<div className="label-text">深夜好友</div>
|
||||
<h2 className="hero-title">当城市睡去</h2>
|
||||
<p className="hero-desc">这一年你留下了</p>
|
||||
<h2 className="hero-title">月光下的你</h2>
|
||||
<p className="hero-desc">在这一年你留下了</p>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{midnightKing.count}</span>
|
||||
<span className="stat-unit">条深夜的消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你。
|
||||
<br />你和Ta的对话占深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>。
|
||||
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你胡思乱想。
|
||||
<br />你和Ta的对话占你深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>。
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
@@ -991,11 +906,46 @@ function AnnualReportWindow() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 曾经的好朋友 */}
|
||||
{lostFriend && (
|
||||
<section className="section" ref={sectionRefs.lostFriend}>
|
||||
<div className="label-text">曾经的好朋友</div>
|
||||
<h2 className="hero-title">{lostFriend.displayName}</h2>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{formatNumber(lostFriend.earlyCount)}</span>
|
||||
<span className="stat-unit">条消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
在 <span className="hl">{lostFriend.periodDesc}</span>
|
||||
<br />你们曾有聊不完的话题
|
||||
</p>
|
||||
<div className="lost-friend-visual">
|
||||
<div className="avatar-group sender">
|
||||
<Avatar url={lostFriend.avatarUrl} name={lostFriend.displayName} size="lg" />
|
||||
<span className="avatar-label">TA</span>
|
||||
</div>
|
||||
<div className="fading-line">
|
||||
<div className="line-path" />
|
||||
<div className="line-glow" />
|
||||
<div className="flow-particle" />
|
||||
</div>
|
||||
<div className="avatar-group receiver">
|
||||
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||
<span className="avatar-label">我</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hero-desc fading">
|
||||
人类发明后悔
|
||||
<br />来证明拥有的珍贵
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 年度常用语 - 词云 */}
|
||||
{topPhrases && topPhrases.length > 0 && (
|
||||
<section className="section" ref={sectionRefs.topPhrases}>
|
||||
<div className="label-text">年度常用语</div>
|
||||
<h2 className="hero-title">你在{year}年的年度常用语</h2>
|
||||
<h2 className="hero-title">{phrasesTitle}</h2>
|
||||
<p className="hero-desc">
|
||||
这一年,你说得最多的是:
|
||||
<br />
|
||||
@@ -1008,6 +958,57 @@ function AnnualReportWindow() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 朋友圈 */}
|
||||
{reportData.snsStats && reportData.snsStats.totalPosts > 0 && (
|
||||
<section className="section" ref={sectionRefs.sns}>
|
||||
<div className="label-text">朋友圈</div>
|
||||
<h2 className="hero-title">记录生活时刻</h2>
|
||||
<p className="hero-desc">
|
||||
这一年,你发布了
|
||||
</p>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{reportData.snsStats.totalPosts}</span>
|
||||
<span className="stat-unit">条朋友圈</span>
|
||||
</div>
|
||||
|
||||
<div className="sns-stats-container" style={{ display: 'flex', gap: '60px', marginTop: '40px', justifyContent: 'center' }}>
|
||||
{reportData.snsStats.topLikers.length > 0 && (
|
||||
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
|
||||
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>更关心你的Ta</h3>
|
||||
<div className="mini-ranking">
|
||||
{reportData.snsStats.topLikers.slice(0, 3).map((u, i) => (
|
||||
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
|
||||
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
|
||||
</div>
|
||||
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}赞</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reportData.snsStats.topLiked.length > 0 && (
|
||||
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
|
||||
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>你最关心的Ta</h3>
|
||||
<div className="mini-ranking">
|
||||
{reportData.snsStats.topLiked.slice(0, 3).map((u, i) => (
|
||||
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
|
||||
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
|
||||
</div>
|
||||
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}赞</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 好友排行 */}
|
||||
<section className="section" ref={sectionRefs.ranking}>
|
||||
<div className="label-text">好友排行</div>
|
||||
@@ -1064,7 +1065,7 @@ function AnnualReportWindow() {
|
||||
<br />愿新的一年,
|
||||
<br />所有期待,皆有回声。
|
||||
</p>
|
||||
<div className="ending-year">{year}</div>
|
||||
<div className="ending-year">{yearTitleShort}</div>
|
||||
<div className="ending-brand">WEFLOW</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
132
src/pages/ChatHistoryPage.scss
Normal file
132
src/pages/ChatHistoryPage.scss
Normal file
@@ -0,0 +1,132 @@
|
||||
.chat-history-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
|
||||
.history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.status-msg {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
|
||||
&.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
&.empty {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.sender {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: var(--bg-secondary);
|
||||
padding: 10px 14px;
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
&.image-bubble {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.media-content {
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-tip {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.media-placeholder {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
250
src/pages/ChatHistoryPage.tsx
Normal file
250
src/pages/ChatHistoryPage.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useLocation } from 'react-router-dom'
|
||||
import { ChatRecordItem } from '../types/models'
|
||||
import TitleBar from '../components/TitleBar'
|
||||
import './ChatHistoryPage.scss'
|
||||
|
||||
export default function ChatHistoryPage() {
|
||||
const params = useParams<{ sessionId: string; messageId: string }>()
|
||||
const location = useLocation()
|
||||
const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [title, setTitle] = useState('聊天记录')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 简单的 XML 标签内容提取
|
||||
const extractXmlValue = (xml: string, tag: string): string => {
|
||||
const match = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`).exec(xml)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
// 简单的 HTML 实体解码
|
||||
const decodeHtmlEntities = (text?: string): string | undefined => {
|
||||
if (!text) return text
|
||||
return text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
// 前端兜底解析合并转发聊天记录
|
||||
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
|
||||
try {
|
||||
const type = extractXmlValue(content, 'type')
|
||||
if (type !== '19') return undefined
|
||||
|
||||
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
|
||||
if (!match) return undefined
|
||||
|
||||
const innerXml = match[1]
|
||||
const items: ChatRecordItem[] = []
|
||||
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
|
||||
let itemMatch: RegExpExecArray | null
|
||||
|
||||
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
|
||||
const attrs = itemMatch[1]
|
||||
const body = itemMatch[2]
|
||||
|
||||
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
|
||||
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
|
||||
|
||||
const sourcename = extractXmlValue(body, 'sourcename')
|
||||
const sourcetime = extractXmlValue(body, 'sourcetime')
|
||||
const sourceheadurl = extractXmlValue(body, 'sourceheadurl')
|
||||
const datadesc = extractXmlValue(body, 'datadesc')
|
||||
const datatitle = extractXmlValue(body, 'datatitle')
|
||||
const fileext = extractXmlValue(body, 'fileext')
|
||||
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0')
|
||||
const messageuuid = extractXmlValue(body, 'messageuuid')
|
||||
|
||||
const dataurl = extractXmlValue(body, 'dataurl')
|
||||
const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl')
|
||||
const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl')
|
||||
const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')
|
||||
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5')
|
||||
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0')
|
||||
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0')
|
||||
const duration = parseInt(extractXmlValue(body, 'duration') || '0')
|
||||
|
||||
items.push({
|
||||
datatype,
|
||||
sourcename,
|
||||
sourcetime,
|
||||
sourceheadurl,
|
||||
datadesc: decodeHtmlEntities(datadesc),
|
||||
datatitle: decodeHtmlEntities(datatitle),
|
||||
fileext,
|
||||
datasize,
|
||||
messageuuid,
|
||||
dataurl: decodeHtmlEntities(dataurl),
|
||||
datathumburl: decodeHtmlEntities(datathumburl),
|
||||
datacdnurl: decodeHtmlEntities(datacdnurl),
|
||||
aeskey: decodeHtmlEntities(aeskey),
|
||||
md5,
|
||||
imgheight,
|
||||
imgwidth,
|
||||
duration
|
||||
})
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined
|
||||
} catch (e) {
|
||||
console.error('前端解析聊天记录失败:', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 统一从路由参数或 pathname 中解析 sessionId / messageId
|
||||
const getIds = () => {
|
||||
const sessionId = params.sessionId || ''
|
||||
const messageId = params.messageId || ''
|
||||
|
||||
if (sessionId && messageId) {
|
||||
return { sid: sessionId, mid: messageId }
|
||||
}
|
||||
|
||||
// 独立窗口场景下没有 Route 包裹,用 pathname 手动解析
|
||||
const match = /^\/chat-history\/([^/]+)\/([^/]+)/.exec(location.pathname)
|
||||
if (match) {
|
||||
return { sid: match[1], mid: match[2] }
|
||||
}
|
||||
|
||||
return { sid: '', mid: '' }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const { sid, mid } = getIds()
|
||||
if (!sid || !mid) {
|
||||
setError('无效的聊天记录链接')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMessage(sid, parseInt(mid, 10))
|
||||
if (result.success && result.message) {
|
||||
const msg = result.message
|
||||
// 优先使用后端解析好的列表
|
||||
let records: ChatRecordItem[] | undefined = msg.chatRecordList
|
||||
|
||||
// 如果后端没有解析到,则在前端兜底解析一次
|
||||
if ((!records || records.length === 0) && msg.content) {
|
||||
records = parseChatHistory(msg.content) || []
|
||||
}
|
||||
|
||||
if (records && records.length > 0) {
|
||||
setRecordList(records)
|
||||
const match = /<title>(.*?)<\/title>/.exec(msg.content || '')
|
||||
if (match) setTitle(match[1])
|
||||
} else {
|
||||
setError('暂时无法解析这条聊天记录')
|
||||
}
|
||||
} else {
|
||||
setError(result.error || '获取消息失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setError('加载详情失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [params.sessionId, params.messageId, location.pathname])
|
||||
|
||||
return (
|
||||
<div className="chat-history-page">
|
||||
<TitleBar title={title} />
|
||||
<div className="history-list">
|
||||
{loading ? (
|
||||
<div className="status-msg">加载中...</div>
|
||||
) : error ? (
|
||||
<div className="status-msg error">{error}</div>
|
||||
) : recordList.length === 0 ? (
|
||||
<div className="status-msg empty">暂无可显示的聊天记录</div>
|
||||
) : (
|
||||
recordList.map((item, i) => (
|
||||
<HistoryItem key={i} item={item} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
// sourcetime 在合并转发里有两种格式:
|
||||
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
||||
let time = ''
|
||||
if (item.sourcetime) {
|
||||
if (/^\d+$/.test(item.sourcetime)) {
|
||||
time = new Date(parseInt(item.sourcetime, 10) * 1000).toLocaleString()
|
||||
} else {
|
||||
time = item.sourcetime
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (item.datatype === 1) {
|
||||
// 文本消息
|
||||
return <div className="text-content">{item.datadesc || ''}</div>
|
||||
}
|
||||
if (item.datatype === 3) {
|
||||
// 图片
|
||||
const src = item.datathumburl || item.datacdnurl
|
||||
if (src) {
|
||||
return (
|
||||
<div className="media-content">
|
||||
<img
|
||||
src={src}
|
||||
alt="图片"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const placeholder = document.createElement('div')
|
||||
placeholder.className = 'media-tip'
|
||||
placeholder.textContent = '图片无法加载'
|
||||
target.parentElement?.appendChild(placeholder)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="media-placeholder">[图片]</div>
|
||||
}
|
||||
if (item.datatype === 43) {
|
||||
return <div className="media-placeholder">[视频] {item.datatitle}</div>
|
||||
}
|
||||
if (item.datatype === 34) {
|
||||
return <div className="media-placeholder">[语音] {item.duration ? (item.duration / 1000).toFixed(0) + '"' : ''}</div>
|
||||
}
|
||||
// Fallback
|
||||
return <div className="text-content">{item.datadesc || item.datatitle || '[不支持的消息类型]'}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="history-item">
|
||||
<div className="avatar">
|
||||
{item.sourceheadurl ? (
|
||||
<img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{item.sourcename?.slice(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="content-wrapper">
|
||||
<div className="header">
|
||||
<span className="sender">{item.sourcename || '未知发送者'}</span>
|
||||
<span className="time">{time}</span>
|
||||
</div>
|
||||
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user