mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Compare commits
369 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7852a8c07 | ||
|
|
4b9d94eb62 | ||
|
|
70481fd468 | ||
|
|
52c67f4d23 | ||
|
|
d3618f3065 | ||
|
|
29472beee8 | ||
|
|
acaac507b1 | ||
|
|
f25c23b2b3 | ||
|
|
5ab0466a87 | ||
|
|
d49c44f3be | ||
|
|
4577b4e955 | ||
|
|
dafde2eaba | ||
|
|
db4fab9130 | ||
|
|
9aee578707 | ||
|
|
6d74eb65ae | ||
|
|
6e8ae3a12b | ||
|
|
a4be7f9005 | ||
|
|
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 |
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -21,48 +21,41 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22.12
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- 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
|
- name: Build Frontend & Type Check
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
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
|
- name: Package and Publish
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
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
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -56,3 +56,9 @@ Thumbs.db
|
|||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
|
*info
|
||||||
|
概述.md
|
||||||
|
chatlab-format.md
|
||||||
|
*.bak
|
||||||
|
AGENTS.md
|
||||||
|
.claude/
|
||||||
65
README.md
65
README.md
@@ -19,22 +19,44 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
<a href="https://github.com/hicccc77/WeFlow/issues">
|
||||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
||||||
|
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://t.me/+hn3QzNc4DbA0MzNl">
|
<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">
|
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 仅支持微信 **4.0 及以上**版本,确保你的微信版本符合要求
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- 本地实时查看聊天记录
|
- 本地实时查看聊天记录
|
||||||
|
- 朋友圈图片、视频、**实况**的预览和解密
|
||||||
- 统计分析与群聊画像
|
- 统计分析与群聊画像
|
||||||
- 年度报告与可视化概览
|
- 年度报告与可视化概览
|
||||||
- 导出聊天记录为 HTML 等格式
|
- 导出聊天记录为 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 +83,19 @@ npm run build
|
|||||||
|
|
||||||
打包产物在 `release` 目录下。
|
打包产物在 `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/ # 打包资源
|
|
||||||
```
|
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
- [密语 CipherTalk](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
|
## Star History
|
||||||
@@ -111,6 +114,4 @@ WeFlow/
|
|||||||
|
|
||||||
**请负责任地使用本工具,遵守相关法律法规**
|
**请负责任地使用本工具,遵守相关法律法规**
|
||||||
|
|
||||||
我们总是在向前走,却很少有机会回头看看
|
|
||||||
|
|
||||||
</div>
|
</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) })
|
||||||
|
})
|
||||||
760
electron/main.ts
760
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 { Worker } from 'worker_threads'
|
||||||
import { join } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { autoUpdater } from 'electron-updater'
|
import { autoUpdater } from 'electron-updater'
|
||||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
@@ -13,8 +14,16 @@ import { imagePreloadService } from './services/imagePreloadService'
|
|||||||
import { analyticsService } from './services/analyticsService'
|
import { analyticsService } from './services/analyticsService'
|
||||||
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||||
import { annualReportService } from './services/annualReportService'
|
import { annualReportService } from './services/annualReportService'
|
||||||
import { exportService, ExportOptions } from './services/exportService'
|
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||||
import { KeyService } from './services/keyService'
|
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 { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||||
|
import { httpService } from './services/httpService'
|
||||||
|
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
@@ -26,6 +35,47 @@ const AUTO_UPDATE_ENABLED =
|
|||||||
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
||||||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
|
(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
|
let configService: ConfigService | null = null
|
||||||
|
|
||||||
@@ -54,7 +104,8 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, 'preload.js'),
|
preload: join(__dirname, 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false
|
nodeIntegration: false,
|
||||||
|
webSecurity: false // Allow loading local files (video playback)
|
||||||
},
|
},
|
||||||
titleBarStyle: 'hidden',
|
titleBarStyle: 'hidden',
|
||||||
titleBarOverlay: {
|
titleBarOverlay: {
|
||||||
@@ -92,6 +143,50 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
win.loadFile(join(__dirname, '../dist/index.html'))
|
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 })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确)
|
||||||
|
win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
|
||||||
|
const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
|
||||||
|
try {
|
||||||
|
const host = new URL(url).hostname
|
||||||
|
if (trusted.some(d => host.endsWith(d))) {
|
||||||
|
event.preventDefault()
|
||||||
|
callback(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
callback(false)
|
||||||
|
})
|
||||||
|
|
||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,10 +260,11 @@ function createOnboardingWindow() {
|
|||||||
: join(process.resourcesPath, 'icon.ico')
|
: join(process.resourcesPath, 'icon.ico')
|
||||||
|
|
||||||
onboardingWindow = new BrowserWindow({
|
onboardingWindow = new BrowserWindow({
|
||||||
width: 1100,
|
width: 960,
|
||||||
height: 720,
|
height: 680,
|
||||||
minWidth: 900,
|
minWidth: 900,
|
||||||
minHeight: 600,
|
minHeight: 620,
|
||||||
|
resizable: false,
|
||||||
frame: false,
|
frame: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
backgroundColor: '#00000000',
|
backgroundColor: '#00000000',
|
||||||
@@ -199,6 +295,225 @@ function createOnboardingWindow() {
|
|||||||
return onboardingWindow
|
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() {
|
function showMainWindow() {
|
||||||
shouldShowMain = true
|
shouldShowMain = true
|
||||||
if (mainWindowReady) {
|
if (mainWindowReady) {
|
||||||
@@ -208,6 +523,7 @@ function showMainWindow() {
|
|||||||
|
|
||||||
// 注册 IPC 处理器
|
// 注册 IPC 处理器
|
||||||
function registerIpcHandlers() {
|
function registerIpcHandlers() {
|
||||||
|
registerNotificationHandlers()
|
||||||
// 配置相关
|
// 配置相关
|
||||||
ipcMain.handle('config:get', async (_, key: string) => {
|
ipcMain.handle('config:get', async (_, key: string) => {
|
||||||
return configService?.get(key as any)
|
return configService?.get(key as any)
|
||||||
@@ -305,7 +621,7 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
// 监听下载进度
|
// 监听下载进度
|
||||||
autoUpdater.on('download-progress', (progress) => {
|
autoUpdater.on('download-progress', (progress) => {
|
||||||
win?.webContents.send('app:downloadProgress', progress.percent)
|
win?.webContents.send('app:downloadProgress', progress)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 下载完成后自动安装
|
// 下载完成后自动安装
|
||||||
@@ -321,6 +637,11 @@ function registerIpcHandlers() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('app:ignoreUpdate', async (_, version: string) => {
|
||||||
|
configService?.set('ignoredUpdateVersion', version)
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
|
||||||
// 窗口控制
|
// 窗口控制
|
||||||
ipcMain.on('window:minimize', (event) => {
|
ipcMain.on('window:minimize', (event) => {
|
||||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||||
@@ -355,6 +676,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 () => {
|
ipcMain.handle('dbpath:autoDetect', async () => {
|
||||||
return dbPathService.autoDetect()
|
return dbPathService.autoDetect()
|
||||||
@@ -364,6 +764,10 @@ function registerIpcHandlers() {
|
|||||||
return dbPathService.scanWxids(rootPath)
|
return dbPathService.scanWxids(rootPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('dbpath:scanWxidCandidates', async (_, rootPath: string) => {
|
||||||
|
return dbPathService.scanWxidCandidates(rootPath)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('dbpath:getDefault', async () => {
|
ipcMain.handle('dbpath:getDefault', async () => {
|
||||||
return dbPathService.getDefaultPath()
|
return dbPathService.getDefaultPath()
|
||||||
})
|
})
|
||||||
@@ -397,20 +801,41 @@ function registerIpcHandlers() {
|
|||||||
return chatService.enrichSessionsContactInfo(usernames)
|
return chatService.enrichSessionsContactInfo(usernames)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
|
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
|
||||||
return chatService.getMessages(sessionId, offset, limit)
|
return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
|
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
|
||||||
return chatService.getLatestMessages(sessionId, limit)
|
return chatService.getLatestMessages(sessionId, limit)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getContact', async (_, username: string) => {
|
ipcMain.handle('chat:getNewMessages', async (_, sessionId: string, minTime: number, limit?: number) => {
|
||||||
return chatService.getContact(username)
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
|
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) => {
|
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
|
||||||
@@ -438,14 +863,107 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getImageData(sessionId, msgId)
|
return chatService.getImageData(sessionId, msgId)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string) => {
|
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
|
||||||
return chatService.getVoiceData(sessionId, msgId)
|
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)
|
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:getSnsUsernames', async () => {
|
||||||
|
return snsService.getSnsUsernames()
|
||||||
|
})
|
||||||
|
|
||||||
|
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] }
|
||||||
|
})
|
||||||
|
|
||||||
// 私聊克隆
|
// 私聊克隆
|
||||||
|
|
||||||
|
|
||||||
@@ -460,28 +978,108 @@ function registerIpcHandlers() {
|
|||||||
return true
|
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) => {
|
ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => {
|
||||||
return exportService.exportSessions(sessionIds, outputDir, options)
|
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) => {
|
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||||
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
|
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
|
||||||
|
return contactExportService.exportContacts(outputDir, options)
|
||||||
|
})
|
||||||
|
|
||||||
// 数据分析相关
|
// 数据分析相关
|
||||||
ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
|
ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
|
||||||
return analyticsService.getOverallStatistics(force)
|
return analyticsService.getOverallStatistics(force)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => {
|
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number, beginTimestamp?: number, endTimestamp?: number) => {
|
||||||
return analyticsService.getContactRankings(limit)
|
return analyticsService.getContactRankings(limit, beginTimestamp, endTimestamp)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('analytics:getTimeDistribution', async () => {
|
ipcMain.handle('analytics:getTimeDistribution', async () => {
|
||||||
return analyticsService.getTimeDistribution()
|
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 () => {
|
ipcMain.handle('groupAnalytics:getGroupChats', async () => {
|
||||||
return groupAnalyticsService.getGroupChats()
|
return groupAnalyticsService.getGroupChats()
|
||||||
@@ -503,12 +1101,28 @@ function registerIpcHandlers() {
|
|||||||
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
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 () => {
|
ipcMain.handle('window:openAgreementWindow', async () => {
|
||||||
createAgreementWindow()
|
createAgreementWindow()
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 打开图片查看窗口
|
||||||
|
ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => {
|
||||||
|
createImageViewerWindow(imagePath)
|
||||||
|
})
|
||||||
|
|
||||||
// 完成引导,关闭引导窗口并显示主窗口
|
// 完成引导,关闭引导窗口并显示主窗口
|
||||||
ipcMain.handle('window:completeOnboarding', async () => {
|
ipcMain.handle('window:completeOnboarding', async () => {
|
||||||
try {
|
try {
|
||||||
@@ -606,6 +1220,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 }> }) => {
|
ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => {
|
||||||
try {
|
try {
|
||||||
const { baseDir, folderName, images } = payload
|
const { baseDir, folderName, images } = payload
|
||||||
@@ -651,6 +1333,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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主窗口引用
|
// 主窗口引用
|
||||||
@@ -669,7 +1369,16 @@ function checkForUpdatesOnStartup() {
|
|||||||
if (result && result.updateInfo) {
|
if (result && result.updateInfo) {
|
||||||
const currentVersion = app.getVersion()
|
const currentVersion = app.getVersion()
|
||||||
const latestVersion = result.updateInfo.version
|
const latestVersion = result.updateInfo.version
|
||||||
|
|
||||||
|
// 检查是否有新版本
|
||||||
if (latestVersion !== currentVersion && mainWindow) {
|
if (latestVersion !== currentVersion && mainWindow) {
|
||||||
|
// 检查该版本是否被用户忽略
|
||||||
|
const ignoredVersion = configService?.get('ignoredUpdateVersion')
|
||||||
|
if (ignoredVersion === latestVersion) {
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 通知渲染进程有新版本
|
// 通知渲染进程有新版本
|
||||||
mainWindow.webContents.send('app:updateAvailable', {
|
mainWindow.webContents.send('app:updateAvailable', {
|
||||||
version: latestVersion,
|
version: latestVersion,
|
||||||
@@ -702,6 +1411,17 @@ app.whenReady().then(() => {
|
|||||||
createOnboardingWindow()
|
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()
|
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')
|
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: {
|
dialog: {
|
||||||
@@ -29,7 +47,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||||
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
||||||
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
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))
|
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
|
||||||
return () => ipcRenderer.removeAllListeners('app:downloadProgress')
|
return () => ipcRenderer.removeAllListeners('app:downloadProgress')
|
||||||
},
|
},
|
||||||
@@ -42,7 +61,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// 日志
|
// 日志
|
||||||
log: {
|
log: {
|
||||||
getPath: () => ipcRenderer.invoke('log:getPath'),
|
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'),
|
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||||
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: {
|
dbPath: {
|
||||||
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
|
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
|
||||||
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
|
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
|
||||||
|
scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', rootPath),
|
||||||
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
|
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -94,19 +123,46 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
enrichSessionsContactInfo: (usernames: string[]) =>
|
||||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
||||||
getMessages: (sessionId: string, offset?: number, limit?: number) =>
|
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
|
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||||
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
|
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),
|
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
||||||
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', 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'),
|
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||||
close: () => ipcRenderer.invoke('chat:close'),
|
close: () => ipcRenderer.invoke('chat:close'),
|
||||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
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)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@@ -129,24 +185,44 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 视频
|
||||||
|
video: {
|
||||||
|
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
||||||
|
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||||
|
},
|
||||||
|
|
||||||
// 数据分析
|
// 数据分析
|
||||||
analytics: {
|
analytics: {
|
||||||
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
|
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
||||||
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
|
getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||||
|
ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp),
|
||||||
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
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) => {
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||||
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
|
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('analytics:progress')
|
return () => ipcRenderer.removeAllListeners('analytics:progress')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 缓存管理
|
||||||
|
cache: {
|
||||||
|
clearAnalytics: () => ipcRenderer.invoke('cache:clearAnalytics'),
|
||||||
|
clearImages: () => ipcRenderer.invoke('cache:clearImages'),
|
||||||
|
clearAll: () => ipcRenderer.invoke('cache:clearAll')
|
||||||
|
},
|
||||||
|
|
||||||
// 群聊分析
|
// 群聊分析
|
||||||
groupAnalytics: {
|
groupAnalytics: {
|
||||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
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),
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime)
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
|
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)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 年度报告
|
// 年度报告
|
||||||
@@ -160,12 +236,62 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
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: {
|
export: {
|
||||||
|
getExportStats: (sessionIds: string[], options: any) =>
|
||||||
|
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
||||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
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),
|
||||||
|
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
||||||
|
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')
|
||||||
|
},
|
||||||
|
|
||||||
|
// HTTP API 服务
|
||||||
|
http: {
|
||||||
|
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
||||||
|
stop: () => ipcRenderer.invoke('http:stop'),
|
||||||
|
status: () => ipcRenderer.invoke('http:status')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { readFile, writeFile } from 'fs/promises'
|
import { readFile, writeFile, rm } from 'fs/promises'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
export interface ChatStatistics {
|
export interface ChatStatistics {
|
||||||
totalMessages: number
|
totalMessages: number
|
||||||
@@ -30,6 +31,7 @@ export interface ContactRanking {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
wechatId?: string
|
||||||
messageCount: number
|
messageCount: number
|
||||||
sentCount: number
|
sentCount: number
|
||||||
receivedCount: number
|
receivedCount: number
|
||||||
@@ -46,6 +48,58 @@ class AnalyticsService {
|
|||||||
this.configService = new ConfigService()
|
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 {
|
private cleanAccountDirName(name: string): string {
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
if (!trimmed) return trimmed
|
if (!trimmed) return trimmed
|
||||||
@@ -54,7 +108,11 @@ class AnalyticsService {
|
|||||||
if (match) return match[1]
|
if (match) return match[1]
|
||||||
return trimmed
|
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 {
|
private isPrivateSession(username: string, cleanedWxid: string): boolean {
|
||||||
@@ -97,13 +155,15 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getPrivateSessions(
|
private async getPrivateSessions(
|
||||||
cleanedWxid: string
|
cleanedWxid: string,
|
||||||
|
excludedUsernames?: Set<string>
|
||||||
): Promise<{ usernames: string[]; numericIds: string[] }> {
|
): Promise<{ usernames: string[]; numericIds: string[] }> {
|
||||||
const sessionResult = await wcdbService.getSessions()
|
const sessionResult = await wcdbService.getSessions()
|
||||||
if (!sessionResult.success || !sessionResult.sessions) {
|
if (!sessionResult.success || !sessionResult.sessions) {
|
||||||
return { usernames: [], numericIds: [] }
|
return { usernames: [], numericIds: [] }
|
||||||
}
|
}
|
||||||
const rows = sessionResult.sessions as Record<string, any>[]
|
const rows = sessionResult.sessions as Record<string, any>[]
|
||||||
|
const excluded = excludedUsernames ?? this.getExcludedUsernamesSet()
|
||||||
|
|
||||||
const sample = rows[0]
|
const sample = rows[0]
|
||||||
void sample
|
void sample
|
||||||
@@ -124,7 +184,11 @@ class AnalyticsService {
|
|||||||
return { username, idValue }
|
return { username, idValue }
|
||||||
})
|
})
|
||||||
const usernames = sessions.map((s) => s.username)
|
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 privateUsernames = privateSessions.map((s) => s.username)
|
||||||
const numericIds = privateSessions
|
const numericIds = privateSessions
|
||||||
.map((s) => s.idValue)
|
.map((s) => s.idValue)
|
||||||
@@ -177,11 +241,18 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
|
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
|
||||||
const sample = sessionIds.slice(0, 5).join(',')
|
if (sessionIds.length === 0) {
|
||||||
return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}`
|
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> {
|
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 = {
|
const aggregate = {
|
||||||
total: 0,
|
total: 0,
|
||||||
sent: 0,
|
sent: 0,
|
||||||
@@ -206,8 +277,22 @@ class AnalyticsService {
|
|||||||
if (endTimestamp > 0 && createTime > endTimestamp) return
|
if (endTimestamp > 0 && createTime > endTimestamp) return
|
||||||
|
|
||||||
const localType = parseInt(row.local_type || row.type || '1', 10)
|
const localType = parseInt(row.local_type || row.type || '1', 10)
|
||||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? 0
|
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
|
||||||
const isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
|
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
|
aggregate.total += 1
|
||||||
sessionStat.total += 1
|
sessionStat.total += 1
|
||||||
@@ -324,7 +409,7 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getCacheFilePath(): string {
|
private getCacheFilePath(): string {
|
||||||
return join(app.getPath('userData'), 'analytics_cache.json')
|
return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
|
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
|
||||||
@@ -369,6 +454,65 @@ class AnalyticsService {
|
|||||||
void results
|
void results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }> {
|
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
@@ -433,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 {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
@@ -443,7 +591,7 @@ class AnalyticsService {
|
|||||||
return { success: false, error: '未找到消息会话' }
|
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) {
|
if (!result.success || !result.data) {
|
||||||
return { success: false, error: result.error || '聚合统计失败' }
|
return { success: false, error: result.error || '聚合统计失败' }
|
||||||
}
|
}
|
||||||
@@ -451,9 +599,10 @@ class AnalyticsService {
|
|||||||
const d = result.data
|
const d = result.data
|
||||||
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
|
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
|
||||||
const usernames = Object.keys(sessions)
|
const usernames = Object.keys(sessions)
|
||||||
const [displayNames, avatarUrls] = await Promise.all([
|
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
|
||||||
wcdbService.getDisplayNames(usernames),
|
wcdbService.getDisplayNames(usernames),
|
||||||
wcdbService.getAvatarUrls(usernames)
|
wcdbService.getAvatarUrls(usernames),
|
||||||
|
this.getAliasMap(usernames)
|
||||||
])
|
])
|
||||||
|
|
||||||
const rankings: ContactRanking[] = usernames
|
const rankings: ContactRanking[] = usernames
|
||||||
@@ -465,10 +614,13 @@ class AnalyticsService {
|
|||||||
const avatarUrl = avatarUrls.success && avatarUrls.map
|
const avatarUrl = avatarUrls.success && avatarUrls.map
|
||||||
? avatarUrls.map[username]
|
? avatarUrls.map[username]
|
||||||
: undefined
|
: undefined
|
||||||
|
const alias = aliasMap[username] || ''
|
||||||
|
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
|
||||||
return {
|
return {
|
||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
wechatId,
|
||||||
messageCount: stat.total,
|
messageCount: stat.total,
|
||||||
sentCount: stat.sent,
|
sentCount: stat.sent,
|
||||||
receivedCount: stat.received,
|
receivedCount: stat.received,
|
||||||
@@ -528,6 +680,18 @@ class AnalyticsService {
|
|||||||
return { success: false, error: String(e) }
|
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()
|
export const analyticsService = new AnalyticsService()
|
||||||
|
|||||||
@@ -69,6 +69,20 @@ export interface AnnualReportData {
|
|||||||
phrase: string
|
phrase: string
|
||||||
count: number
|
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 {
|
class AnnualReportService {
|
||||||
@@ -101,8 +115,9 @@ class AnnualReportService {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
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 ensureConnectedWithConfig(
|
private async ensureConnectedWithConfig(
|
||||||
@@ -178,11 +193,15 @@ class AnnualReportService {
|
|||||||
if (!raw) return ''
|
if (!raw) return ''
|
||||||
if (typeof raw === 'string') {
|
if (typeof raw === 'string') {
|
||||||
if (raw.length === 0) return ''
|
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')
|
const bytes = Buffer.from(raw, 'hex')
|
||||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
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 {
|
try {
|
||||||
const bytes = Buffer.from(raw, 'base64')
|
const bytes = Buffer.from(raw, 'base64')
|
||||||
return this.decodeBinaryContent(bytes)
|
return this.decodeBinaryContent(bytes)
|
||||||
@@ -397,8 +416,15 @@ class AnnualReportService {
|
|||||||
|
|
||||||
this.reportProgress('加载会话列表...', 15, onProgress)
|
this.reportProgress('加载会话列表...', 15, onProgress)
|
||||||
|
|
||||||
const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000)
|
const isAllTime = year <= 0
|
||||||
const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
|
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
|
let totalMessages = 0
|
||||||
const contactStats = new Map<string, { sent: number; received: number }>()
|
const contactStats = new Map<string, { sent: number; received: number }>()
|
||||||
@@ -420,7 +446,7 @@ class AnnualReportService {
|
|||||||
const CONVERSATION_GAP = 3600
|
const CONVERSATION_GAP = 3600
|
||||||
|
|
||||||
this.reportProgress('统计会话消息...', 20, onProgress)
|
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) {
|
if (!result.success || !result.data) {
|
||||||
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
|
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
|
||||||
}
|
}
|
||||||
@@ -473,8 +499,8 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
|
this.reportProgress('加载扩展统计...', 30, onProgress)
|
||||||
const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd)
|
const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
|
||||||
if (extras.success && extras.data) {
|
if (extras.success && extras.data) {
|
||||||
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
||||||
const extrasData = extras.data as any
|
const extrasData = extras.data as any
|
||||||
@@ -554,7 +580,7 @@ class AnnualReportService {
|
|||||||
// 为保持功能完整,我们进行深度集成的轻量遍历:
|
// 为保持功能完整,我们进行深度集成的轻量遍历:
|
||||||
for (let i = 0; i < sessionIds.length; i++) {
|
for (let i = 0; i < sessionIds.length; i++) {
|
||||||
const sessionId = sessionIds[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
|
if (!cursor.success || !cursor.cursor) continue
|
||||||
|
|
||||||
let lastDayIndex: number | null = null
|
let lastDayIndex: number | null = null
|
||||||
@@ -575,9 +601,22 @@ class AnnualReportService {
|
|||||||
if (!createTime) continue
|
if (!createTime) continue
|
||||||
|
|
||||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
|
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)
|
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)) {
|
if (!conversationStarts.has(sessionId)) {
|
||||||
conversationStarts.set(sessionId, { initiated: 0, received: 0 })
|
conversationStarts.set(sessionId, { initiated: 0, received: 0 })
|
||||||
@@ -689,7 +728,7 @@ class AnnualReportService {
|
|||||||
|
|
||||||
if (!streakComputedInLoop) {
|
if (!streakComputedInLoop) {
|
||||||
this.reportProgress('计算连续聊天...', 45, onProgress)
|
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) {
|
if (streakResult.days > longestStreakDays) {
|
||||||
longestStreakDays = streakResult.days
|
longestStreakDays = streakResult.days
|
||||||
longestStreakSessionId = streakResult.sessionId
|
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)
|
this.reportProgress('整理联系人信息...', 85, onProgress)
|
||||||
|
|
||||||
const contactIds = Array.from(contactStats.keys())
|
const contactIds = Array.from(contactStats.keys())
|
||||||
@@ -901,8 +976,130 @@ class AnnualReportService {
|
|||||||
.slice(0, 32)
|
.slice(0, 32)
|
||||||
.map(([phrase, count]) => ({ phrase, count }))
|
.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 = {
|
const reportData: AnnualReportData = {
|
||||||
year,
|
year: reportYear,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
totalFriends: contactStats.size,
|
totalFriends: contactStats.size,
|
||||||
coreFriends,
|
coreFriends,
|
||||||
@@ -915,7 +1112,9 @@ class AnnualReportService {
|
|||||||
mutualFriend,
|
mutualFriend,
|
||||||
socialInitiative,
|
socialInitiative,
|
||||||
responseSpeed,
|
responseSpeed,
|
||||||
topPhrases
|
topPhrases,
|
||||||
|
snsStats: snsStatsResult,
|
||||||
|
lostFriend
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: reportData }
|
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'
|
import Store from 'electron-store'
|
||||||
|
|
||||||
interface ConfigSchema {
|
interface ConfigSchema {
|
||||||
@@ -8,24 +10,60 @@ interface ConfigSchema {
|
|||||||
onboardingDone: boolean
|
onboardingDone: boolean
|
||||||
imageXorKey: number
|
imageXorKey: number
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
|
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||||||
|
|
||||||
// 缓存相关
|
// 缓存相关
|
||||||
cachePath: string
|
cachePath: string
|
||||||
|
|
||||||
lastOpenedDb: string
|
lastOpenedDb: string
|
||||||
lastSession: string
|
lastSession: string
|
||||||
|
|
||||||
// 界面相关
|
// 界面相关
|
||||||
theme: 'light' | 'dark' | 'system'
|
theme: 'light' | 'dark' | 'system'
|
||||||
themeId: string
|
themeId: string
|
||||||
language: string
|
language: string
|
||||||
logEnabled: boolean
|
logEnabled: boolean
|
||||||
llmModelPath: string
|
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 {
|
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() {
|
constructor() {
|
||||||
|
if (ConfigService.instance) {
|
||||||
|
return ConfigService.instance
|
||||||
|
}
|
||||||
|
ConfigService.instance = this
|
||||||
this.store = new Store<ConfigSchema>({
|
this.store = new Store<ConfigSchema>({
|
||||||
name: 'WeFlow-config',
|
name: 'WeFlow-config',
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -35,14 +73,34 @@ export class ConfigService {
|
|||||||
onboardingDone: false,
|
onboardingDone: false,
|
||||||
imageXorKey: 0,
|
imageXorKey: 0,
|
||||||
imageAesKey: '',
|
imageAesKey: '',
|
||||||
|
wxidConfigs: {},
|
||||||
cachePath: '',
|
cachePath: '',
|
||||||
|
|
||||||
lastOpenedDb: '',
|
lastOpenedDb: '',
|
||||||
lastSession: '',
|
lastSession: '',
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
themeId: 'cloud-dancer',
|
themeId: 'cloud-dancer',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
logEnabled: false,
|
logEnabled: false,
|
||||||
llmModelPath: ''
|
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: []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -55,6 +113,14 @@ export class ConfigService {
|
|||||||
this.store.set(key, value)
|
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 {
|
getAll(): ConfigSchema {
|
||||||
return this.store.store
|
return this.store.store
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
export interface ContactCacheEntry {
|
export interface ContactCacheEntry {
|
||||||
displayName?: string
|
displayName?: string
|
||||||
@@ -15,7 +16,7 @@ export class ContactCacheService {
|
|||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
? cacheBasePath
|
? cacheBasePath
|
||||||
: join(app.getPath('userData'), 'WeFlowCache')
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
this.cacheFilePath = join(basePath, 'contacts.json')
|
this.cacheFilePath = join(basePath, 'contacts.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
@@ -34,6 +35,14 @@ export class ContactCacheService {
|
|||||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
if (parsed && typeof parsed === 'object') {
|
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
|
this.cache = parsed
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -72,4 +81,13 @@ export class ContactCacheService {
|
|||||||
console.error('ContactCacheService: 保存缓存失败', 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 数据目录
|
// 微信4.x 数据目录
|
||||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||||
// 旧版微信数据目录
|
|
||||||
possiblePaths.push(join(home, 'Documents', 'WeChat Files'))
|
|
||||||
|
|
||||||
for (const path of possiblePaths) {
|
for (const path of possiblePaths) {
|
||||||
if (existsSync(path)) {
|
if (existsSync(path)) {
|
||||||
@@ -27,7 +26,7 @@ export class DbPathService {
|
|||||||
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
|
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有有效的账号目录
|
// 检查是否有有效的账号目录
|
||||||
const accounts = this.findAccountDirs(path)
|
const accounts = this.findAccountDirs(path)
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
@@ -47,10 +46,10 @@ export class DbPathService {
|
|||||||
*/
|
*/
|
||||||
findAccountDirs(rootPath: string): string[] {
|
findAccountDirs(rootPath: string): string[] {
|
||||||
const accounts: string[] = []
|
const accounts: string[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = readdirSync(rootPath)
|
const entries = readdirSync(rootPath)
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(rootPath, entry)
|
const entryPath = join(rootPath, entry)
|
||||||
let stat: ReturnType<typeof statSync>
|
let stat: ReturnType<typeof statSync>
|
||||||
@@ -59,7 +58,7 @@ export class DbPathService {
|
|||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
if (!this.isPotentialAccountName(entry)) continue
|
if (!this.isPotentialAccountName(entry)) continue
|
||||||
|
|
||||||
@@ -69,8 +68,8 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
|
|
||||||
return accounts
|
return accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +118,54 @@ 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 列表
|
* 扫描 wxid 列表
|
||||||
*/
|
*/
|
||||||
scanWxids(rootPath: string): WxidInfo[] {
|
scanWxids(rootPath: string): WxidInfo[] {
|
||||||
const wxids: WxidInfo[] = []
|
const wxids: WxidInfo[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.isAccountDir(rootPath)) {
|
if (this.isAccountDir(rootPath)) {
|
||||||
const wxid = basename(rootPath)
|
const wxid = basename(rootPath)
|
||||||
@@ -133,14 +174,14 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accounts = this.findAccountDirs(rootPath)
|
const accounts = this.findAccountDirs(rootPath)
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const fullPath = join(rootPath, account)
|
const fullPath = join(rootPath, account)
|
||||||
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
||||||
wxids.push({ wxid: account, modifiedTime })
|
wxids.push({ wxid: account, modifiedTime })
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
|
|
||||||
return wxids.sort((a, b) => {
|
return wxids.sort((a, b) => {
|
||||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
|
|||||||
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 { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { chatService } from './chatService'
|
||||||
|
import type { Message } from './chatService'
|
||||||
|
|
||||||
export interface GroupChatInfo {
|
export interface GroupChatInfo {
|
||||||
username: string
|
username: string
|
||||||
@@ -12,6 +17,10 @@ export interface GroupMember {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
nickname?: string
|
||||||
|
alias?: string
|
||||||
|
remark?: string
|
||||||
|
groupNickname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupMessageRank {
|
export interface GroupMessageRank {
|
||||||
@@ -41,14 +50,43 @@ class GroupAnalyticsService {
|
|||||||
this.configService = new ConfigService()
|
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 {
|
private cleanAccountDirName(name: string): string {
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
if (!trimmed) return trimmed
|
if (!trimmed) return trimmed
|
||||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
if (match) return match[1]
|
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 async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||||
@@ -65,6 +103,329 @@ class GroupAnalyticsService {
|
|||||||
return { success: true }
|
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 }> {
|
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
@@ -80,23 +441,38 @@ class GroupAnalyticsService {
|
|||||||
.map((row) => row.username || row.user_name || row.userName || '')
|
.map((row) => row.username || row.user_name || row.userName || '')
|
||||||
.filter((username) => username.includes('@chatroom'))
|
.filter((username) => username.includes('@chatroom'))
|
||||||
|
|
||||||
const [displayNames, avatarUrls, memberCounts] = await Promise.all([
|
const [memberCounts, contactInfo] = await Promise.all([
|
||||||
wcdbService.getDisplayNames(groupIds),
|
wcdbService.getGroupMemberCounts(groupIds),
|
||||||
wcdbService.getAvatarUrls(groupIds),
|
chatService.enrichSessionsContactInfo(groupIds)
|
||||||
wcdbService.getGroupMemberCounts(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[] = []
|
const groups: GroupChatInfo[] = []
|
||||||
for (const groupId of groupIds) {
|
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({
|
groups.push({
|
||||||
username: groupId,
|
username: groupId,
|
||||||
displayName: displayNames.success && displayNames.map
|
displayName,
|
||||||
? (displayNames.map[groupId] || groupId)
|
|
||||||
: groupId,
|
|
||||||
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
|
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
|
||||||
? memberCounts.map[groupId]
|
? memberCounts.map[groupId]
|
||||||
: 0,
|
: 0,
|
||||||
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined
|
avatarUrl
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,15 +493,88 @@ class GroupAnalyticsService {
|
|||||||
return { success: false, error: result.error || '获取群成员失败' }
|
return { success: false, error: result.error || '获取群成员失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = result.members as { username: string; avatarUrl?: string }[]
|
const members = result.members as Array<{
|
||||||
const usernames = members.map((m) => m.username)
|
username: string
|
||||||
const displayNames = await wcdbService.getDisplayNames(usernames)
|
avatarUrl?: string
|
||||||
|
originalName?: string
|
||||||
|
}>
|
||||||
|
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||||
|
|
||||||
const data: GroupMember[] = members.map((m) => ({
|
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||||
username: m.username,
|
|
||||||
displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username,
|
const contactMap = new Map<string, {
|
||||||
avatarUrl: m.avatarUrl
|
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 }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -248,6 +697,394 @@ class GroupAnalyticsService {
|
|||||||
return { success: false, error: String(e) }
|
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()
|
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()
|
||||||
|
|
||||||
@@ -2,12 +2,44 @@ import { app, BrowserWindow } from 'electron'
|
|||||||
import { basename, dirname, extname, join } from 'path'
|
import { basename, dirname, extname, join } from 'path'
|
||||||
import { pathToFileURL } from 'url'
|
import { pathToFileURL } from 'url'
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||||
import { writeFile } from 'fs/promises'
|
import { writeFile, rm, readdir } from 'fs/promises'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { Worker } from 'worker_threads'
|
import { Worker } from 'worker_threads'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
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 = {
|
type DecryptResult = {
|
||||||
success: boolean
|
success: boolean
|
||||||
localPath?: string
|
localPath?: string
|
||||||
@@ -36,14 +68,7 @@ export class ImageDecryptService {
|
|||||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
|
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
|
||||||
|
|
||||||
// 同时输出到控制台
|
// 只写入文件,不输出到控制台
|
||||||
if (meta) {
|
|
||||||
console.info(message, meta)
|
|
||||||
} else {
|
|
||||||
console.info(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写入日志文件
|
|
||||||
this.writeLog(logLine)
|
this.writeLog(logLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +108,6 @@ export class ImageDecryptService {
|
|||||||
for (const key of cacheKeys) {
|
for (const key of cacheKeys) {
|
||||||
const cached = this.resolvedCache.get(key)
|
const cached = this.resolvedCache.get(key)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
this.logInfo('缓存命中(从Map)', { key, path: cached, isThumb: this.isThumbnailPath(cached) })
|
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
const dataUrl = this.fileToDataUrl(cached)
|
||||||
const isThumb = this.isThumbnailPath(cached)
|
const isThumb = this.isThumbnailPath(cached)
|
||||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||||
@@ -103,7 +127,6 @@ export class ImageDecryptService {
|
|||||||
for (const key of cacheKeys) {
|
for (const key of cacheKeys) {
|
||||||
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.logInfo('缓存命中(文件系统)', { key, path: existing, isThumb: this.isThumbnailPath(existing) })
|
|
||||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
||||||
const dataUrl = this.fileToDataUrl(existing)
|
const dataUrl = this.fileToDataUrl(existing)
|
||||||
const isThumb = this.isThumbnailPath(existing)
|
const isThumb = this.isThumbnailPath(existing)
|
||||||
@@ -238,20 +261,39 @@ export class ImageDecryptService {
|
|||||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||||
|
|
||||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||||
const decrypted = await this.decryptDatAuto(datPath, xorKey, 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)
|
await writeFile(outputPath, decrypted)
|
||||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
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)
|
const isThumb = this.isThumbnailPath(datPath)
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||||
if (!isThumb) {
|
if (!isThumb) {
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
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)
|
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb }
|
||||||
@@ -338,9 +380,9 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
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(
|
private async resolveDatPath(
|
||||||
@@ -373,10 +415,16 @@ export class ImageDecryptService {
|
|||||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||||
return hardlinkPath
|
return hardlinkPath
|
||||||
}
|
}
|
||||||
// hardlink 找到的是缩略图,但要求高清图,直接返回 null,不再搜索
|
// hardlink 找到的是缩略图,但要求高清图
|
||||||
if (!allowThumbnail && isThumb) {
|
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
|
||||||
return null
|
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 })
|
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
|
||||||
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
|
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
|
||||||
@@ -389,9 +437,13 @@ export class ImageDecryptService {
|
|||||||
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
|
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
|
||||||
return 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 })
|
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||||
}
|
}
|
||||||
@@ -407,10 +459,13 @@ export class ImageDecryptService {
|
|||||||
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||||
return hardlinkPath
|
return hardlinkPath
|
||||||
}
|
}
|
||||||
// hardlink 找到的是缩略图,但要求高清图,直接返回 null
|
// hardlink 找到的是缩略图,但要求高清图
|
||||||
if (!allowThumbnail && isThumb) {
|
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
|
||||||
return null
|
if (hdPath) {
|
||||||
|
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
|
return hdPath
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||||
}
|
}
|
||||||
@@ -425,6 +480,9 @@ export class ImageDecryptService {
|
|||||||
const cached = this.resolvedCache.get(imageDatName)
|
const cached = this.resolvedCache.get(imageDatName)
|
||||||
if (cached && existsSync(cached)) {
|
if (cached && existsSync(cached)) {
|
||||||
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
|
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
|
||||||
|
// 缓存的是缩略图,尝试找高清图
|
||||||
|
const hdPath = this.findHdVariantInSameDir(cached)
|
||||||
|
if (hdPath) return hdPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,6 +777,17 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
const root = join(accountDir, 'msg', 'attach')
|
const root = join(accountDir, 'msg', 'attach')
|
||||||
if (!existsSync(root)) return null
|
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)
|
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
|
||||||
if (found) {
|
if (found) {
|
||||||
this.resolvedCache.set(key, found)
|
this.resolvedCache.set(key, found)
|
||||||
@@ -727,6 +796,134 @@ export class ImageDecryptService {
|
|||||||
return null
|
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(
|
private async searchDatFileInDir(
|
||||||
dirPath: string,
|
dirPath: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
@@ -857,42 +1054,71 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
|
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 normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
|
||||||
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||||
|
|
||||||
if (sessionId) {
|
// 遍历所有可能的缓存根路径
|
||||||
const sessionDir = join(root, this.sanitizeDirName(sessionId))
|
for (const root of allRoots) {
|
||||||
if (existsSync(sessionDir)) {
|
// 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg
|
||||||
try {
|
if (sessionId) {
|
||||||
const sessionEntries = readdirSync(sessionDir)
|
const sessionDir = join(root, this.sanitizeDirName(sessionId))
|
||||||
for (const entry of sessionEntries) {
|
if (existsSync(sessionDir)) {
|
||||||
const timeDir = join(sessionDir, entry)
|
try {
|
||||||
if (!this.isDirectory(timeDir)) continue
|
const dateDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||||
const hit = this.findCachedOutputInDir(timeDir, normalizedKey, extensions, preferHd)
|
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||||
if (hit) return hit
|
.map(d => d.name)
|
||||||
}
|
.sort()
|
||||||
} catch {
|
.reverse() // 最新的日期优先
|
||||||
// ignore
|
|
||||||
|
for (const dateDir of dateDirs) {
|
||||||
|
const imageDir = join(sessionDir, dateDir)
|
||||||
|
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg
|
// 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId)
|
||||||
const imageDir = join(root, normalizedKey)
|
try {
|
||||||
if (existsSync(imageDir)) {
|
const sessionDirs = readdirSync(root, { withFileTypes: true })
|
||||||
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
.filter(d => d.isDirectory())
|
||||||
if (hit) return hit
|
.map(d => d.name)
|
||||||
}
|
|
||||||
|
|
||||||
// 兼容旧的平铺结构
|
for (const session of sessionDirs) {
|
||||||
for (const ext of extensions) {
|
const sessionDir = join(root, session)
|
||||||
const candidate = join(root, `${cacheKey}${ext}`)
|
// 检查是否是日期目录结构
|
||||||
if (existsSync(candidate)) return candidate
|
try {
|
||||||
}
|
const subDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||||
for (const ext of extensions) {
|
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||||
const candidate = join(root, `${cacheKey}_t${ext}`)
|
.map(d => d.name)
|
||||||
if (existsSync(candidate)) return candidate
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const candidate = join(root, `${cacheKey}_t${ext}`)
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -904,6 +1130,18 @@ export class ImageDecryptService {
|
|||||||
extensions: string[],
|
extensions: string[],
|
||||||
preferHd: boolean
|
preferHd: boolean
|
||||||
): string | null {
|
): 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) {
|
for (const ext of extensions) {
|
||||||
if (preferHd) {
|
if (preferHd) {
|
||||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||||
@@ -1050,15 +1288,19 @@ export class ImageDecryptService {
|
|||||||
if (this.cacheIndexed) return
|
if (this.cacheIndexed) return
|
||||||
if (this.cacheIndexing) return this.cacheIndexing
|
if (this.cacheIndexing) return this.cacheIndexing
|
||||||
this.cacheIndexing = new Promise((resolve) => {
|
this.cacheIndexing = new Promise((resolve) => {
|
||||||
const root = this.getCacheRoot()
|
// 扫描所有可能的缓存根目录
|
||||||
try {
|
const allRoots = this.getAllCacheRoots()
|
||||||
this.indexCacheDir(root, 2, 0)
|
this.logInfo('开始索引缓存', { roots: allRoots.length })
|
||||||
} catch {
|
|
||||||
this.cacheIndexed = true
|
for (const root of allRoots) {
|
||||||
this.cacheIndexing = null
|
try {
|
||||||
resolve()
|
this.indexCacheDir(root, 3, 0) // 增加深度到3,支持 sessionId/YYYY-MM 结构
|
||||||
return
|
} catch (e) {
|
||||||
|
this.logError('索引目录失败', e, { root })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
|
||||||
this.cacheIndexed = true
|
this.cacheIndexed = true
|
||||||
this.cacheIndexing = null
|
this.cacheIndexing = null
|
||||||
resolve()
|
resolve()
|
||||||
@@ -1066,6 +1308,39 @@ export class ImageDecryptService {
|
|||||||
return this.cacheIndexing
|
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 {
|
private indexCacheDir(root: string, maxDepth: number, depth: number): void {
|
||||||
let entries: string[]
|
let entries: string[]
|
||||||
try {
|
try {
|
||||||
@@ -1406,6 +1681,159 @@ export class ImageDecryptService {
|
|||||||
return mostCommonKey
|
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> {
|
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
||||||
const version = this.getDatVersion(inputPath)
|
const version = this.getDatVersion(inputPath)
|
||||||
@@ -1430,6 +1858,71 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
await writeFile(outputPath, decrypted)
|
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()
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export class KeyService {
|
|||||||
private ReadProcessMemory: any = null
|
private ReadProcessMemory: any = null
|
||||||
private MEMORY_BASIC_INFORMATION: any = null
|
private MEMORY_BASIC_INFORMATION: any = null
|
||||||
private TerminateProcess: any = null
|
private TerminateProcess: any = null
|
||||||
|
private QueryFullProcessImageNameW: any = null
|
||||||
|
|
||||||
// User32
|
// User32
|
||||||
private EnumWindows: any = null
|
private EnumWindows: any = null
|
||||||
@@ -42,6 +43,7 @@ export class KeyService {
|
|||||||
private GetWindowThreadProcessId: any = null
|
private GetWindowThreadProcessId: any = null
|
||||||
private IsWindowVisible: any = null
|
private IsWindowVisible: any = null
|
||||||
private EnumChildWindows: any = null
|
private EnumChildWindows: any = null
|
||||||
|
private PostMessageW: any = null
|
||||||
private WNDENUMPROC_PTR: any = null
|
private WNDENUMPROC_PTR: any = null
|
||||||
|
|
||||||
// Advapi32
|
// Advapi32
|
||||||
@@ -56,6 +58,7 @@ export class KeyService {
|
|||||||
private readonly HKEY_LOCAL_MACHINE = 0x80000002
|
private readonly HKEY_LOCAL_MACHINE = 0x80000002
|
||||||
private readonly HKEY_CURRENT_USER = 0x80000001
|
private readonly HKEY_CURRENT_USER = 0x80000001
|
||||||
private readonly ERROR_SUCCESS = 0
|
private readonly ERROR_SUCCESS = 0
|
||||||
|
private readonly WM_CLOSE = 0x0010
|
||||||
|
|
||||||
private getDllPath(): string {
|
private getDllPath(): string {
|
||||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||||
@@ -113,13 +116,13 @@ export class KeyService {
|
|||||||
|
|
||||||
// 检查是否已经有本地副本,如果有就使用它
|
// 检查是否已经有本地副本,如果有就使用它
|
||||||
if (existsSync(localPath)) {
|
if (existsSync(localPath)) {
|
||||||
console.log(`使用已存在的 DLL 本地副本: ${localPath}`)
|
|
||||||
return localPath
|
return localPath
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`检测到网络路径 DLL,正在复制到本地: ${originalPath} -> ${localPath}`)
|
|
||||||
copyFileSync(originalPath, localPath)
|
copyFileSync(originalPath, localPath)
|
||||||
console.log('DLL 本地化成功')
|
|
||||||
return localPath
|
return localPath
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('DLL 本地化失败:', e)
|
console.error('DLL 本地化失败:', e)
|
||||||
@@ -143,7 +146,7 @@ export class KeyService {
|
|||||||
|
|
||||||
// 检查是否为网络路径,如果是则本地化
|
// 检查是否为网络路径,如果是则本地化
|
||||||
if (this.isNetworkPath(dllPath)) {
|
if (this.isNetworkPath(dllPath)) {
|
||||||
console.log('检测到网络路径,将进行本地化处理')
|
|
||||||
dllPath = this.localizeNetworkDll(dllPath)
|
dllPath = this.localizeNetworkDll(dllPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +197,7 @@ export class KeyService {
|
|||||||
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
|
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
|
||||||
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
|
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
|
||||||
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
|
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.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'))])
|
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
|
||||||
|
|
||||||
@@ -222,6 +226,7 @@ export class KeyService {
|
|||||||
|
|
||||||
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
|
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.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.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
||||||
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
|
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
|
||||||
@@ -310,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> {
|
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
|
// 1. Registry - Uninstall Keys
|
||||||
const uninstallKeys = [
|
const uninstallKeys = [
|
||||||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||||
@@ -396,16 +440,60 @@ export class KeyService {
|
|||||||
return fallbackPid ?? null
|
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 {
|
||||||
|
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 {
|
try {
|
||||||
await execFileAsync('taskkill', ['/F', '/IM', 'Weixin.exe'])
|
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
|
||||||
await execFileAsync('taskkill', ['/F', '/IM', 'WeChat.exe'])
|
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore if not found
|
// Ignore if not found
|
||||||
}
|
}
|
||||||
await new Promise(r => setTimeout(r, 1000))
|
|
||||||
|
return await this.waitForWeChatExit(5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Window Detection ---
|
// --- Window Detection ---
|
||||||
|
|
||||||
private getWindowTitle(hWnd: any): string {
|
private getWindowTitle(hWnd: any): string {
|
||||||
@@ -564,15 +652,24 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Restart WeChat
|
// 2. Restart WeChat
|
||||||
onStatus?.('正在重启微信以进行获取...', 0)
|
onStatus?.('正在关闭微信以进行获取...', 0)
|
||||||
await this.killWeChatProcesses()
|
const closed = await this.killWeChatProcesses()
|
||||||
|
if (!closed) {
|
||||||
|
const err = '无法自动关闭微信,请手动退出后重试'
|
||||||
|
onStatus?.(err, 2)
|
||||||
|
return { success: false, error: err }
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Launch
|
// 3. Launch
|
||||||
onStatus?.('正在启动微信...', 0)
|
onStatus?.('正在启动微信...', 0)
|
||||||
const sub = spawn(wechatPath, { detached: true, stdio: 'ignore' })
|
const sub = spawn(wechatPath, {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
cwd: dirname(wechatPath)
|
||||||
|
})
|
||||||
sub.unref()
|
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)
|
onStatus?.('等待微信界面就绪...', 0)
|
||||||
const pid = await this.waitForWeChatWindow()
|
const pid = await this.waitForWeChatWindow()
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
@@ -588,6 +685,11 @@ export class KeyService {
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||||
if (error) {
|
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 }
|
return { success: false, error }
|
||||||
}
|
}
|
||||||
const statusBuffer = Buffer.alloc(256)
|
const statusBuffer = Buffer.alloc(256)
|
||||||
@@ -836,16 +938,17 @@ export class KeyService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private isAlphaNumAscii(byte: number): boolean {
|
private isAlphaNumLower(byte: number): boolean {
|
||||||
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39)
|
// 只匹配小写字母 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
|
if (start + 64 > buf.length) return false
|
||||||
for (let j = 0; j < 32; j++) {
|
for (let j = 0; j < 32; j++) {
|
||||||
const charByte = buf[start + j * 2]
|
const charByte = buf[start + j * 2]
|
||||||
const nullByte = buf[start + j * 2 + 1]
|
const nullByte = buf[start + j * 2 + 1]
|
||||||
if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) {
|
if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -878,8 +981,6 @@ export class KeyService {
|
|||||||
const regions: Array<[number, number]> = []
|
const regions: Array<[number, number]> = []
|
||||||
const MEM_COMMIT = 0x1000
|
const MEM_COMMIT = 0x1000
|
||||||
const MEM_PRIVATE = 0x20000
|
const MEM_PRIVATE = 0x20000
|
||||||
const MEM_MAPPED = 0x40000
|
|
||||||
const MEM_IMAGE = 0x1000000
|
|
||||||
const PAGE_NOACCESS = 0x01
|
const PAGE_NOACCESS = 0x01
|
||||||
const PAGE_GUARD = 0x100
|
const PAGE_GUARD = 0x100
|
||||||
|
|
||||||
@@ -894,10 +995,9 @@ export class KeyService {
|
|||||||
const protect = info.Protect
|
const protect = info.Protect
|
||||||
const type = info.Type
|
const type = info.Type
|
||||||
const regionSize = Number(info.RegionSize)
|
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])
|
regions.push([Number(info.BaseAddress), regionSize])
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAddress = address + regionSize
|
const nextAddress = address + regionSize
|
||||||
@@ -926,86 +1026,51 @@ export class KeyService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const allRegions = this.getMemoryRegions(hProcess)
|
const allRegions = this.getMemoryRegions(hProcess)
|
||||||
|
const totalRegions = allRegions.length
|
||||||
|
let scannedCount = 0
|
||||||
|
let skippedCount = 0
|
||||||
|
|
||||||
// 优化1: 只保留小内存区域(< 10MB)- 密钥通常在小区域,可大幅减少扫描时间
|
for (const [baseAddress, regionSize] of allRegions) {
|
||||||
const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024)
|
// 跳过太大的内存区域(> 100MB)
|
||||||
|
if (regionSize > 100 * 1024 * 1024) {
|
||||||
|
skippedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域)
|
scannedCount++
|
||||||
const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1])
|
if (scannedCount % 10 === 0) {
|
||||||
|
onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`)
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
}
|
||||||
|
|
||||||
// 优化3: 计算总字节数用于精确进度报告
|
const memory = this.readProcessMemory(hProcess, baseAddress, regionSize)
|
||||||
const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0)
|
if (!memory) continue
|
||||||
let processedBytes = 0
|
|
||||||
|
|
||||||
// 优化4: 减小分块大小到 1MB(参考 wx_key 项目)
|
// 直接在原始字节中搜索32字节的小写字母数字序列
|
||||||
const chunkSize = 1 * 1024 * 1024
|
for (let i = 0; i < memory.length - 34; i++) {
|
||||||
const overlap = 65
|
// 检查前导字符(不是小写字母或数字)
|
||||||
let currentRegion = 0
|
if (this.isAlphaNumLower(memory[i])) continue
|
||||||
|
|
||||||
for (const [baseAddress, regionSize] of sortedRegions) {
|
// 检查接下来32个字节是否都是小写字母或数字
|
||||||
currentRegion++
|
let valid = true
|
||||||
const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0
|
for (let j = 1; j <= 32; j++) {
|
||||||
onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`)
|
if (!this.isAlphaNumLower(memory[i + j])) {
|
||||||
|
valid = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!valid) continue
|
||||||
|
|
||||||
// 每个区域都让出主线程,确保UI流畅
|
// 检查尾部字符(不是小写字母或数字)
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) {
|
||||||
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
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let dataToScan: Buffer
|
const keyBytes = memory.subarray(i + 1, i + 33)
|
||||||
if (trailing && trailing.length) {
|
if (this.verifyKey(ciphertext, keyBytes)) {
|
||||||
dataToScan = Buffer.concat([trailing, chunk])
|
return keyBytes.toString('ascii')
|
||||||
} else {
|
|
||||||
dataToScan = chunk
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < dataToScan.length - 34; i++) {
|
|
||||||
if (this.isAlphaNumAscii(dataToScan[i])) continue
|
|
||||||
let valid = true
|
|
||||||
for (let j = 1; j <= 32; j++) {
|
|
||||||
if (!this.isAlphaNumAscii(dataToScan[i + j])) {
|
|
||||||
valid = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) {
|
|
||||||
valid = false
|
|
||||||
}
|
|
||||||
if (valid) {
|
|
||||||
const keyBytes = dataToScan.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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新已处理字节数
|
|
||||||
processedBytes += regionSize
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
export interface SessionMessageCacheEntry {
|
export interface SessionMessageCacheEntry {
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
@@ -15,7 +16,7 @@ export class MessageCacheService {
|
|||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
? cacheBasePath
|
? cacheBasePath
|
||||||
: join(app.getPath('userData'), 'WeFlowCache')
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
this.cacheFilePath = join(basePath, 'session-messages.json')
|
this.cacheFilePath = join(basePath, 'session-messages.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
@@ -65,4 +66,13 @@ export class MessageCacheService {
|
|||||||
console.error('MessageCacheService: 保存缓存失败', error)
|
console.error('MessageCacheService: 保存缓存失败', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache = {}
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MessageCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
862
electron/services/snsService.ts
Normal file
862
electron/services/snsService.ts
Normal file
@@ -0,0 +1,862 @@
|
|||||||
|
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 getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||||
|
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
|
||||||
|
if (!result.success || !result.rows) {
|
||||||
|
// 尝试 userName 列名
|
||||||
|
const result2 = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT userName FROM SnsTimeLine')
|
||||||
|
if (!result2.success || !result2.rows) return { success: false, error: result.error || result2.error }
|
||||||
|
return { success: true, usernames: result2.rows.map((r: any) => r.userName).filter(Boolean) }
|
||||||
|
}
|
||||||
|
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { join, dirname, basename } from 'path'
|
import { join, dirname, basename } from 'path'
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
|
|
||||||
|
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||||
|
let lastDllInitError: string | null = null
|
||||||
|
export function getLastDllInitError(): string | null {
|
||||||
|
return lastDllInitError
|
||||||
|
}
|
||||||
|
|
||||||
export class WcdbCore {
|
export class WcdbCore {
|
||||||
private resourcesPath: string | null = null
|
private resourcesPath: string | null = null
|
||||||
private userDataPath: string | null = null
|
private userDataPath: string | null = null
|
||||||
@@ -14,12 +20,15 @@ export class WcdbCore {
|
|||||||
private currentWxid: string | null = null
|
private currentWxid: string | null = null
|
||||||
|
|
||||||
// 函数引用
|
// 函数引用
|
||||||
|
private wcdbInitProtection: any = null
|
||||||
private wcdbInit: any = null
|
private wcdbInit: any = null
|
||||||
private wcdbShutdown: any = null
|
private wcdbShutdown: any = null
|
||||||
private wcdbOpenAccount: any = null
|
private wcdbOpenAccount: any = null
|
||||||
private wcdbCloseAccount: any = null
|
private wcdbCloseAccount: any = null
|
||||||
private wcdbSetMyWxid: any = null
|
private wcdbSetMyWxid: any = null
|
||||||
private wcdbFreeString: any = null
|
private wcdbFreeString: any = null
|
||||||
|
private wcdbUpdateMessage: any = null
|
||||||
|
private wcdbDeleteMessage: any = null
|
||||||
private wcdbGetSessions: any = null
|
private wcdbGetSessions: any = null
|
||||||
private wcdbGetMessages: any = null
|
private wcdbGetMessages: any = null
|
||||||
private wcdbGetMessageCount: any = null
|
private wcdbGetMessageCount: any = null
|
||||||
@@ -28,6 +37,7 @@ export class WcdbCore {
|
|||||||
private wcdbGetGroupMemberCount: any = null
|
private wcdbGetGroupMemberCount: any = null
|
||||||
private wcdbGetGroupMemberCounts: any = null
|
private wcdbGetGroupMemberCounts: any = null
|
||||||
private wcdbGetGroupMembers: any = null
|
private wcdbGetGroupMembers: any = null
|
||||||
|
private wcdbGetGroupNicknames: any = null
|
||||||
private wcdbGetMessageTables: any = null
|
private wcdbGetMessageTables: any = null
|
||||||
private wcdbGetMessageMeta: any = null
|
private wcdbGetMessageMeta: any = null
|
||||||
private wcdbGetContact: any = null
|
private wcdbGetContact: any = null
|
||||||
@@ -36,7 +46,9 @@ export class WcdbCore {
|
|||||||
private wcdbGetAvailableYears: any = null
|
private wcdbGetAvailableYears: any = null
|
||||||
private wcdbGetAnnualReportStats: any = null
|
private wcdbGetAnnualReportStats: any = null
|
||||||
private wcdbGetAnnualReportExtras: any = null
|
private wcdbGetAnnualReportExtras: any = null
|
||||||
|
private wcdbGetDualReportStats: any = null
|
||||||
private wcdbGetGroupStats: any = null
|
private wcdbGetGroupStats: any = null
|
||||||
|
private wcdbGetMessageDates: any = null
|
||||||
private wcdbOpenMessageCursor: any = null
|
private wcdbOpenMessageCursor: any = null
|
||||||
private wcdbOpenMessageCursorLite: any = null
|
private wcdbOpenMessageCursorLite: any = null
|
||||||
private wcdbFetchMessageBatch: any = null
|
private wcdbFetchMessageBatch: any = null
|
||||||
@@ -48,6 +60,20 @@ export class WcdbCore {
|
|||||||
private wcdbGetMessageById: any = null
|
private wcdbGetMessageById: any = null
|
||||||
private wcdbGetEmoticonCdnUrl: any = null
|
private wcdbGetEmoticonCdnUrl: any = null
|
||||||
private wcdbGetDbStatus: any = null
|
private wcdbGetDbStatus: any = null
|
||||||
|
private wcdbGetVoiceData: any = null
|
||||||
|
private wcdbGetSnsTimeline: any = null
|
||||||
|
private wcdbGetSnsAnnualStats: any = null
|
||||||
|
private wcdbVerifyUser: any = null
|
||||||
|
private wcdbStartMonitorPipe: any = null
|
||||||
|
private wcdbStopMonitorPipe: any = null
|
||||||
|
private wcdbGetMonitorPipeName: any = null
|
||||||
|
|
||||||
|
private monitorPipeClient: any = null
|
||||||
|
private monitorCallback: ((type: string, json: string) => void) | null = null
|
||||||
|
private monitorReconnectTimer: any = null
|
||||||
|
private monitorPipePath: string = ''
|
||||||
|
|
||||||
|
|
||||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private logTimer: NodeJS.Timeout | null = null
|
private logTimer: NodeJS.Timeout | null = null
|
||||||
@@ -67,6 +93,113 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用命名管道 IPC
|
||||||
|
startMonitor(callback: (type: string, json: string) => void): boolean {
|
||||||
|
if (!this.wcdbStartMonitorPipe) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.monitorCallback = callback
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = this.wcdbStartMonitorPipe()
|
||||||
|
if (result !== 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 DLL 获取动态管道名(含 PID)
|
||||||
|
let pipePath = '\\\\.\\pipe\\weflow_monitor'
|
||||||
|
if (this.wcdbGetMonitorPipeName) {
|
||||||
|
try {
|
||||||
|
const namePtr = [null as any]
|
||||||
|
if (this.wcdbGetMonitorPipeName(namePtr) === 0 && namePtr[0]) {
|
||||||
|
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
|
||||||
|
this.wcdbFreeString(namePtr[0])
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectMonitorPipe(pipePath)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[wcdbCore] startMonitor exception:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接命名管道,支持断开后自动重连
|
||||||
|
private connectMonitorPipe(pipePath: string) {
|
||||||
|
this.monitorPipePath = pipePath
|
||||||
|
const net = require('net')
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.monitorCallback) return
|
||||||
|
|
||||||
|
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
|
||||||
|
})
|
||||||
|
|
||||||
|
let buffer = ''
|
||||||
|
this.monitorPipeClient.on('data', (data: Buffer) => {
|
||||||
|
buffer += data.toString('utf8')
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line)
|
||||||
|
this.monitorCallback?.(parsed.action || 'update', line)
|
||||||
|
} catch {
|
||||||
|
this.monitorCallback?.('update', line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.monitorPipeClient.on('error', () => {
|
||||||
|
})
|
||||||
|
|
||||||
|
this.monitorPipeClient.on('close', () => {
|
||||||
|
this.monitorPipeClient = null
|
||||||
|
this.scheduleReconnect()
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定时重连
|
||||||
|
private scheduleReconnect() {
|
||||||
|
if (this.monitorReconnectTimer || !this.monitorCallback) return
|
||||||
|
this.monitorReconnectTimer = setTimeout(() => {
|
||||||
|
this.monitorReconnectTimer = null
|
||||||
|
if (this.monitorCallback && !this.monitorPipeClient) {
|
||||||
|
this.connectMonitorPipe(this.monitorPipePath)
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
stopMonitor(): void {
|
||||||
|
this.monitorCallback = null
|
||||||
|
if (this.monitorReconnectTimer) {
|
||||||
|
clearTimeout(this.monitorReconnectTimer)
|
||||||
|
this.monitorReconnectTimer = null
|
||||||
|
}
|
||||||
|
if (this.monitorPipeClient) {
|
||||||
|
this.monitorPipeClient.destroy()
|
||||||
|
this.monitorPipeClient = null
|
||||||
|
}
|
||||||
|
if (this.wcdbStopMonitorPipe) {
|
||||||
|
this.wcdbStopMonitorPipe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留旧方法签名以兼容
|
||||||
|
setMonitor(callback: (type: string, json: string) => void): boolean {
|
||||||
|
return this.startMonitor(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 DLL 路径
|
* 获取 DLL 路径
|
||||||
*/
|
*/
|
||||||
@@ -101,19 +234,21 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isLogEnabled(): boolean {
|
private isLogEnabled(): boolean {
|
||||||
if (process.env.WEFLOW_WORKER === '1') return false
|
// 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志
|
||||||
if (process.env.WCDB_LOG_ENABLED === '1') return true
|
if (process.env.WCDB_LOG_ENABLED === '1') return true
|
||||||
return this.logEnabled
|
return this.logEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeLog(message: string, force = false): void {
|
private writeLog(message: string, force = false): void {
|
||||||
if (!force && !this.isLogEnabled()) return
|
if (!force && !this.isLogEnabled()) return
|
||||||
|
const line = `[${new Date().toISOString()}] ${message}`
|
||||||
|
// 同时输出到控制台和文件
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
||||||
const dir = join(base, 'logs')
|
const dir = join(base, 'logs')
|
||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
const line = `[${new Date().toISOString()}] ${message}\n`
|
appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' })
|
||||||
appendFileSync(join(dir, 'wcdb.log'), line, { encoding: 'utf8' })
|
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +341,65 @@ export class WcdbCore {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dllDir = dirname(dllPath)
|
||||||
|
const wcdbCorePath = join(dllDir, 'WCDB.dll')
|
||||||
|
if (existsSync(wcdbCorePath)) {
|
||||||
|
try {
|
||||||
|
this.koffi.load(wcdbCorePath)
|
||||||
|
this.writeLog('预加载 WCDB.dll 成功')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
|
||||||
|
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sdl2Path = join(dllDir, 'SDL2.dll')
|
||||||
|
if (existsSync(sdl2Path)) {
|
||||||
|
try {
|
||||||
|
this.koffi.load(sdl2Path)
|
||||||
|
this.writeLog('预加载 SDL2.dll 成功')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
|
||||||
|
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.lib = this.koffi.load(dllPath)
|
this.lib = this.koffi.load(dllPath)
|
||||||
|
|
||||||
|
// InitProtection (Added for security)
|
||||||
|
try {
|
||||||
|
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
|
||||||
|
|
||||||
|
// 尝试多个可能的资源路径
|
||||||
|
const resourcePaths = [
|
||||||
|
dllDir, // DLL 所在目录
|
||||||
|
dirname(dllDir), // 上级目录
|
||||||
|
this.resourcesPath, // 配置的资源路径
|
||||||
|
join(process.cwd(), 'resources') // 开发环境
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
let protectionOk = false
|
||||||
|
for (const resPath of resourcePaths) {
|
||||||
|
try {
|
||||||
|
//
|
||||||
|
protectionOk = this.wcdbInitProtection(resPath)
|
||||||
|
if (protectionOk) {
|
||||||
|
//
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!protectionOk) {
|
||||||
|
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
|
||||||
|
// this.writeLog('InitProtection 失败,继续运行')
|
||||||
|
// 不返回 false,允许继续运行
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// console.warn('InitProtection symbol not found:', e)
|
||||||
|
}
|
||||||
|
|
||||||
// 定义类型
|
// 定义类型
|
||||||
// wcdb_status wcdb_init()
|
// wcdb_status wcdb_init()
|
||||||
this.wcdbInit = this.lib.func('int32 wcdb_init()')
|
this.wcdbInit = this.lib.func('int32 wcdb_init()')
|
||||||
@@ -230,6 +422,20 @@ export class WcdbCore {
|
|||||||
this.wcdbSetMyWxid = null
|
this.wcdbSetMyWxid = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_update_message(wcdb_handle handle, const char* session_id, int64_t local_id, int32_t create_time, const char* new_content, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbUpdateMessage = this.lib.func('int32 wcdb_update_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* newContent, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbUpdateMessage = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_delete_message(wcdb_handle handle, const char* session_id, int64_t local_id, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbDeleteMessage = this.lib.func('int32 wcdb_delete_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* dbPathHint, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbDeleteMessage = null
|
||||||
|
}
|
||||||
|
|
||||||
// void wcdb_free_string(char* ptr)
|
// void wcdb_free_string(char* ptr)
|
||||||
this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)')
|
this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)')
|
||||||
|
|
||||||
@@ -261,6 +467,13 @@ export class WcdbCore {
|
|||||||
// wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json)
|
// wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json)
|
||||||
this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_group_nicknames(wcdb_handle handle, const char* chatroom_id, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetGroupNicknames = this.lib.func('int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetGroupNicknames = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json)
|
// wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json)
|
||||||
this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
||||||
|
|
||||||
@@ -297,6 +510,20 @@ export class WcdbCore {
|
|||||||
this.wcdbGetAnnualReportExtras = null
|
this.wcdbGetAnnualReportExtras = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_dual_report_stats(wcdb_handle handle, const char* session_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetDualReportStats = this.lib.func('int32 wcdb_get_dual_report_stats(int64 handle, const char* sessionId, int32 begin, int32 end, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetDualReportStats = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_logs(char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetLogs = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
// wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||||
try {
|
try {
|
||||||
this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)')
|
this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)')
|
||||||
@@ -304,6 +531,13 @@ export class WcdbCore {
|
|||||||
this.wcdbGetGroupStats = null
|
this.wcdbGetGroupStats = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_message_dates(wcdb_handle handle, const char* session_id, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetMessageDates = this.lib.func('int32 wcdb_get_message_dates(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetMessageDates = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_open_message_cursor(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor)
|
// wcdb_status wcdb_open_message_cursor(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor)
|
||||||
this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)')
|
this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)')
|
||||||
|
|
||||||
@@ -345,6 +579,49 @@ export class WcdbCore {
|
|||||||
this.wcdbGetDbStatus = null
|
this.wcdbGetDbStatus = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_voice_data(wcdb_handle handle, const char* session_id, int32_t create_time, int32_t local_id, int64_t svr_id, const char* candidates_json, char** out_hex)
|
||||||
|
try {
|
||||||
|
this.wcdbGetVoiceData = this.lib.func('int32 wcdb_get_voice_data(int64 handle, const char* sessionId, int32 createTime, int32 localId, int64 svrId, const char* candidatesJson, _Out_ void** outHex)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetVoiceData = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetSnsTimeline = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_sns_annual_stats(wcdb_handle handle, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetSnsAnnualStats = this.lib.func('int32 wcdb_get_sns_annual_stats(int64 handle, int32 begin, int32 end, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetSnsAnnualStats = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named pipe IPC for monitoring (replaces callback)
|
||||||
|
try {
|
||||||
|
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
||||||
|
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
|
||||||
|
this.wcdbGetMonitorPipeName = this.lib.func('int32 wcdb_get_monitor_pipe_name(_Out_ void** outName)')
|
||||||
|
this.writeLog('Monitor pipe functions loaded')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load monitor pipe functions:', e)
|
||||||
|
this.wcdbStartMonitorPipe = null
|
||||||
|
this.wcdbStopMonitorPipe = null
|
||||||
|
this.wcdbGetMonitorPipeName = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
|
||||||
|
try {
|
||||||
|
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbVerifyUser = null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
const initResult = this.wcdbInit()
|
const initResult = this.wcdbInit()
|
||||||
if (initResult !== 0) {
|
if (initResult !== 0) {
|
||||||
@@ -353,9 +630,20 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
|
lastDllInitError = null
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('WCDB 初始化异常:', e)
|
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||||
|
console.error('WCDB 初始化异常:', errorMsg)
|
||||||
|
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
|
||||||
|
lastDllInitError = errorMsg
|
||||||
|
// 检查是否是常见的 VC++ 运行时缺失错误
|
||||||
|
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
|
||||||
|
errorMsg.includes('The specified module could not be found')) {
|
||||||
|
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
|
||||||
|
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
|
||||||
|
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,10 +661,18 @@ export class WcdbCore {
|
|||||||
return { success: true, sessionCount: 0 }
|
return { success: true, sessionCount: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录当前活动连接,用于在测试结束后恢复(避免影响聊天页等正在使用的连接)
|
||||||
|
const hadActiveConnection = this.handle !== null
|
||||||
|
const prevPath = this.currentPath
|
||||||
|
const prevKey = this.currentKey
|
||||||
|
const prevWxid = this.currentWxid
|
||||||
|
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
const initOk = await this.initialize()
|
const initOk = await this.initialize()
|
||||||
if (!initOk) {
|
if (!initOk) {
|
||||||
return { success: false, error: 'WCDB 初始化失败' }
|
// 返回更详细的错误信息,帮助用户诊断问题
|
||||||
|
const detailedError = lastDllInitError || 'WCDB 初始化失败'
|
||||||
|
return { success: false, error: detailedError }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,8 +711,8 @@ export class WcdbCore {
|
|||||||
return { success: false, error: '无效的数据库句柄' }
|
return { success: false, error: '无效的数据库句柄' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试成功,使用 shutdown 清理所有资源(包括测试句柄)
|
// 测试成功:使用 shutdown 清理资源(包括测试句柄)
|
||||||
// 这会中断当前活动连接,但 testConnection 本应该是独立测试
|
// 注意:shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接
|
||||||
try {
|
try {
|
||||||
this.wcdbShutdown()
|
this.wcdbShutdown()
|
||||||
this.handle = null
|
this.handle = null
|
||||||
@@ -428,6 +724,15 @@ export class WcdbCore {
|
|||||||
console.error('关闭测试数据库时出错:', closeErr)
|
console.error('关闭测试数据库时出错:', closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 恢复测试前的连接(如果之前有活动连接)
|
||||||
|
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
|
||||||
|
try {
|
||||||
|
await this.open(prevPath, prevKey, prevWxid)
|
||||||
|
} catch {
|
||||||
|
// 恢复失败则保持断开,由调用方处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, sessionCount: 0 }
|
return { success: true, sessionCount: 0 }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('测试连接异常:', e)
|
console.error('测试连接异常:', e)
|
||||||
@@ -611,7 +916,7 @@ export class WcdbCore {
|
|||||||
try {
|
try {
|
||||||
this.wcdbSetMyWxid(this.handle, wxid)
|
this.wcdbSetMyWxid(this.handle, wxid)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('设置 wxid 失败:', e)
|
// 静默失败
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.isLogEnabled()) {
|
if (this.isLogEnabled()) {
|
||||||
@@ -710,6 +1015,37 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定时间之后的新消息
|
||||||
|
*/
|
||||||
|
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
|
||||||
|
const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0)
|
||||||
|
if (!openRes.success || !openRes.cursor) {
|
||||||
|
return { success: false, error: openRes.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = openRes.cursor
|
||||||
|
try {
|
||||||
|
// 2. 获取批次
|
||||||
|
const fetchRes = await this.fetchMessageBatch(cursor)
|
||||||
|
if (!fetchRes.success) {
|
||||||
|
return { success: false, error: fetchRes.error }
|
||||||
|
}
|
||||||
|
return { success: true, messages: fetchRes.rows }
|
||||||
|
} finally {
|
||||||
|
// 3. 关闭游标
|
||||||
|
await this.closeMessageCursor(cursor)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> {
|
async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -790,7 +1126,6 @@ export class WcdbCore {
|
|||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
console.warn(`[wcdbCore] getAvatarUrls DLL调用失败: result=${result}, usernames=${toFetch.length}`)
|
|
||||||
if (Object.keys(resultMap).length > 0) {
|
if (Object.keys(resultMap).length > 0) {
|
||||||
return { success: true, map: resultMap, error: `获取头像失败: ${result}` }
|
return { success: true, map: resultMap, error: `获取头像失败: ${result}` }
|
||||||
}
|
}
|
||||||
@@ -798,25 +1133,18 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) {
|
if (!jsonStr) {
|
||||||
console.error('[wcdbCore] getAvatarUrls 解析JSON失败')
|
|
||||||
return { success: false, error: '解析头像失败' }
|
return { success: false, error: '解析头像失败' }
|
||||||
}
|
}
|
||||||
const map = JSON.parse(jsonStr) as Record<string, string>
|
const map = JSON.parse(jsonStr) as Record<string, string>
|
||||||
let successCount = 0
|
|
||||||
let emptyCount = 0
|
|
||||||
for (const username of toFetch) {
|
for (const username of toFetch) {
|
||||||
const url = map[username]
|
const url = map[username]
|
||||||
if (url && url.trim()) {
|
if (url && url.trim()) {
|
||||||
resultMap[username] = url
|
resultMap[username] = url
|
||||||
// 只缓存有效的URL
|
// 只缓存有效的URL
|
||||||
this.avatarUrlCache.set(username, { url, updatedAt: now })
|
this.avatarUrlCache.set(username, { url, updatedAt: now })
|
||||||
successCount++
|
|
||||||
} else {
|
|
||||||
emptyCount++
|
|
||||||
// 不缓存空URL,下次可以重新尝试
|
|
||||||
}
|
}
|
||||||
|
// 不缓存空URL,下次可以重新尝试
|
||||||
}
|
}
|
||||||
console.log(`[wcdbCore] getAvatarUrls 成功: ${successCount}个, 空结果: ${emptyCount}个, 总请求: ${toFetch.length}`)
|
|
||||||
return { success: true, map: resultMap }
|
return { success: true, map: resultMap }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[wcdbCore] getAvatarUrls 异常:', e)
|
console.error('[wcdbCore] getAvatarUrls 异常:', e)
|
||||||
@@ -889,6 +1217,28 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
if (!this.wcdbGetGroupNicknames) {
|
||||||
|
return { success: false, error: '当前 DLL 版本不支持获取群昵称接口' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetGroupNicknames(this.handle, chatroomId, outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取群昵称失败: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析群昵称失败' }
|
||||||
|
const nicknames = JSON.parse(jsonStr)
|
||||||
|
return { success: true, nicknames }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
|
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -908,6 +1258,29 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!this.wcdbGetMessageDates) {
|
||||||
|
return { success: false, error: 'DLL 不支持 getMessageDates' }
|
||||||
|
}
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetMessageDates(this.handle, sessionId, outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
// 空结果也可能是正常的(无消息)
|
||||||
|
return { success: true, dates: [] }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析日期列表失败' }
|
||||||
|
const dates = JSON.parse(jsonStr)
|
||||||
|
return { success: true, dates }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
|
async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -1230,13 +1603,31 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||||
|
if (!this.lib) return { success: false, error: 'DLL 未加载' }
|
||||||
|
if (!this.wcdbGetLogs) return { success: false, error: '接口未就绪' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetLogs(outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取日志失败: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析日志失败' }
|
||||||
|
return { success: true, logs: JSON.parse(jsonStr) }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr)
|
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
return { success: false, error: `执行查询失败: ${result}` }
|
return { success: false, error: `执行查询失败: ${result}` }
|
||||||
}
|
}
|
||||||
@@ -1295,9 +1686,7 @@ export class WcdbCore {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
} async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
|
||||||
|
|
||||||
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
|
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
try {
|
try {
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
@@ -1313,5 +1702,187 @@ export class WcdbCore {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbGetVoiceData) return { success: false, error: '当前 DLL 版本不支持获取语音数据' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetVoiceData(this.handle, sessionId, createTime, localId, BigInt(svrId || 0), JSON.stringify(candidates), outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取语音数据失败: ${result}` }
|
||||||
|
}
|
||||||
|
const hex = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (hex === null) return { success: false, error: '解析语音数据失败' }
|
||||||
|
return { success: true, hex: hex || undefined }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Windows Hello
|
||||||
|
*/
|
||||||
|
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
const initOk = await this.initialize()
|
||||||
|
if (!initOk) return { success: false, error: 'WCDB 初始化失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.wcdbVerifyUser) {
|
||||||
|
return { success: false, error: 'Binding not found: VerifyUser' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
// Allocate buffer for result JSON
|
||||||
|
const maxLen = 1024
|
||||||
|
const outBuf = Buffer.alloc(maxLen)
|
||||||
|
|
||||||
|
// Call native function
|
||||||
|
const hwndVal = hwnd ? BigInt(hwnd) : BigInt(0)
|
||||||
|
this.wcdbVerifyUser(hwndVal, message || '', outBuf, maxLen)
|
||||||
|
|
||||||
|
// Parse result
|
||||||
|
const jsonStr = this.koffi.decode(outBuf, 'char', -1)
|
||||||
|
const result = JSON.parse(jsonStr)
|
||||||
|
resolve(result)
|
||||||
|
} catch (e) {
|
||||||
|
resolve({ success: false, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : ''
|
||||||
|
const result = this.wcdbGetSnsTimeline(
|
||||||
|
this.handle,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
usernamesJson,
|
||||||
|
keyword || '',
|
||||||
|
startTime || 0,
|
||||||
|
endTime || 0,
|
||||||
|
outPtr
|
||||||
|
)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取朋友圈失败: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析朋友圈数据失败' }
|
||||||
|
const timeline = JSON.parse(jsonStr)
|
||||||
|
return { success: true, timeline }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!this.wcdbGetSnsAnnualStats) {
|
||||||
|
return { success: false, error: 'wcdbGetSnsAnnualStats 未找到' }
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetSnsAnnualStats(this.handle, beginTimestamp, endTimestamp, outPtr)
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `getSnsAnnualStats failed: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: 'Failed to decode JSON' }
|
||||||
|
return { success: true, data: JSON.parse(jsonStr) }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('getSnsAnnualStats 异常:', e)
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
if (!this.wcdbGetDualReportStats) {
|
||||||
|
return { success: false, error: '未支持双人报告统计' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp)
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetDualReportStats(this.handle, sessionId, begin, end, outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取双人报告统计失败: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析双人报告统计失败' }
|
||||||
|
const data = JSON.parse(jsonStr)
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 修改消息内容
|
||||||
|
*/
|
||||||
|
async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized || !this.wcdbUpdateMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' }
|
||||||
|
if (!this.handle) return { success: false, error: 'Not Connected' }
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const outError = [null as any]
|
||||||
|
const result = this.wcdbUpdateMessage(this.handle, sessionId, localId, createTime, newContent, outError)
|
||||||
|
|
||||||
|
if (result !== 0) {
|
||||||
|
let errorMsg = 'Unknown Error'
|
||||||
|
if (outError[0]) {
|
||||||
|
errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)'
|
||||||
|
}
|
||||||
|
resolve({ success: false, error: errorMsg })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ success: true })
|
||||||
|
} catch (e) {
|
||||||
|
resolve({ success: false, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除消息
|
||||||
|
*/
|
||||||
|
async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized || !this.wcdbDeleteMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' }
|
||||||
|
if (!this.handle) return { success: false, error: 'Not Connected' }
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const outError = [null as any]
|
||||||
|
const result = this.wcdbDeleteMessage(this.handle, sessionId, localId, createTime || 0, dbPathHint || '', outError)
|
||||||
|
|
||||||
|
if (result !== 0) {
|
||||||
|
let errorMsg = 'Unknown Error'
|
||||||
|
if (outError[0]) {
|
||||||
|
errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)'
|
||||||
|
}
|
||||||
|
console.error(`[WcdbCore] deleteMessage fail: code=${result}, error=${errorMsg}`)
|
||||||
|
resolve({ success: false, error: errorMsg })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ success: true })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[WcdbCore] deleteMessage exception:`, e)
|
||||||
|
resolve({ success: false, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export class WcdbService {
|
|||||||
private resourcesPath: string | null = null
|
private resourcesPath: string | null = null
|
||||||
private userDataPath: string | null = null
|
private userDataPath: string | null = null
|
||||||
private logEnabled = false
|
private logEnabled = false
|
||||||
|
private monitorListener: ((type: string, json: string) => void) | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initWorker()
|
this.initWorker()
|
||||||
@@ -47,8 +48,16 @@ export class WcdbService {
|
|||||||
try {
|
try {
|
||||||
this.worker = new Worker(finalPath)
|
this.worker = new Worker(finalPath)
|
||||||
|
|
||||||
this.worker.on('message', (msg: WorkerMessage) => {
|
this.worker.on('message', (msg: any) => {
|
||||||
const { id, result, error } = msg
|
const { id, result, error, type, payload } = msg
|
||||||
|
|
||||||
|
if (type === 'monitor') {
|
||||||
|
if (this.monitorListener) {
|
||||||
|
this.monitorListener(payload.type, payload.json)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const p = this.pending.get(id)
|
const p = this.pending.get(id)
|
||||||
if (p) {
|
if (p) {
|
||||||
this.pending.delete(id)
|
this.pending.delete(id)
|
||||||
@@ -58,11 +67,25 @@ export class WcdbService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.worker.on('error', (err) => {
|
this.worker.on('error', (err) => {
|
||||||
|
// Worker 发生错误,需要 reject 所有 pending promises
|
||||||
console.error('WCDB Worker 错误:', err)
|
console.error('WCDB Worker 错误:', err)
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||||
|
for (const [id, p] of this.pending) {
|
||||||
|
p.reject(new Error(`Worker 错误: ${errorMsg}`))
|
||||||
|
}
|
||||||
|
this.pending.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.worker.on('exit', (code) => {
|
this.worker.on('exit', (code) => {
|
||||||
if (code !== 0) console.error(`WCDB Worker 异常退出,退出码: ${code}`)
|
// Worker 退出,需要 reject 所有 pending promises
|
||||||
|
if (code !== 0) {
|
||||||
|
console.error('WCDB Worker 异常退出,退出码:', code)
|
||||||
|
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||||
|
for (const [id, p] of this.pending) {
|
||||||
|
p.reject(new Error(errorMsg))
|
||||||
|
}
|
||||||
|
this.pending.clear()
|
||||||
|
}
|
||||||
this.worker = null
|
this.worker = null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,7 +96,7 @@ export class WcdbService {
|
|||||||
this.setLogEnabled(this.logEnabled)
|
this.setLogEnabled(this.logEnabled)
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('创建 WCDB Worker 失败:', e)
|
// Failed to create worker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +120,7 @@ export class WcdbService {
|
|||||||
setPaths(resourcesPath: string, userDataPath: string): void {
|
setPaths(resourcesPath: string, userDataPath: string): void {
|
||||||
this.resourcesPath = resourcesPath
|
this.resourcesPath = resourcesPath
|
||||||
this.userDataPath = userDataPath
|
this.userDataPath = userDataPath
|
||||||
this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(console.error)
|
this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,7 +128,15 @@ export class WcdbService {
|
|||||||
*/
|
*/
|
||||||
setLogEnabled(enabled: boolean): void {
|
setLogEnabled(enabled: boolean): void {
|
||||||
this.logEnabled = enabled
|
this.logEnabled = enabled
|
||||||
this.callWorker('setLogEnabled', { enabled }).catch(console.error)
|
this.callWorker('setLogEnabled', { enabled }).catch(() => { })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置数据库监控回调
|
||||||
|
*/
|
||||||
|
setMonitor(callback: (type: string, json: string) => void): void {
|
||||||
|
this.monitorListener = callback;
|
||||||
|
this.callWorker('setMonitor').catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,6 +204,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessages', { sessionId, limit, offset })
|
return this.callWorker('getMessages', { sessionId, limit, offset })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取新消息(增量刷新)
|
||||||
|
*/
|
||||||
|
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('getNewMessages', { sessionId, minTime, limit })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息总数
|
* 获取消息总数
|
||||||
*/
|
*/
|
||||||
@@ -215,6 +253,11 @@ export class WcdbService {
|
|||||||
return this.callWorker('getGroupMembers', { chatroomId })
|
return this.callWorker('getGroupMembers', { chatroomId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取群成员群名片昵称
|
||||||
|
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
|
||||||
|
return this.callWorker('getGroupNicknames', { chatroomId })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息表列表
|
* 获取消息表列表
|
||||||
*/
|
*/
|
||||||
@@ -229,6 +272,10 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageTableStats', { sessionId })
|
return this.callWorker('getMessageTableStats', { sessionId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
|
||||||
|
return this.callWorker('getMessageDates', { sessionId })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息元数据
|
* 获取消息元数据
|
||||||
*/
|
*/
|
||||||
@@ -271,6 +318,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd })
|
return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取双人报告统计数据
|
||||||
|
*/
|
||||||
|
async getDualReportStats(sessionId: string, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getDualReportStats', { sessionId, beginTimestamp, endTimestamp })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取群聊统计
|
* 获取群聊统计
|
||||||
*/
|
*/
|
||||||
@@ -341,6 +395,57 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageById', { sessionId, localId })
|
return this.callWorker('getMessageById', { sessionId, localId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取语音数据
|
||||||
|
*/
|
||||||
|
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
|
||||||
|
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取朋友圈
|
||||||
|
*/
|
||||||
|
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取朋友圈年度统计
|
||||||
|
*/
|
||||||
|
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 DLL 内部日志
|
||||||
|
*/
|
||||||
|
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||||
|
return this.callWorker('getLogs')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Windows Hello
|
||||||
|
*/
|
||||||
|
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('verifyUser', { message, hwnd })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改消息内容
|
||||||
|
*/
|
||||||
|
async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('updateMessage', { sessionId, localId, createTime, newContent })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除消息
|
||||||
|
*/
|
||||||
|
async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const wcdbService = new WcdbService()
|
export const wcdbService = new WcdbService()
|
||||||
|
|||||||
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[]>
|
||||||
|
}
|
||||||
@@ -19,6 +19,16 @@ if (parentPort) {
|
|||||||
core.setLogEnabled(payload.enabled)
|
core.setLogEnabled(payload.enabled)
|
||||||
result = { success: true }
|
result = { success: true }
|
||||||
break
|
break
|
||||||
|
case 'setMonitor':
|
||||||
|
core.setMonitor((type, json) => {
|
||||||
|
parentPort!.postMessage({
|
||||||
|
id: -1,
|
||||||
|
type: 'monitor',
|
||||||
|
payload: { type, json }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
result = { success: true }
|
||||||
|
break
|
||||||
case 'testConnection':
|
case 'testConnection':
|
||||||
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||||
break
|
break
|
||||||
@@ -38,6 +48,9 @@ if (parentPort) {
|
|||||||
case 'getMessages':
|
case 'getMessages':
|
||||||
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
|
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
|
||||||
break
|
break
|
||||||
|
case 'getNewMessages':
|
||||||
|
result = await core.getNewMessages(payload.sessionId, payload.minTime, payload.limit)
|
||||||
|
break
|
||||||
case 'getMessageCount':
|
case 'getMessageCount':
|
||||||
result = await core.getMessageCount(payload.sessionId)
|
result = await core.getMessageCount(payload.sessionId)
|
||||||
break
|
break
|
||||||
@@ -56,12 +69,18 @@ if (parentPort) {
|
|||||||
case 'getGroupMembers':
|
case 'getGroupMembers':
|
||||||
result = await core.getGroupMembers(payload.chatroomId)
|
result = await core.getGroupMembers(payload.chatroomId)
|
||||||
break
|
break
|
||||||
|
case 'getGroupNicknames':
|
||||||
|
result = await core.getGroupNicknames(payload.chatroomId)
|
||||||
|
break
|
||||||
case 'getMessageTables':
|
case 'getMessageTables':
|
||||||
result = await core.getMessageTables(payload.sessionId)
|
result = await core.getMessageTables(payload.sessionId)
|
||||||
break
|
break
|
||||||
case 'getMessageTableStats':
|
case 'getMessageTableStats':
|
||||||
result = await core.getMessageTableStats(payload.sessionId)
|
result = await core.getMessageTableStats(payload.sessionId)
|
||||||
break
|
break
|
||||||
|
case 'getMessageDates':
|
||||||
|
result = await core.getMessageDates(payload.sessionId)
|
||||||
|
break
|
||||||
case 'getMessageMeta':
|
case 'getMessageMeta':
|
||||||
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
||||||
break
|
break
|
||||||
@@ -80,6 +99,9 @@ if (parentPort) {
|
|||||||
case 'getAnnualReportExtras':
|
case 'getAnnualReportExtras':
|
||||||
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
|
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
|
||||||
break
|
break
|
||||||
|
case 'getDualReportStats':
|
||||||
|
result = await core.getDualReportStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
case 'getGroupStats':
|
case 'getGroupStats':
|
||||||
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
@@ -110,6 +132,31 @@ if (parentPort) {
|
|||||||
case 'getMessageById':
|
case 'getMessageById':
|
||||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||||
break
|
break
|
||||||
|
case '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:
|
default:
|
||||||
result = { success: false, error: `Unknown method: ${type}` }
|
result = { success: false, error: `Unknown method: ${type}` }
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
ManifestDPIAware true
|
||||||
|
|
||||||
!include "WordFunc.nsh"
|
!include "WordFunc.nsh"
|
||||||
|
!include "nsDialogs.nsh"
|
||||||
|
|
||||||
!macro customInit
|
!macro customInit
|
||||||
; 设置 DPI 感知
|
; 设置 DPI 感知
|
||||||
@@ -16,3 +17,49 @@ ManifestDPIAware true
|
|||||||
StrCpy $INSTDIR "$INSTDIR\WeFlow"
|
StrCpy $INSTDIR "$INSTDIR\WeFlow"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
!macroend
|
!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
|
||||||
|
|||||||
2089
package-lock.json
generated
2089
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
49
package.json
49
package.json
@@ -1,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": "cc",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/hicccc77/WeFlow"
|
||||||
|
},
|
||||||
|
"//": "二改不应改变此处的作者与应用信息",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "echo 'No native modules to rebuild'",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"rebuild": "echo 'No native modules to rebuild'",
|
"rebuild": "electron-rebuild",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -19,6 +24,8 @@
|
|||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^10.0.0",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
@@ -27,7 +34,12 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"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",
|
"wechat-emojis": "^1.0.2",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
@@ -50,6 +62,8 @@
|
|||||||
"appId": "com.WeFlow.app",
|
"appId": "com.WeFlow.app",
|
||||||
"publish": {
|
"publish": {
|
||||||
"provider": "github",
|
"provider": "github",
|
||||||
|
"owner": "hicccc77",
|
||||||
|
"repo": "WeFlow",
|
||||||
"releaseType": "release"
|
"releaseType": "release"
|
||||||
},
|
},
|
||||||
"productName": "WeFlow",
|
"productName": "WeFlow",
|
||||||
@@ -92,11 +106,38 @@
|
|||||||
{
|
{
|
||||||
"from": "public/icon.ico",
|
"from": "public/icon.ico",
|
||||||
"to": "icon.ico"
|
"to": "icon.ico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "electron/assets/wasm/",
|
||||||
|
"to": "assets/wasm/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"dist-electron/**/*"
|
"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;
|
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 {
|
.main-layout {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
267
src/App.tsx
267
src/App.tsx
@@ -10,43 +10,76 @@ import AnalyticsPage from './pages/AnalyticsPage'
|
|||||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||||
import AnnualReportPage from './pages/AnnualReportPage'
|
import AnnualReportPage from './pages/AnnualReportPage'
|
||||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||||
|
import DualReportPage from './pages/DualReportPage'
|
||||||
|
import DualReportWindow from './pages/DualReportWindow'
|
||||||
import AgreementPage from './pages/AgreementPage'
|
import AgreementPage from './pages/AgreementPage'
|
||||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||||
import DataManagementPage from './pages/DataManagementPage'
|
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import ExportPage from './pages/ExportPage'
|
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 { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||||
import * as configService from './services/config'
|
import * as configService from './services/config'
|
||||||
import { Download, X, Shield } from 'lucide-react'
|
import { Download, X, Shield } from 'lucide-react'
|
||||||
import './App.scss'
|
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() {
|
function App() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
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 { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||||
const isAgreementWindow = location.pathname === '/agreement-window'
|
const isAgreementWindow = location.pathname === '/agreement-window'
|
||||||
const isOnboardingWindow = location.pathname === '/onboarding-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 [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 [showAgreement, setShowAgreement] = useState(false)
|
||||||
const [agreementChecked, setAgreementChecked] = useState(false)
|
const [agreementChecked, setAgreementChecked] = useState(false)
|
||||||
const [agreementLoading, setAgreementLoading] = useState(true)
|
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(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
const body = document.body
|
const body = document.body
|
||||||
const appRoot = document.getElementById('app')
|
const appRoot = document.getElementById('app')
|
||||||
|
|
||||||
if (isOnboardingWindow) {
|
if (isOnboardingWindow || isNotificationWindow) {
|
||||||
root.style.background = 'transparent'
|
root.style.background = 'transparent'
|
||||||
body.style.background = 'transparent'
|
body.style.background = 'transparent'
|
||||||
body.style.overflow = 'hidden'
|
body.style.overflow = 'hidden'
|
||||||
@@ -67,15 +100,28 @@ function App() {
|
|||||||
|
|
||||||
// 应用主题
|
// 应用主题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
document.documentElement.setAttribute('data-mode', themeMode)
|
const applyMode = (mode: ThemeMode, systemDark?: boolean) => {
|
||||||
|
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
|
||||||
// 更新窗口控件颜色以适配主题
|
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||||
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
document.documentElement.setAttribute('data-mode', effectiveMode)
|
||||||
if (!isOnboardingWindow) {
|
const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
||||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
if (!isOnboardingWindow && !isNotificationWindow) {
|
||||||
|
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [currentTheme, themeMode, isOnboardingWindow])
|
|
||||||
|
applyMode(themeMode)
|
||||||
|
|
||||||
|
// 监听系统主题变化
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
if (useThemeStore.getState().themeMode === 'system') {
|
||||||
|
applyMode('system', e.matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
||||||
|
|
||||||
// 读取已保存的主题设置
|
// 读取已保存的主题设置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,7 +134,7 @@ function App() {
|
|||||||
if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) {
|
if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) {
|
||||||
setTheme(savedThemeId as ThemeId)
|
setTheme(savedThemeId as ThemeId)
|
||||||
}
|
}
|
||||||
if (savedThemeMode === 'light' || savedThemeMode === 'dark') {
|
if (savedThemeMode === 'light' || savedThemeMode === 'dark' || savedThemeMode === 'system') {
|
||||||
setThemeMode(savedThemeMode)
|
setThemeMode(savedThemeMode)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -145,26 +191,57 @@ function App() {
|
|||||||
|
|
||||||
// 监听启动时的更新通知
|
// 监听启动时的更新通知
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info) => {
|
if (isNotificationWindow) return // Skip updates in notification window
|
||||||
setUpdateInfo(info)
|
|
||||||
|
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||||
|
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
||||||
|
if (info) {
|
||||||
|
setUpdateInfo({ ...info, hasUpdate: true })
|
||||||
|
if (!useAppStore.getState().isLocked) {
|
||||||
|
setShowUpdateDialog(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
|
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||||
setDownloadProgress(progress)
|
setDownloadProgress(progress)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
removeUpdateListener?.()
|
removeUpdateListener?.()
|
||||||
removeProgressListener?.()
|
removeProgressListener?.()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||||
|
|
||||||
|
// 解锁后显示暂存的更新弹窗
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||||
|
setShowUpdateDialog(true)
|
||||||
|
}
|
||||||
|
}, [isLocked])
|
||||||
|
|
||||||
const handleUpdateNow = async () => {
|
const handleUpdateNow = async () => {
|
||||||
|
setShowUpdateDialog(false)
|
||||||
setIsDownloading(true)
|
setIsDownloading(true)
|
||||||
setDownloadProgress(0)
|
setDownloadProgress({ percent: 0 })
|
||||||
try {
|
try {
|
||||||
await window.electronAPI.app.downloadAndInstall()
|
await window.electronAPI.app.downloadAndInstall()
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error('更新失败:', e)
|
console.error('更新失败:', e)
|
||||||
setIsDownloading(false)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,34 +259,81 @@ function App() {
|
|||||||
const decryptKey = await configService.getDecryptKey()
|
const decryptKey = await configService.getDecryptKey()
|
||||||
const wxid = await configService.getMyWxid()
|
const wxid = await configService.getMyWxid()
|
||||||
const onboardingDone = await configService.getOnboardingDone()
|
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) {
|
if (!onboardingDone) {
|
||||||
await configService.setOnboardingDone(true)
|
await configService.setOnboardingDone(true)
|
||||||
}
|
}
|
||||||
console.log('检测到已保存的配置,正在自动连接...')
|
|
||||||
const result = await window.electronAPI.chat.connect()
|
const result = await window.electronAPI.chat.connect()
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log('自动连接成功')
|
|
||||||
setDbConnected(true, dbPath)
|
setDbConnected(true, dbPath)
|
||||||
// 如果当前在欢迎页,跳转到首页
|
// 如果当前在欢迎页,跳转到首页
|
||||||
if (window.location.hash === '#/' || window.location.hash === '') {
|
if (window.location.hash === '#/' || window.location.hash === '') {
|
||||||
navigate('/home')
|
navigate('/home')
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
} catch (e) {
|
||||||
console.error('自动连接出错:', e)
|
console.error('自动连接出错:', e)
|
||||||
|
// 捕获异常但不清除配置,防止循环重新引导
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
autoConnect()
|
autoConnect()
|
||||||
}, [isAgreementWindow, isOnboardingWindow, navigate, setDbConnected])
|
}, [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) {
|
if (isAgreementWindow) {
|
||||||
return <AgreementPage />
|
return <AgreementPage />
|
||||||
@@ -219,11 +343,49 @@ function App() {
|
|||||||
return <WelcomePage standalone />
|
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 (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
|
<div className="window-drag-region" aria-hidden="true" />
|
||||||
|
{isLocked && (
|
||||||
|
<LockScreen
|
||||||
|
onUnlock={() => setLocked(false)}
|
||||||
|
avatar={lockAvatar}
|
||||||
|
useHello={lockUseHello}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
|
|
||||||
|
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
||||||
|
<UpdateProgressCapsule />
|
||||||
|
|
||||||
|
{/* 全局会话监听与通知 */}
|
||||||
|
<GlobalSessionMonitor />
|
||||||
|
|
||||||
|
{/* 全局批量转写进度浮窗 */}
|
||||||
|
<BatchTranscribeGlobal />
|
||||||
|
|
||||||
{/* 用户协议弹窗 */}
|
{/* 用户协议弹窗 */}
|
||||||
{showAgreement && !agreementLoading && (
|
{showAgreement && !agreementLoading && (
|
||||||
<div className="agreement-overlay">
|
<div className="agreement-overlay">
|
||||||
@@ -245,13 +407,13 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="agreement-text">
|
<div className="agreement-text">
|
||||||
<h4>1. 数据安全</h4>
|
<h4>1. 数据安全</h4>
|
||||||
<p>本软件所有数据处理均在本地完成,不会上传任何聊天记录、个人信息到服务器。您的数据完全由您自己掌控。</p>
|
<p>本软件所有数据处理均在本地完成,不会上传任何聊天记录、个人信息到服务器。你的数据完全由你自己掌控。</p>
|
||||||
|
|
||||||
<h4>2. 使用须知</h4>
|
<h4>2. 使用须知</h4>
|
||||||
<p>本软件仅供个人学习研究使用,请勿用于任何非法用途。使用本软件解密、查看、分析的数据应为您本人所有或已获得授权。</p>
|
<p>本软件仅供个人学习研究使用,请勿用于任何非法用途。使用本软件解密、查看、分析的数据应为你本人所有或已获得授权。</p>
|
||||||
|
|
||||||
<h4>3. 免责声明</h4>
|
<h4>3. 免责声明</h4>
|
||||||
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保您的使用行为符合当地法律法规。</p>
|
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p>
|
||||||
|
|
||||||
<h4>4. 隐私保护</h4>
|
<h4>4. 隐私保护</h4>
|
||||||
<p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
<p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||||
@@ -275,31 +437,16 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 更新提示条 */}
|
{/* 更新提示对话框 */}
|
||||||
{updateInfo && (
|
<UpdateDialog
|
||||||
<div className="update-banner">
|
open={showUpdateDialog}
|
||||||
<span className="update-text">
|
updateInfo={updateInfo}
|
||||||
发现新版本 <strong>v{updateInfo.version}</strong>
|
onClose={() => setShowUpdateDialog(false)}
|
||||||
</span>
|
onUpdate={handleUpdateNow}
|
||||||
{isDownloading ? (
|
onIgnore={handleIgnoreUpdate}
|
||||||
<div className="update-progress">
|
isDownloading={isDownloading}
|
||||||
<div className="progress-bar">
|
progress={downloadProgress}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="main-layout">
|
<div className="main-layout">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -309,14 +456,20 @@ function App() {
|
|||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/home" element={<HomePage />} />
|
<Route path="/home" element={<HomePage />} />
|
||||||
<Route path="/chat" element={<ChatPage />} />
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
|
|
||||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
<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="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/export" element={<ExportPage />} />
|
<Route path="/export" element={<ExportPage />} />
|
||||||
|
<Route path="/sns" element={<SnsPage />} />
|
||||||
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
|
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</RouteGuard>
|
</RouteGuard>
|
||||||
</main>
|
</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'
|
||||||
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;
|
opacity: 0;
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -138,12 +139,25 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-grid {
|
.calendar-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-template-rows: auto repeat(6, 32px);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +170,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day {
|
.calendar-day {
|
||||||
aspect-ratio: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -211,4 +224,68 @@
|
|||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.year-month-picker {
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
.year-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.year-label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.month-btn {
|
||||||
|
padding: 8px 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||||
const [selectingStart, setSelectingStart] = useState(true)
|
const [selectingStart, setSelectingStart] = useState(true)
|
||||||
|
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 点击外部关闭
|
// 点击外部关闭
|
||||||
@@ -86,7 +87,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
|
|
||||||
const handleDateClick = (day: number) => {
|
const handleDateClick = (day: number) => {
|
||||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
|
||||||
if (selectingStart) {
|
if (selectingStart) {
|
||||||
onStartDateChange(dateStr)
|
onStartDateChange(dateStr)
|
||||||
if (endDate && dateStr > endDate) {
|
if (endDate && dateStr > endDate) {
|
||||||
@@ -125,8 +126,8 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
const isToday = (day: number) => {
|
const isToday = (day: number) => {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
return currentMonth.getFullYear() === today.getFullYear() &&
|
return currentMonth.getFullYear() === today.getFullYear() &&
|
||||||
currentMonth.getMonth() === today.getMonth() &&
|
currentMonth.getMonth() === today.getMonth() &&
|
||||||
day === today.getDate()
|
day === today.getDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderCalendar = () => {
|
const renderCalendar = () => {
|
||||||
@@ -185,12 +186,38 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
|
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className="month-year">{currentMonth.getFullYear()}年 {MONTH_NAMES[currentMonth.getMonth()]}</span>
|
<span className="month-year clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||||
|
{currentMonth.getFullYear()}年 {MONTH_NAMES[currentMonth.getMonth()]}
|
||||||
|
</span>
|
||||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
|
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{renderCalendar()}
|
{showYearMonthPicker ? (
|
||||||
|
<div className="year-month-picker">
|
||||||
|
<div className="year-selector">
|
||||||
|
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth()))}>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
</button>
|
||||||
|
<span className="year-label">{currentMonth.getFullYear()}年</span>
|
||||||
|
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth()))}>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="month-grid">
|
||||||
|
{MONTH_NAMES.map((name, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`month-btn ${i === currentMonth.getMonth() ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentMonth(new Date(currentMonth.getFullYear(), i))
|
||||||
|
setShowYearMonthPicker(false)
|
||||||
|
}}
|
||||||
|
>{name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : renderCalendar()}
|
||||||
<div className="selection-hint">
|
<div className="selection-hint">
|
||||||
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
|
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
375
src/components/JumpToDateDialog.scss
Normal file
375
src/components/JumpToDateDialog.scss
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
.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);
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-month-picker {
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
.year-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.year-label {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.month-btn {
|
||||||
|
padding: 10px 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/components/JumpToDateDialog.tsx
Normal file
235
src/components/JumpToDateDialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
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))
|
||||||
|
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||||
|
|
||||||
|
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 clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{showYearMonthPicker ? (
|
||||||
|
<div className="year-month-picker">
|
||||||
|
<div className="year-selector">
|
||||||
|
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="year-label">{calendarDate.getFullYear()}年</span>
|
||||||
|
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="month-grid">
|
||||||
|
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||||
|
setShowYearMonthPicker(false)
|
||||||
|
}}
|
||||||
|
>{name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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'
|
||||||
207
src/components/NotificationToast.scss
Normal file
207
src/components/NotificationToast.scss
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
.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);
|
||||||
|
|
||||||
|
// 浅色模式下使用不透明背景,避免透明窗口中通知过于透明
|
||||||
|
[data-mode="light"] &,
|
||||||
|
:not([data-mode]) & {
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 确保背景不透明
|
||||||
|
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
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不需要数据库连接的页面
|
const PUBLIC_ROUTES = ['/', '/home', '/settings']
|
||||||
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/data-management']
|
|
||||||
|
|
||||||
function RouteGuard({ children }: RouteGuardProps) {
|
function RouteGuard({ children }: RouteGuardProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
width: 200px;
|
width: 220px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
transition: width 0.25s ease;
|
transition: width 0.25s ease;
|
||||||
|
|
||||||
&.collapsed {
|
&.collapsed {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
|
|
||||||
.nav-menu,
|
.nav-menu,
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label {
|
.nav-label {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -32,14 +32,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 0 8px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 16px;
|
padding: 10px 12px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -49,13 +49,12 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -77,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 0 8px;
|
padding: 0 12px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -99,9 +98,9 @@
|
|||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } 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'
|
import './Sidebar.scss'
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
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) => {
|
const isActive = (path: string) => {
|
||||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||||
@@ -34,7 +42,25 @@ function Sidebar() {
|
|||||||
<span className="nav-label">聊天</span>
|
<span className="nav-label">聊天</span>
|
||||||
</NavLink>
|
</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
|
<NavLink
|
||||||
@@ -76,18 +102,21 @@ function Sidebar() {
|
|||||||
<span className="nav-label">导出</span>
|
<span className="nav-label">导出</span>
|
||||||
</NavLink>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<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
|
<NavLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
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('')} />
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
357
src/components/Sns/SnsMediaGrid.tsx
Normal file
357
src/components/Sns/SnsMediaGrid.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import React, { useState, useRef } from 'react'
|
||||||
|
import { Play, Lock, Download, ImageOff } 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
|
||||||
|
onMediaDeleted?: () => 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, onMediaDeleted }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [deleted, setDeleted] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const markDeleted = () => { setDeleted(true); onMediaDeleted?.() }
|
||||||
|
const retryCount = useRef(0)
|
||||||
|
const [retryKey, setRetryKey] = useState(0)
|
||||||
|
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
|
||||||
|
|
||||||
|
// 视频重试:失败时重试最多2次,耗尽才标记删除
|
||||||
|
const videoRetryOrDelete = () => {
|
||||||
|
if (retryCount.current < 2) {
|
||||||
|
retryCount.current++
|
||||||
|
setRetryKey(k => k + 1)
|
||||||
|
} else {
|
||||||
|
markDeleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
markDeleted()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,让 <video> 标签显示
|
||||||
|
if (!cancelled) setThumbSrc(localPath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoRetryOrDelete()
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingCover(false)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
if (!cancelled) {
|
||||||
|
if (isVideo) {
|
||||||
|
videoRetryOrDelete()
|
||||||
|
} else {
|
||||||
|
markDeleted()
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
setIsGeneratingCover(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [media, isVideo, isLive, targetUrl, retryKey])
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
return (
|
||||||
|
<div className="sns-media-item deleted-media">
|
||||||
|
<div className="deleted-placeholder">
|
||||||
|
<ImageOff size={24} />
|
||||||
|
<span>已删除</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : thumbSrc ? (
|
||||||
|
<img
|
||||||
|
src={thumbSrc}
|
||||||
|
className="media-image"
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => { if (!loading && !isVideo) markDeleted() }}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{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, onMediaDeleted }) => {
|
||||||
|
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} onMediaDeleted={onMediaDeleted} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
302
src/components/Sns/SnsPostItem.tsx
Normal file
302
src/components/Sns/SnsPostItem.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal, Trash2 } from 'lucide-react'
|
||||||
|
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
||||||
|
import { Avatar } from '../Avatar'
|
||||||
|
import { SnsMediaGrid } from './SnsMediaGrid'
|
||||||
|
import { getEmojiPath } from 'wechat-emojis'
|
||||||
|
|
||||||
|
// 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 => {
|
||||||
|
// type 3 是链接类型,直接用 media[0] 的 url 和 thumb
|
||||||
|
if (post.type === 3) {
|
||||||
|
const url = post.media[0]?.url || post.linkUrl
|
||||||
|
if (!url) return null
|
||||||
|
const titleCandidates = [
|
||||||
|
post.linkTitle || '',
|
||||||
|
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||||
|
post.contentDesc || ''
|
||||||
|
]
|
||||||
|
const title = titleCandidates
|
||||||
|
.map((v) => decodeHtmlEntities(v))
|
||||||
|
.find((v) => Boolean(v) && !/^https?:\/\//i.test(v))
|
||||||
|
return { url, title: title || '网页链接', thumb: post.media[0]?.thumb }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [mediaDeleted, setMediaDeleted] = useState(false)
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析微信表情
|
||||||
|
const renderTextWithEmoji = (text: string) => {
|
||||||
|
if (!text) return text
|
||||||
|
const parts = text.split(/\[(.*?)\]/g)
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
if (index % 2 === 1) {
|
||||||
|
// @ts-ignore
|
||||||
|
const path = getEmojiPath(part as any)
|
||||||
|
if (path) {
|
||||||
|
return <img key={index} src={`${import.meta.env.BASE_URL}${path}`} alt={`[${part}]`} className="inline-emoji" style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }} />
|
||||||
|
}
|
||||||
|
return `[${part}]`
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`sns-post-item ${mediaDeleted ? 'post-deleted' : ''}`}>
|
||||||
|
<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>
|
||||||
|
<div className="post-header-actions">
|
||||||
|
{mediaDeleted && (
|
||||||
|
<span className="post-deleted-badge">
|
||||||
|
<Trash2 size={12} />
|
||||||
|
<span>已删除</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDebug(post);
|
||||||
|
}} title="查看原始数据">
|
||||||
|
<Code size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.contentDesc && (
|
||||||
|
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showLinkCard && linkCard && (
|
||||||
|
<SnsLinkCard card={linkCard} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMediaGrid && (
|
||||||
|
<div className="post-media-container">
|
||||||
|
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
|
||||||
|
</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">{renderTextWithEmoji(c.content)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import './TitleBar.scss'
|
import './TitleBar.scss'
|
||||||
|
|
||||||
function TitleBar() {
|
interface TitleBarProps {
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleBar({ title }: TitleBarProps = {}) {
|
||||||
return (
|
return (
|
||||||
<div className="title-bar">
|
<div className="title-bar">
|
||||||
<img src="./logo.png" alt="WeFlow" className="title-logo" />
|
<img src="./logo.png" alt="WeFlow" className="title-logo" />
|
||||||
<span className="titles">WeFlow</span>
|
<span className="titles">{title || 'WeFlow'}</span>
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,40 +9,40 @@ function AgreementPage() {
|
|||||||
<div className="agreement-content">
|
<div className="agreement-content">
|
||||||
{/* 协议内容 - 请替换为完整的协议文本 */}
|
{/* 协议内容 - 请替换为完整的协议文本 */}
|
||||||
<h2>用户协议</h2>
|
<h2>用户协议</h2>
|
||||||
|
|
||||||
<h3>一、总则</h3>
|
<h3>一、总则</h3>
|
||||||
<p>欢迎使用WeFlow(WeFlow)软件。请在使用本软件前仔细阅读本协议。一旦您开始使用本软件,即表示您已充分理解并同意本协议的全部内容。</p>
|
<p>欢迎使用WeFlow(WeFlow)软件。请在使用本软件前仔细阅读本协议。一旦你开始使用本软件,即表示你已充分理解并同意本协议的全部内容。</p>
|
||||||
|
|
||||||
<h3>二、软件说明</h3>
|
<h3>二、软件说明</h3>
|
||||||
<p>WeFlow是一款本地化的微信聊天记录查看与分析工具,所有数据处理均在用户本地设备上完成。</p>
|
<p>WeFlow是一款本地化的微信聊天记录查看与分析工具,所有数据处理均在用户本地设备上完成。</p>
|
||||||
|
|
||||||
<h3>三、使用条款</h3>
|
<h3>三、使用条款</h3>
|
||||||
<p>1. 本软件仅供个人学习、研究使用,严禁用于任何商业用途或非法目的。</p>
|
<p>1. 本软件仅供个人学习、研究使用,严禁用于任何商业用途或非法目的。</p>
|
||||||
<p>2. 用户应确保所查看、分析的数据为本人所有或已获得合法授权。</p>
|
<p>2. 用户应确保所查看、分析的数据为本人所有或已获得合法授权。</p>
|
||||||
<p>3. 用户不得利用本软件侵犯他人隐私、窃取他人信息或从事其他违法活动。</p>
|
<p>3. 用户不得利用本软件侵犯他人隐私、窃取他人信息或从事其他违法活动。</p>
|
||||||
|
|
||||||
<h3>四、免责声明</h3>
|
<h3>四、免责声明</h3>
|
||||||
<p>1. 本软件按"现状"提供,开发者不对软件的适用性、可靠性、准确性作任何明示或暗示的保证。</p>
|
<p>1. 本软件按"现状"提供,开发者不对软件的适用性、可靠性、准确性作任何明示或暗示的保证。</p>
|
||||||
<p>2. 因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊或后果性损害,开发者不承担任何责任。</p>
|
<p>2. 因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊或后果性损害,开发者不承担任何责任。</p>
|
||||||
<p>3. 用户因违反本协议或相关法律法规而产生的一切后果由用户自行承担。</p>
|
<p>3. 用户因违反本协议或相关法律法规而产生的一切后果由用户自行承担。</p>
|
||||||
|
|
||||||
<h3>五、知识产权</h3>
|
<h3>五、知识产权</h3>
|
||||||
<p>本软件的所有权、知识产权及相关权益均归开发者所有。未经授权,不得复制、修改、传播本软件。</p>
|
<p>本软件的所有权、知识产权及相关权益均归开发者所有。未经授权,不得复制、修改、传播本软件。</p>
|
||||||
|
|
||||||
<h2>隐私政策</h2>
|
<h2>隐私政策</h2>
|
||||||
|
|
||||||
<h3>一、数据收集</h3>
|
<h3>一、数据收集</h3>
|
||||||
<p>本软件不收集、不上传、不存储任何用户个人信息或聊天数据。所有数据处理均在本地完成。</p>
|
<p>本软件不收集、不上传、不存储任何用户个人信息或聊天数据。所有数据处理均在本地完成。</p>
|
||||||
|
|
||||||
<h3>二、数据安全</h3>
|
<h3>二、数据安全</h3>
|
||||||
<p>您的聊天记录和个人数据完全存储在您的本地设备上,本软件不会将任何数据传输至外部服务器。</p>
|
<p>你的聊天记录和个人数据完全存储在你的本地设备上,本软件不会将任何数据传输至外部服务器。</p>
|
||||||
|
|
||||||
<h3>三、网络请求</h3>
|
<h3>三、网络请求</h3>
|
||||||
<p>本软件仅在检查更新时会访问更新服务器获取版本信息,不涉及任何用户数据的传输。</p>
|
<p>本软件仅在检查更新时会访问更新服务器获取版本信息,不涉及任何用户数据的传输。</p>
|
||||||
|
|
||||||
<h3>四、第三方服务</h3>
|
<h3>四、第三方服务</h3>
|
||||||
<p>本软件不集成任何第三方数据分析、广告或追踪服务。</p>
|
<p>本软件不集成任何第三方数据分析、广告或追踪服务。</p>
|
||||||
|
|
||||||
<p className="agreement-footer-text">最后更新日期:2025年1月</p>
|
<p className="agreement-footer-text">最后更新日期:2025年1月</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,6 +45,30 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary);
|
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 {
|
@keyframes spin {
|
||||||
@@ -292,4 +316,215 @@
|
|||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排除好友弹窗
|
||||||
|
.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,22 +1,52 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
import './AnalyticsPage.scss'
|
import './AnalyticsPage.scss'
|
||||||
import './DataManagementPage.scss'
|
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
|
|
||||||
|
interface ExcludeCandidate {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
wechatId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeUsername = (value: string) => value.trim().toLowerCase()
|
||||||
|
|
||||||
function AnalyticsPage() {
|
function AnalyticsPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [loadingStatus, setLoadingStatus] = useState('')
|
const [loadingStatus, setLoadingStatus] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [progress, setProgress] = useState(0)
|
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 themeMode = useThemeStore((state) => state.themeMode)
|
||||||
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
|
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore()
|
||||||
const loadData = async (forceRefresh = false) => {
|
|
||||||
|
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
|
if (isLoaded && !forceRefresh) return
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -55,16 +85,128 @@ function AnalyticsPage() {
|
|||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
if (removeListener) removeListener()
|
if (removeListener) removeListener()
|
||||||
}
|
}
|
||||||
}
|
}, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution])
|
||||||
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const force = location.state?.forceRefresh === true
|
const force = location.state?.forceRefresh === true
|
||||||
loadData(force)
|
loadData(force)
|
||||||
}, [location.state])
|
}, [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 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) => {
|
const formatDate = (timestamp: number | null) => {
|
||||||
if (!timestamp) return '-'
|
if (!timestamp) return '-'
|
||||||
@@ -231,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) {
|
if (error && !isLoaded) {
|
||||||
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
||||||
}
|
}
|
||||||
@@ -240,10 +398,16 @@ function AnalyticsPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>私聊分析</h1>
|
<h1>私聊分析</h1>
|
||||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
<div className="header-actions">
|
||||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||||
{isLoading ? '刷新中...' : '刷新'}
|
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||||
</button>
|
{isLoading ? '刷新中...' : '刷新'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={openExcludeDialog}>
|
||||||
|
<UserMinus size={16} />
|
||||||
|
排除好友{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-scroll">
|
<div className="page-scroll">
|
||||||
<section className="page-section">
|
<section className="page-section">
|
||||||
@@ -309,6 +473,89 @@ function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ function AnalyticsWelcomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1>私聊数据分析</h1>
|
<h1>私聊数据分析</h1>
|
||||||
<p>
|
<p>
|
||||||
WeFlow 可以分析您的聊天记录,生成详细的统计报表。<br />
|
WeFlow 可以分析你的聊天记录,生成详细的统计报表。<br />
|
||||||
您可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
|
你可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="action-cards">
|
<div className="action-cards">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 40px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icon {
|
.header-icon {
|
||||||
@@ -25,6 +26,63 @@
|
|||||||
margin: 0 0 48px;
|
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 {
|
.year-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -34,6 +92,12 @@
|
|||||||
margin-bottom: 48px;
|
margin-bottom: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-section .year-grid {
|
||||||
|
justify-content: flex-start;
|
||||||
|
max-width: none;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.year-card {
|
.year-card {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
@@ -104,6 +168,13 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.spin {
|
.spin {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Calendar, Loader2, Sparkles } from 'lucide-react'
|
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||||
import './AnnualReportPage.scss'
|
import './AnnualReportPage.scss'
|
||||||
|
|
||||||
|
type YearOption = number | 'all'
|
||||||
|
|
||||||
function AnnualReportPage() {
|
function AnnualReportPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [availableYears, setAvailableYears] = useState<number[]>([])
|
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 [isLoading, setIsLoading] = useState(true)
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
@@ -22,7 +25,8 @@ function AnnualReportPage() {
|
|||||||
const result = await window.electronAPI.annualReport.getAvailableYears()
|
const result = await window.electronAPI.annualReport.getAvailableYears()
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
setAvailableYears(result.data)
|
setAvailableYears(result.data)
|
||||||
setSelectedYear(result.data[0])
|
setSelectedYear((prev) => prev ?? result.data[0])
|
||||||
|
setSelectedPairYear((prev) => prev ?? result.data[0])
|
||||||
} else if (!result.success) {
|
} else if (!result.success) {
|
||||||
setLoadError(result.error || '加载年度数据失败')
|
setLoadError(result.error || '加载年度数据失败')
|
||||||
}
|
}
|
||||||
@@ -35,10 +39,11 @@ function AnnualReportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerateReport = async () => {
|
const handleGenerateReport = async () => {
|
||||||
if (!selectedYear) return
|
if (selectedYear === null) return
|
||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
try {
|
try {
|
||||||
navigate(`/annual-report/view?year=${selectedYear}`)
|
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||||
|
navigate(`/annual-report/view?year=${yearParam}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('生成报告失败:', e)
|
console.error('生成报告失败:', e)
|
||||||
} finally {
|
} 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-page">
|
<div className="annual-report-page">
|
||||||
@@ -67,42 +78,98 @@ function AnnualReportPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const yearOptions: YearOption[] = availableYears.length > 0
|
||||||
|
? ['all', ...availableYears]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const getYearLabel = (value: YearOption | null) => {
|
||||||
|
if (!value) return ''
|
||||||
|
return value === 'all' ? '全部时间' : `${value} 年`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-page">
|
<div className="annual-report-page">
|
||||||
<Sparkles size={32} className="header-icon" />
|
<Sparkles size={32} className="header-icon" />
|
||||||
<h1 className="page-title">年度报告</h1>
|
<h1 className="page-title">年度报告</h1>
|
||||||
<p className="page-desc">选择年份,生成你的微信聊天年度回顾</p>
|
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||||||
|
|
||||||
<div className="year-grid">
|
<div className="report-sections">
|
||||||
{availableYears.map(year => (
|
<section className="report-section">
|
||||||
<div
|
<div className="section-header">
|
||||||
key={year}
|
<div>
|
||||||
className={`year-card ${selectedYear === year ? 'selected' : ''}`}
|
<h2 className="section-title">总年度报告</h2>
|
||||||
onClick={() => setSelectedYear(year)}
|
<p className="section-desc">包含所有会话与消息</p>
|
||||||
>
|
</div>
|
||||||
<span className="year-number">{year}</span>
|
|
||||||
<span className="year-label">年</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<div className="year-grid">
|
||||||
className="generate-btn"
|
{yearOptions.map(option => (
|
||||||
onClick={handleGenerateReport}
|
<div
|
||||||
disabled={!selectedYear || isGenerating}
|
key={option}
|
||||||
>
|
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||||
{isGenerating ? (
|
onClick={() => setSelectedYear(option)}
|
||||||
<>
|
>
|
||||||
<Loader2 size={20} className="spin" />
|
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||||
<span>正在生成...</span>
|
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||||
</>
|
</div>
|
||||||
) : (
|
))}
|
||||||
<>
|
</div>
|
||||||
<Sparkles size={20} />
|
|
||||||
<span>生成 {selectedYear} 年度报告</span>
|
<button
|
||||||
</>
|
className="generate-btn"
|
||||||
)}
|
onClick={handleGenerateReport}
|
||||||
</button>
|
disabled={!selectedYear || isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={20} className="spin" />
|
||||||
|
<span>正在生成...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles size={20} />
|
||||||
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
.annual-report-window {
|
.annual-report-window {
|
||||||
// 使用全局主题变量,带回退值
|
// 使用全局主题变量,带回退值
|
||||||
--ar-primary: var(--primary, #07C160);
|
--ar-primary: var(--primary, #07C160);
|
||||||
|
--ar-primary-rgb: var(--primary-rgb, 7, 193, 96);
|
||||||
--ar-accent: var(--accent, #F2AA00);
|
--ar-accent: var(--accent, #F2AA00);
|
||||||
|
--ar-accent-rgb: 242, 170, 0;
|
||||||
--ar-text-main: var(--text-primary, #222222);
|
--ar-text-main: var(--text-primary, #222222);
|
||||||
--ar-text-sub: var(--text-secondary, #555555);
|
--ar-text-sub: var(--text-secondary, #555555);
|
||||||
--ar-bg-color: var(--bg-primary, #F9F8F6);
|
--ar-bg-color: var(--bg-primary, #F9F8F6);
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
|
|
||||||
// 背景装饰圆点 - 毛玻璃效果
|
// 背景装饰圆点 - 毛玻璃效果
|
||||||
.bg-decoration {
|
.bg-decoration {
|
||||||
position: absolute; // Changed from fixed
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
@@ -53,10 +55,10 @@
|
|||||||
.deco-circle {
|
.deco-circle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: rgba(var(--ar-primary-rgb), 0.03);
|
||||||
backdrop-filter: blur(40px);
|
backdrop-filter: blur(40px);
|
||||||
-webkit-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 {
|
&.c1 {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
@@ -243,6 +245,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.exporting-snapshot {
|
.exporting-snapshot {
|
||||||
|
|
||||||
.hero-title,
|
.hero-title,
|
||||||
.label-text,
|
.label-text,
|
||||||
.hero-desc,
|
.hero-desc,
|
||||||
@@ -253,6 +256,11 @@
|
|||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deco-circle {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
@@ -1279,3 +1287,135 @@
|
|||||||
color: var(--ar-text-sub) !important;
|
color: var(--ar-text-sub) !important;
|
||||||
text-align: center;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,20 @@ interface AnnualReportData {
|
|||||||
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
|
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
|
||||||
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
|
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
|
||||||
topPhrases?: { phrase: string; count: number }[]
|
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 {
|
interface SectionInfo {
|
||||||
@@ -95,148 +109,8 @@ const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 热力图组件
|
import Heatmap from '../components/ReportHeatmap'
|
||||||
const Heatmap = ({ data }: { data: number[][] }) => {
|
import WordCloud from '../components/ReportWordCloud'
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnnualReportWindow() {
|
function AnnualReportWindow() {
|
||||||
const [reportData, setReportData] = useState<AnnualReportData | null>(null)
|
const [reportData, setReportData] = useState<AnnualReportData | null>(null)
|
||||||
@@ -274,6 +148,8 @@ function AnnualReportWindow() {
|
|||||||
responseSpeed: useRef<HTMLElement>(null),
|
responseSpeed: useRef<HTMLElement>(null),
|
||||||
topPhrases: useRef<HTMLElement>(null),
|
topPhrases: useRef<HTMLElement>(null),
|
||||||
ranking: useRef<HTMLElement>(null),
|
ranking: useRef<HTMLElement>(null),
|
||||||
|
sns: useRef<HTMLElement>(null),
|
||||||
|
lostFriend: useRef<HTMLElement>(null),
|
||||||
ending: useRef<HTMLElement>(null),
|
ending: useRef<HTMLElement>(null),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +158,8 @@ function AnnualReportWindow() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||||
const yearParam = params.get('year')
|
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)
|
generateReport(year)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -337,6 +214,11 @@ function AnnualReportWindow() {
|
|||||||
return `${Math.round(seconds / 3600)}小时`
|
return `${Math.round(seconds / 3600)}小时`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
|
||||||
|
if (value === 0) return '历史以来'
|
||||||
|
return withSuffix ? `${value}年` : `${value}`
|
||||||
|
}
|
||||||
|
|
||||||
// 获取可用的板块列表
|
// 获取可用的板块列表
|
||||||
const getAvailableSections = (): SectionInfo[] => {
|
const getAvailableSections = (): SectionInfo[] => {
|
||||||
if (!reportData) return []
|
if (!reportData) return []
|
||||||
@@ -367,10 +249,16 @@ function AnnualReportWindow() {
|
|||||||
if (reportData.responseSpeed) {
|
if (reportData.responseSpeed) {
|
||||||
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.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) {
|
if (reportData.topPhrases && reportData.topPhrases.length > 0) {
|
||||||
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
|
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
|
||||||
}
|
}
|
||||||
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
|
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 })
|
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
||||||
return sections
|
return sections
|
||||||
}
|
}
|
||||||
@@ -595,7 +483,8 @@ function AnnualReportWindow() {
|
|||||||
|
|
||||||
const dataUrl = outputCanvas.toDataURL('image/png')
|
const dataUrl = outputCanvas.toDataURL('image/png')
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
|
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||||
|
link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png`
|
||||||
link.href = dataUrl
|
link.href = dataUrl
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
@@ -658,11 +547,12 @@ function AnnualReportWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setExportProgress('正在写入文件...')
|
setExportProgress('正在写入文件...')
|
||||||
|
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||||
const exportResult = await window.electronAPI.annualReport.exportImages({
|
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||||
baseDir: dirResult.filePaths[0],
|
baseDir: dirResult.filePaths[0],
|
||||||
folderName: `${reportData?.year}年度报告_分模块`,
|
folderName: `${yearFilePrefix}年度报告_分模块`,
|
||||||
images: exportedImages.map((img) => ({
|
images: exportedImages.map((img) => ({
|
||||||
name: `${reportData?.year}年度报告_${img.name}.png`,
|
name: `${yearFilePrefix}年度报告_${img.name}.png`,
|
||||||
dataUrl: img.data
|
dataUrl: img.data
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
@@ -733,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 topFriend = coreFriends[0]
|
||||||
const mostActive = getMostActiveTime(activityHeatmap.data)
|
const mostActive = getMostActiveTime(activityHeatmap.data)
|
||||||
const socialStoryName = topFriend?.displayName || '好友'
|
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 (
|
return (
|
||||||
<div className="annual-report-window">
|
<div className="annual-report-window">
|
||||||
@@ -827,7 +721,7 @@ function AnnualReportWindow() {
|
|||||||
{/* 封面 */}
|
{/* 封面 */}
|
||||||
<section className="section" ref={sectionRefs.cover}>
|
<section className="section" ref={sectionRefs.cover}>
|
||||||
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
|
<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" />
|
<hr className="divider" />
|
||||||
<p className="hero-desc">每一条消息背后<br />都藏着一段独特的故事</p>
|
<p className="hero-desc">每一条消息背后<br />都藏着一段独特的故事</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -869,7 +763,7 @@ function AnnualReportWindow() {
|
|||||||
{/* 月度好友 */}
|
{/* 月度好友 */}
|
||||||
<section className="section" ref={sectionRefs.monthlyFriends}>
|
<section className="section" ref={sectionRefs.monthlyFriends}>
|
||||||
<div className="label-text">月度好友</div>
|
<div className="label-text">月度好友</div>
|
||||||
<h2 className="hero-title">{year}年月度好友</h2>
|
<h2 className="hero-title">{monthlyTitle}</h2>
|
||||||
<p className="hero-desc">根据12个月的聊天习惯</p>
|
<p className="hero-desc">根据12个月的聊天习惯</p>
|
||||||
<div className="monthly-orbit">
|
<div className="monthly-orbit">
|
||||||
{monthlyTopFriends.map((m, i) => (
|
{monthlyTopFriends.map((m, i) => (
|
||||||
@@ -883,7 +777,7 @@ function AnnualReportWindow() {
|
|||||||
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="hero-desc">只要你想<br />我一直在</p>
|
<p className="hero-desc">你只管说<br />我一直在</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 双向奔赴 */}
|
{/* 双向奔赴 */}
|
||||||
@@ -983,15 +877,15 @@ function AnnualReportWindow() {
|
|||||||
{midnightKing && (
|
{midnightKing && (
|
||||||
<section className="section" ref={sectionRefs.midnightKing}>
|
<section className="section" ref={sectionRefs.midnightKing}>
|
||||||
<div className="label-text">深夜好友</div>
|
<div className="label-text">深夜好友</div>
|
||||||
<h2 className="hero-title">当城市睡去</h2>
|
<h2 className="hero-title">月光下的你</h2>
|
||||||
<p className="hero-desc">这一年你留下了</p>
|
<p className="hero-desc">在这一年你留下了</p>
|
||||||
<div className="big-stat">
|
<div className="big-stat">
|
||||||
<span className="stat-num">{midnightKing.count}</span>
|
<span className="stat-num">{midnightKing.count}</span>
|
||||||
<span className="stat-unit">条深夜的消息</span>
|
<span className="stat-unit">条深夜的消息</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="hero-desc">
|
<p className="hero-desc">
|
||||||
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你。
|
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你胡思乱想。
|
||||||
<br />你和Ta的对话占深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>。
|
<br />你和Ta的对话占你深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -1012,11 +906,46 @@ function AnnualReportWindow() {
|
|||||||
</section>
|
</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 && (
|
{topPhrases && topPhrases.length > 0 && (
|
||||||
<section className="section" ref={sectionRefs.topPhrases}>
|
<section className="section" ref={sectionRefs.topPhrases}>
|
||||||
<div className="label-text">年度常用语</div>
|
<div className="label-text">年度常用语</div>
|
||||||
<h2 className="hero-title">你在{year}年的年度常用语</h2>
|
<h2 className="hero-title">{phrasesTitle}</h2>
|
||||||
<p className="hero-desc">
|
<p className="hero-desc">
|
||||||
这一年,你说得最多的是:
|
这一年,你说得最多的是:
|
||||||
<br />
|
<br />
|
||||||
@@ -1029,6 +958,57 @@ function AnnualReportWindow() {
|
|||||||
</section>
|
</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}>
|
<section className="section" ref={sectionRefs.ranking}>
|
||||||
<div className="label-text">好友排行</div>
|
<div className="label-text">好友排行</div>
|
||||||
@@ -1085,7 +1065,7 @@ function AnnualReportWindow() {
|
|||||||
<br />愿新的一年,
|
<br />愿新的一年,
|
||||||
<br />所有期待,皆有回声。
|
<br />所有期待,皆有回声。
|
||||||
</p>
|
</p>
|
||||||
<div className="ending-year">{year}</div>
|
<div className="ending-year">{yearTitleShort}</div>
|
||||||
<div className="ending-brand">WEFLOW</div>
|
<div className="ending-brand">WEFLOW</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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
File diff suppressed because it is too large
Load Diff
685
src/pages/ContactsPage.scss
Normal file
685
src/pages/ContactsPage.scss
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
.contacts-page {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100% + 48px);
|
||||||
|
margin: -24px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// 左侧联系人面板
|
||||||
|
.contacts-panel {
|
||||||
|
width: 350px;
|
||||||
|
min-width: 350px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: contactsSpin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.export-mode-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 16px 20px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 20px 16px;
|
||||||
|
max-width: 300px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-count {
|
||||||
|
padding: 0 20px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 20px 12px;
|
||||||
|
|
||||||
|
.checkbox-item {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: contactsSpin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-remark {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-type {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.friend {
|
||||||
|
background: rgba(var(--primary-rgb), 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.group {
|
||||||
|
background: rgba(52, 211, 153, 0.1);
|
||||||
|
color: rgb(52, 211, 153);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.official {
|
||||||
|
background: rgba(251, 191, 36, 0.1);
|
||||||
|
color: rgb(251, 191, 36);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧详情面板内的联系人资料
|
||||||
|
.detail-profile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.detail-avatar {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
span { color: #fff; font-size: 28px; font-weight: 600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-info-list {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
min-width: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.goto-chat-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover { background: var(--primary-hover); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-detail {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧设置面板
|
||||||
|
.settings-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-select {
|
||||||
|
position: relative;
|
||||||
|
/* margin-bottom 移到 .setting-section */
|
||||||
|
|
||||||
|
.select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
/* Rounded pill shape */
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 20;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-path-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-folder-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-action {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: contactsSpin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contactsSpin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
504
src/pages/ContactsPage.tsx
Normal file
504
src/pages/ContactsPage.tsx
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
|
||||||
|
import { useChatStore } from '../stores/chatStore'
|
||||||
|
import './ContactsPage.scss'
|
||||||
|
|
||||||
|
interface ContactInfo {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
remark?: string
|
||||||
|
nickname?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactsPage() {
|
||||||
|
const [contacts, setContacts] = useState<ContactInfo[]>([])
|
||||||
|
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
|
||||||
|
const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(new Set())
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
|
const [contactTypes, setContactTypes] = useState({
|
||||||
|
friends: true,
|
||||||
|
groups: false,
|
||||||
|
officials: false,
|
||||||
|
deletedFriends: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 导出模式与查看详情
|
||||||
|
const [exportMode, setExportMode] = useState(false)
|
||||||
|
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { setCurrentSession } = useChatStore()
|
||||||
|
|
||||||
|
// 导出相关状态
|
||||||
|
const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json')
|
||||||
|
const [exportAvatars, setExportAvatars] = useState(true)
|
||||||
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
|
const [showFormatSelect, setShowFormatSelect] = useState(false)
|
||||||
|
const formatDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 加载通讯录
|
||||||
|
const loadContacts = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.chat.connect()
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('连接失败:', result.error)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const contactsResult = await window.electronAPI.chat.getContacts()
|
||||||
|
|
||||||
|
if (contactsResult.success && contactsResult.contacts) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 获取头像URL
|
||||||
|
const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
|
||||||
|
if (usernames.length > 0) {
|
||||||
|
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
||||||
|
if (avatarResult.success && avatarResult.contacts) {
|
||||||
|
contactsResult.contacts.forEach((contact: ContactInfo) => {
|
||||||
|
const enriched = avatarResult.contacts?.[contact.username]
|
||||||
|
if (enriched?.avatarUrl) {
|
||||||
|
contact.avatarUrl = enriched.avatarUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContacts(contactsResult.contacts)
|
||||||
|
setFilteredContacts(contactsResult.contacts)
|
||||||
|
setSelectedUsernames(new Set())
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载通讯录失败:', e)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadContacts()
|
||||||
|
}, [loadContacts])
|
||||||
|
|
||||||
|
// 搜索和类型过滤
|
||||||
|
useEffect(() => {
|
||||||
|
let filtered = contacts
|
||||||
|
|
||||||
|
// 类型过滤
|
||||||
|
filtered = filtered.filter(c => {
|
||||||
|
if (c.type === 'friend' && !contactTypes.friends) return false
|
||||||
|
if (c.type === 'group' && !contactTypes.groups) return false
|
||||||
|
if (c.type === 'official' && !contactTypes.officials) return false
|
||||||
|
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 关键词过滤
|
||||||
|
if (searchKeyword.trim()) {
|
||||||
|
const lower = searchKeyword.toLowerCase()
|
||||||
|
filtered = filtered.filter(c =>
|
||||||
|
c.displayName?.toLowerCase().includes(lower) ||
|
||||||
|
c.remark?.toLowerCase().includes(lower) ||
|
||||||
|
c.username.toLowerCase().includes(lower)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredContacts(filtered)
|
||||||
|
}, [searchKeyword, contacts, contactTypes])
|
||||||
|
|
||||||
|
// 点击外部关闭下拉菜单
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) {
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [showFormatSelect])
|
||||||
|
|
||||||
|
const selectedInFilteredCount = filteredContacts.reduce((count, contact) => {
|
||||||
|
return selectedUsernames.has(contact.username) ? count + 1 : count
|
||||||
|
}, 0)
|
||||||
|
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
|
||||||
|
|
||||||
|
const toggleContactSelected = (username: string, checked: boolean) => {
|
||||||
|
setSelectedUsernames(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (checked) {
|
||||||
|
next.add(username)
|
||||||
|
} else {
|
||||||
|
next.delete(username)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAllFilteredSelected = (checked: boolean) => {
|
||||||
|
setSelectedUsernames(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
filteredContacts.forEach(contact => {
|
||||||
|
if (checked) {
|
||||||
|
next.add(contact.username)
|
||||||
|
} else {
|
||||||
|
next.delete(contact.username)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAvatarLetter = (name: string) => {
|
||||||
|
if (!name) return '?'
|
||||||
|
return [...name][0] || '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContactTypeIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'friend': return <User size={14} />
|
||||||
|
case 'group': return <Users size={14} />
|
||||||
|
case 'official': return <MessageSquare size={14} />
|
||||||
|
case 'former_friend': return <UserX size={14} />
|
||||||
|
default: return <User size={14} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContactTypeName = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'friend': return '好友'
|
||||||
|
case 'group': return '群聊'
|
||||||
|
case 'official': return '公众号'
|
||||||
|
case 'former_friend': return '曾经的好友'
|
||||||
|
default: return '其他'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择导出文件夹
|
||||||
|
const selectExportFolder = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.dialog.openDirectory({
|
||||||
|
title: '选择导出位置'
|
||||||
|
})
|
||||||
|
if (result && !result.canceled && result.filePaths && result.filePaths.length > 0) {
|
||||||
|
setExportFolder(result.filePaths[0])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('选择文件夹失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始导出
|
||||||
|
const startExport = async () => {
|
||||||
|
if (!exportFolder) {
|
||||||
|
alert('请先选择导出位置')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedUsernames.size === 0) {
|
||||||
|
alert('请至少选择一个联系人')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExporting(true)
|
||||||
|
try {
|
||||||
|
const exportOptions = {
|
||||||
|
format: exportFormat,
|
||||||
|
exportAvatars,
|
||||||
|
contactTypes: {
|
||||||
|
friends: contactTypes.friends,
|
||||||
|
groups: contactTypes.groups,
|
||||||
|
officials: contactTypes.officials
|
||||||
|
},
|
||||||
|
selectedUsernames: Array.from(selectedUsernames)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert(`导出成功!共导出 ${result.successCount} 个联系人`)
|
||||||
|
} else {
|
||||||
|
alert(`导出失败:${result.error}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('导出失败:', e)
|
||||||
|
alert(`导出失败:${String(e)}`)
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportFormatOptions = [
|
||||||
|
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整联系人信息' },
|
||||||
|
{ value: 'csv', label: 'CSV (Excel)', desc: '电子表格格式,适合Excel查看' },
|
||||||
|
{ value: 'vcf', label: 'VCF (vCard)', desc: '标准名片格式,支持导入手机' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const getOptionLabel = (value: string) => {
|
||||||
|
return exportFormatOptions.find(opt => opt.value === value)?.label || value
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="contacts-page">
|
||||||
|
{/* 左侧:联系人列表 */}
|
||||||
|
<div className="contacts-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>通讯录</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
className={`icon-btn export-mode-btn ${exportMode ? 'active' : ''}`}
|
||||||
|
onClick={() => { setExportMode(!exportMode); setSelectedContact(null) }}
|
||||||
|
title={exportMode ? '退出导出模式' : '进入导出模式'}
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
</button>
|
||||||
|
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
|
||||||
|
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="search-bar">
|
||||||
|
<Search size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索联系人..."
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={e => setSearchKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchKeyword && (
|
||||||
|
<button className="clear-btn" onClick={() => setSearchKeyword('')}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="type-filters">
|
||||||
|
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
|
||||||
|
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
|
||||||
|
<User size={16} /><span>好友</span>
|
||||||
|
</label>
|
||||||
|
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
|
||||||
|
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
|
||||||
|
<Users size={16} /><span>群聊</span>
|
||||||
|
</label>
|
||||||
|
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
|
||||||
|
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
|
||||||
|
<MessageSquare size={16} /><span>公众号</span>
|
||||||
|
</label>
|
||||||
|
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
|
||||||
|
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
|
||||||
|
<UserX size={16} /><span>曾经的好友</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contacts-count">
|
||||||
|
共 {filteredContacts.length} 个联系人
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exportMode && (
|
||||||
|
<div className="selection-toolbar">
|
||||||
|
<label className="checkbox-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allFilteredSelected}
|
||||||
|
onChange={e => toggleAllFilteredSelected(e.target.checked)}
|
||||||
|
disabled={filteredContacts.length === 0}
|
||||||
|
/>
|
||||||
|
<span>全选当前筛选结果</span>
|
||||||
|
</label>
|
||||||
|
<span className="selection-count">已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length})</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="loading-state">
|
||||||
|
<Loader2 size={32} className="spin" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredContacts.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<span>暂无联系人</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="contacts-list">
|
||||||
|
{filteredContacts.map(contact => {
|
||||||
|
const isChecked = selectedUsernames.has(contact.username)
|
||||||
|
const isActive = !exportMode && selectedContact?.username === contact.username
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contact.username}
|
||||||
|
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (exportMode) {
|
||||||
|
toggleContactSelected(contact.username, !isChecked)
|
||||||
|
} else {
|
||||||
|
setSelectedContact(isActive ? null : contact)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exportMode && (
|
||||||
|
<label className="contact-select" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="contact-avatar">
|
||||||
|
{contact.avatarUrl ? (
|
||||||
|
<img src={contact.avatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<span>{getAvatarLetter(contact.displayName)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="contact-info">
|
||||||
|
<div className="contact-name">{contact.displayName}</div>
|
||||||
|
{contact.remark && contact.remark !== contact.displayName && (
|
||||||
|
<div className="contact-remark">备注: {contact.remark}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`contact-type ${contact.type}`}>
|
||||||
|
{getContactTypeIcon(contact.type)}
|
||||||
|
<span>{getContactTypeName(contact.type)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧面板 */}
|
||||||
|
{exportMode ? (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>导出设置</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-content">
|
||||||
|
<div className="setting-section">
|
||||||
|
<h3>导出格式</h3>
|
||||||
|
<div className="format-select" ref={formatDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => setShowFormatSelect(!showFormatSelect)}
|
||||||
|
>
|
||||||
|
<span className="select-value">{getOptionLabel(exportFormat)}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showFormatSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{exportFormatOptions.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${exportFormat === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setExportFormat(option.value as 'json' | 'csv' | 'vcf')
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
<span className="option-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-section">
|
||||||
|
<h3>导出选项</h3>
|
||||||
|
<label className="checkbox-item">
|
||||||
|
<input type="checkbox" checked={exportAvatars} onChange={e => setExportAvatars(e.target.checked)} />
|
||||||
|
<span>导出头像</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-section">
|
||||||
|
<h3>导出位置</h3>
|
||||||
|
<div className="export-path-display">
|
||||||
|
<FolderOpen size={16} />
|
||||||
|
<span>{exportFolder || '未设置'}</span>
|
||||||
|
</div>
|
||||||
|
<button className="select-folder-btn" onClick={selectExportFolder}>
|
||||||
|
<FolderOpen size={16} />
|
||||||
|
<span>选择导出目录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-action">
|
||||||
|
<button
|
||||||
|
className="export-btn"
|
||||||
|
onClick={startExport}
|
||||||
|
disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
|
||||||
|
>
|
||||||
|
{isExporting ? (
|
||||||
|
<><Loader2 size={18} className="spin" /><span>导出中...</span></>
|
||||||
|
) : (
|
||||||
|
<><Download size={18} /><span>开始导出</span></>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedContact ? (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>联系人详情</h2>
|
||||||
|
</div>
|
||||||
|
<div className="settings-content">
|
||||||
|
<div className="detail-profile">
|
||||||
|
<div className="detail-avatar">
|
||||||
|
{selectedContact.avatarUrl ? (
|
||||||
|
<img src={selectedContact.avatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<span>{getAvatarLetter(selectedContact.displayName)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="detail-name">{selectedContact.displayName}</div>
|
||||||
|
<div className={`contact-type ${selectedContact.type}`}>
|
||||||
|
{getContactTypeIcon(selectedContact.type)}
|
||||||
|
<span>{getContactTypeName(selectedContact.type)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-info-list">
|
||||||
|
<div className="detail-row"><span className="detail-label">用户名</span><span className="detail-value">{selectedContact.username}</span></div>
|
||||||
|
<div className="detail-row"><span className="detail-label">昵称</span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
|
||||||
|
{selectedContact.remark && <div className="detail-row"><span className="detail-label">备注</span><span className="detail-value">{selectedContact.remark}</span></div>}
|
||||||
|
<div className="detail-row"><span className="detail-label">类型</span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="goto-chat-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentSession(selectedContact.username)
|
||||||
|
navigate('/chat')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageCircle size={18} />
|
||||||
|
<span>查看聊天记录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="empty-detail">
|
||||||
|
<User size={48} />
|
||||||
|
<span>点击左侧联系人查看详情</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContactsPage
|
||||||
@@ -1,569 +0,0 @@
|
|||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.tab-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 9999px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--border-color);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-scroll {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-section {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 20px 24px;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-desc {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
.section-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: var(--border-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning {
|
|
||||||
background: #f59e0b;
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: #d97706;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.database-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.database-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: 12px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-icon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
&.decrypted {
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.needs-update {
|
|
||||||
background: #f59e0b;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.pending {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
.db-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-status {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&.decrypted {
|
|
||||||
background: rgba(34, 197, 94, 0.15);
|
|
||||||
color: #16a34a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.needs-update {
|
|
||||||
background: rgba(245, 158, 11, 0.15);
|
|
||||||
color: #b45309;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.pending {
|
|
||||||
background: rgba(234, 179, 8, 0.15);
|
|
||||||
color: #b45309;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 48px 20px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
&.hint {
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.unavailable-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 64px 20px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
&.hint {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-toast {
|
|
||||||
position: fixed;
|
|
||||||
top: 60px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
padding: 10px 24px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 14px;
|
|
||||||
z-index: 100;
|
|
||||||
animation: slideDown 0.3s ease;
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.error {
|
|
||||||
background: var(--danger);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-50%) translateY(-10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(-50%) translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.decrypt-progress-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
|
|
||||||
.progress-card {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 32px 40px;
|
|
||||||
min-width: 400px;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-file {
|
|
||||||
margin: 0 0 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
height: 8px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 9999px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--primary);
|
|
||||||
border-radius: 9999px;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-text {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 图片列表样式
|
|
||||||
.current-dir {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
.dir-label {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dir-path {
|
|
||||||
color: var(--text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
max-height: 500px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: 10px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
|
|
||||||
.decrypt-hint {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-icon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&.decrypted {
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.pending {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.img-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.img-name {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.img-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-tag {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
&.v3 {
|
|
||||||
background: rgba(59, 130, 246, 0.15);
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.v4 {
|
|
||||||
background: rgba(168, 85, 247, 0.15);
|
|
||||||
color: #a855f7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.img-size {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.decrypt-hint {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.more-hint {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 账号选择器
|
|
||||||
.account-selector {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.account-btn {
|
|
||||||
padding: 6px 14px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import * as configService from '../services/config'
|
|
||||||
import './DataManagementPage.scss'
|
|
||||||
|
|
||||||
function DataManagementPage() {
|
|
||||||
const [dbPath, setDbPath] = useState<string | null>(null)
|
|
||||||
const [wxid, setWxid] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadConfig = async () => {
|
|
||||||
const [path, id] = await Promise.all([
|
|
||||||
configService.getDbPath(),
|
|
||||||
configService.getMyWxid()
|
|
||||||
])
|
|
||||||
setDbPath(path)
|
|
||||||
setWxid(id)
|
|
||||||
}
|
|
||||||
loadConfig()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="page-header">
|
|
||||||
<h1>数据管理</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="page-scroll">
|
|
||||||
<section className="page-section">
|
|
||||||
<div className="section-header">
|
|
||||||
<div>
|
|
||||||
<h2>WCDB 直连模式</h2>
|
|
||||||
<p className="section-desc">
|
|
||||||
当前版本通过 WCDB DLL 直接读取加密数据库,不再需要解密流程。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="database-list">
|
|
||||||
<div className="database-item decrypted">
|
|
||||||
<div className="db-info">
|
|
||||||
<div className="db-name">
|
|
||||||
数据库目录
|
|
||||||
</div>
|
|
||||||
<div className="db-path">{dbPath || '未配置'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="database-item decrypted">
|
|
||||||
<div className="db-info">
|
|
||||||
<div className="db-name">
|
|
||||||
微信ID
|
|
||||||
</div>
|
|
||||||
<div className="db-path">{wxid || '未配置'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DataManagementPage
|
|
||||||
193
src/pages/DualReportPage.scss
Normal file
193
src/pages/DualReportPage.scss
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
.dual-report-page {
|
||||||
|
padding: 32px 28px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-report-page.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-badge {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&.top {
|
||||||
|
background: color-mix(in srgb, var(--primary) 18%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--primary-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0; // 允许 flex 子项缩小,配合 ellipsis
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary); // 从 tertiary 改为 secondary 以增强对比度
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary); // 改为 secondary
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary); // 使用主题色更醒目
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user