mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 15:45:51 +00:00
Compare commits
483 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3af530a15e | ||
|
|
6eae60ba54 | ||
|
|
2d711cca80 | ||
|
|
b274c99b91 | ||
|
|
4e66074603 | ||
|
|
42fbc479c9 | ||
|
|
f47610b98a | ||
|
|
cda45ce64c | ||
|
|
009a0d64b8 | ||
|
|
3afb0da017 | ||
|
|
bdc7f8a8a8 | ||
|
|
69a72f24ed | ||
|
|
ee0e71d50e | ||
|
|
39ba175651 | ||
|
|
731f022669 | ||
|
|
8d5527990b | ||
|
|
1ff536c2f7 | ||
|
|
27a18f1fc6 | ||
|
|
8921b90392 | ||
|
|
6cd925b062 | ||
|
|
28a344c63c | ||
|
|
a9b5fa0fae | ||
|
|
65212201ad | ||
|
|
d8c3ba34a8 | ||
|
|
63be8a35ad | ||
|
|
53ef4e11f9 | ||
|
|
c9a6451407 | ||
|
|
9d07a3a7bd | ||
|
|
bd4296199a | ||
|
|
b9e0535f63 | ||
|
|
6e371d75c8 | ||
|
|
7697f382ef | ||
|
|
4c551a8c91 | ||
|
|
56227c69f7 | ||
|
|
5acd3d86c8 | ||
|
|
d7f7139f36 | ||
|
|
1c5cacf1ce | ||
|
|
0a603116ef | ||
|
|
809b28a994 | ||
|
|
f7610a3570 | ||
|
|
bff9e87096 | ||
|
|
d872a8af20 | ||
|
|
4966cdbfac | ||
|
|
cb3eb83eac | ||
|
|
5daa7bce73 | ||
|
|
4e80f93b30 | ||
|
|
2776a1a5ce | ||
|
|
4f402d6a6a | ||
|
|
d544da6e4d | ||
|
|
0e42c19d3b | ||
|
|
0a2bd3d46a | ||
|
|
86d2dade11 | ||
|
|
19ab4409a3 | ||
|
|
3af90bd6e9 | ||
|
|
cfb0cff1a3 | ||
|
|
c08d6cd668 | ||
|
|
a53bebefd7 | ||
|
|
8e0c3306e8 | ||
|
|
f4364b3bd3 | ||
|
|
5b5757a1d7 | ||
|
|
f165f4911b | ||
|
|
b81b538d9a | ||
|
|
2f32c8e092 | ||
|
|
d101a79bf8 | ||
|
|
caea10a190 | ||
|
|
1445202a0d | ||
|
|
6f62ac4ffb | ||
|
|
e87bbe7223 | ||
|
|
e7e2c40c68 | ||
|
|
78b6d445fa | ||
|
|
c212355860 | ||
|
|
c223c20b38 | ||
|
|
524a9cda35 | ||
|
|
8bee66d404 | ||
|
|
142b00499b | ||
|
|
b0ea6c0ea2 | ||
|
|
67fd53a503 | ||
|
|
29529271fb | ||
|
|
4489a0f702 | ||
|
|
0d9fcc731a | ||
|
|
fe1c8862e6 | ||
|
|
092450e4f8 | ||
|
|
da054de708 | ||
|
|
dfac3c57cc | ||
|
|
0f3ecdc4ee | ||
|
|
24c47c3aa3 | ||
|
|
f53de9fe0b | ||
|
|
ee4d1f5689 | ||
|
|
122ad73c2e | ||
|
|
6ad1e6c3f3 | ||
|
|
c899fa72b8 | ||
|
|
e209bd68d4 | ||
|
|
96ac655d92 | ||
|
|
1d97b19774 | ||
|
|
11c7de3568 | ||
|
|
38d899fa94 | ||
|
|
37796c98c9 | ||
|
|
5b2e48badd | ||
|
|
627aa35f88 | ||
|
|
74e974177c | ||
|
|
6911132c95 | ||
|
|
f1affc7d63 | ||
|
|
bea824aee9 | ||
|
|
cbdd5b3a24 | ||
|
|
c02bc753fd | ||
|
|
d4915e1a62 | ||
|
|
2d4a5fc62f | ||
|
|
94a010c9b2 | ||
|
|
a6a202f6ff | ||
|
|
2127fdd443 | ||
|
|
3b3fd8b35c | ||
|
|
95d0937015 | ||
|
|
b070b4f659 | ||
|
|
a8c05fd26c | ||
|
|
ecd64f62bc | ||
|
|
5affd4e57b | ||
|
|
76d69ab7dd | ||
|
|
1d1b38210a | ||
|
|
836032d93e | ||
|
|
dc3e285917 | ||
|
|
e54eb8fea2 | ||
|
|
177dbaa5ff | ||
|
|
1d08ab945d | ||
|
|
10ce7d772c | ||
|
|
e1a23ac606 | ||
|
|
439259ec57 | ||
|
|
a0dda0b866 | ||
|
|
6913defc12 | ||
|
|
f3e2fdd4fc | ||
|
|
5c44b35045 | ||
|
|
cebb6426f8 | ||
|
|
f05e50e63e | ||
|
|
f8ef3f18ff | ||
|
|
47dbc540ac | ||
|
|
766d5ed2af | ||
|
|
783b408611 | ||
|
|
24c91269a0 | ||
|
|
e786026049 | ||
|
|
566b0cf6e5 | ||
|
|
b17844e837 | ||
|
|
5c93c4db57 | ||
|
|
57e8a96a4a | ||
|
|
438581834e | ||
|
|
58cfd49859 | ||
|
|
4a1933e924 | ||
|
|
6ded8c5ab5 | ||
|
|
edf38aad48 | ||
|
|
f4caa51da5 | ||
|
|
9575ba2a9f | ||
|
|
af2fe91f81 | ||
|
|
c641c86598 | ||
|
|
0599de372a | ||
|
|
1c89ee2797 | ||
|
|
5fd846bfc8 | ||
|
|
02aefcf155 | ||
|
|
e92983dd80 | ||
|
|
03f65317a9 | ||
|
|
21cb09fbde | ||
|
|
6c1e7f6f12 | ||
|
|
344dd3343b | ||
|
|
cacb9e449c | ||
|
|
18313141f4 | ||
|
|
ecd73ae0d6 | ||
|
|
7ad754df03 | ||
|
|
cfc601e19a | ||
|
|
9984f9c206 | ||
|
|
39e59a4077 | ||
|
|
d735ed19cb | ||
|
|
f4037a1ccf | ||
|
|
3e917e2062 | ||
|
|
919357a374 | ||
|
|
5b6be864fd | ||
|
|
98a3b06e56 | ||
|
|
6253def76c | ||
|
|
450e5f7e61 | ||
|
|
d2ec9c680d | ||
|
|
56d7ad6999 | ||
|
|
97024395c1 | ||
|
|
10342be2be | ||
|
|
51a3ee4a9b | ||
|
|
8779bbc532 | ||
|
|
90b33ef444 | ||
|
|
3fa0b36426 | ||
|
|
60a64cd777 | ||
|
|
c543fabdf4 | ||
|
|
64b96f00f7 | ||
|
|
86b372de68 | ||
|
|
c108070696 | ||
|
|
80a193a394 | ||
|
|
b9c16dbee4 | ||
|
|
6e870ef300 | ||
|
|
cf45ae30ac | ||
|
|
38a0453cbb | ||
|
|
92d37abbc5 | ||
|
|
39662038f7 | ||
|
|
75b58d0423 | ||
|
|
1814808df1 | ||
|
|
fe57d80a00 | ||
|
|
8cb855328d | ||
|
|
a62ba8e167 | ||
|
|
4f40b4af49 | ||
|
|
8d9a042489 | ||
|
|
ef05466d6d | ||
|
|
0a5cf005a1 | ||
|
|
f6c365bdf1 | ||
|
|
bc2ab60c59 | ||
|
|
ad217d4a3b | ||
|
|
61cc3e6f58 | ||
|
|
a3ab06509e | ||
|
|
54684ea3c9 | ||
|
|
3de4951c96 | ||
|
|
05c551d7ac | ||
|
|
7cea8b4fb3 | ||
|
|
ba2cdbf8cf | ||
|
|
3e004867be | ||
|
|
edaef53712 | ||
|
|
933842f6af | ||
|
|
2eff82891e | ||
|
|
c625756ab4 | ||
|
|
2140a220e2 | ||
|
|
7ead55d801 | ||
|
|
4e0038c813 | ||
|
|
d07e4c8ecd | ||
|
|
63fd42ff05 | ||
|
|
d5dbcd3f80 | ||
|
|
c301f36912 | ||
|
|
9dd5ee2365 | ||
|
|
3388b7a122 | ||
|
|
38af8de469 | ||
|
|
db0ebc6c33 | ||
|
|
7cc2961538 | ||
|
|
835ec4782c | ||
|
|
e6942bc201 | ||
|
|
ebabe1560f | ||
|
|
4da697f507 | ||
|
|
f18fb83a92 | ||
|
|
e050402787 | ||
|
|
b3dd0e25fa | ||
|
|
a5358b82f6 | ||
|
|
2a9f0f24fd | ||
|
|
5945942acd | ||
|
|
bcdb983b98 | ||
|
|
7836c611b7 | ||
|
|
2797d571e4 | ||
|
|
389fd0b1b0 | ||
|
|
25630da1ce | ||
|
|
ca972d3e28 | ||
|
|
80420302c1 | ||
|
|
9759d5f64f | ||
|
|
17a9b6102e | ||
|
|
7e7503035a | ||
|
|
02a6b24517 | ||
|
|
b3fee5b56d | ||
|
|
26d38acddb | ||
|
|
8a30e9b663 | ||
|
|
46a2d04528 | ||
|
|
6a85b82643 | ||
|
|
b436bb63da | ||
|
|
b5cb4051ab | ||
|
|
01f774db54 | ||
|
|
c5a6d765ee | ||
|
|
459f23bbd6 | ||
|
|
360754737f | ||
|
|
36f1476782 | ||
|
|
ecae83f659 | ||
|
|
fbe5109ed9 | ||
|
|
4adedad0de | ||
|
|
28257ba66f | ||
|
|
3062295069 | ||
|
|
3c231a7fde | ||
|
|
0247b02f6e | ||
|
|
8aaad71784 | ||
|
|
e795474917 | ||
|
|
49f99f57c9 | ||
|
|
53398707aa | ||
|
|
1d8a7d2e63 | ||
|
|
313e2bc080 | ||
|
|
0037935280 | ||
|
|
7858b40ce4 | ||
|
|
ab6db27ea7 | ||
|
|
4568795081 | ||
|
|
43643d1a83 | ||
|
|
28e7de6ceb | ||
|
|
c204855a71 | ||
|
|
dab33c4e60 | ||
|
|
47f9c0a502 | ||
|
|
d9a6fd2a42 | ||
|
|
dcb91905ad | ||
|
|
b6fd842d4e | ||
|
|
4b57e3e350 | ||
|
|
1652ebc4ad | ||
|
|
924ff1b6fc | ||
|
|
926ca72331 | ||
|
|
cf7190aaec | ||
|
|
54d6cded53 | ||
|
|
7a7e54ea5b | ||
|
|
7b4aa23f35 | ||
|
|
ac4482bc8b | ||
|
|
0a7f2b15f1 | ||
|
|
95e0b83537 | ||
|
|
bb602af750 | ||
|
|
580242b9d2 | ||
|
|
2cc1b55cbf | ||
|
|
e1944783d0 | ||
|
|
423d760f36 | ||
|
|
16e237b698 | ||
|
|
28d68d8a8e | ||
|
|
d476fbbdae | ||
|
|
64542f2902 | ||
|
|
56a59a5355 | ||
|
|
285ddeb62e | ||
|
|
84ef51f16b | ||
|
|
fb1125136c | ||
|
|
55f7ff1842 | ||
|
|
ac1d2210da | ||
|
|
ff92f355e2 | ||
|
|
4b8c8155fa | ||
|
|
756a83191d | ||
|
|
b5eb8be15e | ||
|
|
38a023d0b6 | ||
|
|
3a878dd019 | ||
|
|
6314c0f1d6 | ||
|
|
c5eed25f06 | ||
|
|
e1243522b0 | ||
|
|
d9108ac6ed | ||
|
|
302abe3e40 | ||
|
|
b6a2191e38 | ||
|
|
84b54e43aa | ||
|
|
e9971aa6c4 | ||
|
|
91f630209c | ||
|
|
b6878aefd6 | ||
|
|
f0f70def8c | ||
|
|
81bc5aefff | ||
|
|
698d2c96d7 | ||
|
|
ce683a539d | ||
|
|
ac481c6b18 | ||
|
|
750d6ad7eb | ||
|
|
7bd801cd01 | ||
|
|
5cb364f754 | ||
|
|
04d1b0c694 | ||
|
|
35028df817 | ||
|
|
2e8f55d7a8 | ||
|
|
815a440082 | ||
|
|
2afcd528dc | ||
|
|
8d68a59799 | ||
|
|
51bc60776d | ||
|
|
43f4c966f9 | ||
|
|
98a0233c4d | ||
|
|
0545be3244 | ||
|
|
4a67b22d8d | ||
|
|
5840bf710c | ||
|
|
1b8e1c2aab | ||
|
|
60aa949cca | ||
|
|
5b05b8927c | ||
|
|
d65d6d2396 | ||
|
|
086ac8fdc9 | ||
|
|
c6c7f128a9 | ||
|
|
36ec12fd0f | ||
|
|
e9fd751578 | ||
|
|
21a97b8871 | ||
|
|
b8ede4cfd0 | ||
|
|
f47eba5764 | ||
|
|
1347136b54 | ||
|
|
89f0758fbb | ||
|
|
b5507b9f5d | ||
|
|
204baa52ab | ||
|
|
bc739dc4a0 | ||
|
|
64616b9136 | ||
|
|
983783ea95 | ||
|
|
1414a4a9cf | ||
|
|
af7639aa73 | ||
|
|
dabc6a2d0a | ||
|
|
d1ef159e87 | ||
|
|
cc5c323ccb | ||
|
|
d18a871429 | ||
|
|
0a1f55f6a6 | ||
|
|
faeda030e9 | ||
|
|
b3700c3a4c | ||
|
|
01a221831f | ||
|
|
9cb41e01e2 | ||
|
|
abdb4f62de | ||
|
|
da7d354436 | ||
|
|
794a306f89 | ||
|
|
ac61ee1833 | ||
|
|
a87d419868 | ||
|
|
abbb7a0cb1 | ||
|
|
a5ae22d2a5 | ||
|
|
22b6a07749 | ||
|
|
dbdb2e2959 | ||
|
|
5147b3f0e4 | ||
|
|
a8eb0057e3 | ||
|
|
7604ff2ae4 | ||
|
|
bf9b5ba593 | ||
|
|
d12c111684 | ||
|
|
dffd3c9138 | ||
|
|
c34f7af6de | ||
|
|
22c7048ef6 | ||
|
|
96aa9d0813 | ||
|
|
d99c0ff8b2 | ||
|
|
c6e8bde078 | ||
|
|
adff7b9e1e | ||
|
|
b62c18fd84 | ||
|
|
de7cbdf494 | ||
|
|
0444ca143e | ||
|
|
596baad296 | ||
|
|
e686bb6247 | ||
|
|
06d6f15e38 | ||
|
|
d3adae42fe | ||
|
|
39b38119c1 | ||
|
|
eace3e9467 | ||
|
|
366da8d38e | ||
|
|
a965890916 | ||
|
|
b07bbd68d7 | ||
|
|
3d4a79aac6 | ||
|
|
e30c4cc644 | ||
|
|
d317be3ad3 | ||
|
|
1b078bd2fd | ||
|
|
1d84ed1614 | ||
|
|
114476d74c | ||
|
|
fb8663fb24 | ||
|
|
3a9be771b4 | ||
|
|
b2ef8f5cd2 | ||
|
|
83d501ae9b | ||
|
|
c555566c9d | ||
|
|
264f9a380b | ||
|
|
33d5951a14 | ||
|
|
68c4e43e05 | ||
|
|
54510f1c18 | ||
|
|
940234c743 | ||
|
|
b31ab46d11 | ||
|
|
c359821844 | ||
|
|
d49cf08e21 | ||
|
|
0f4cd23989 | ||
|
|
e12451911b | ||
|
|
b26f8cc43c | ||
|
|
d63c37cd78 | ||
|
|
c88aa2c9d8 | ||
|
|
4d5c744583 | ||
|
|
5033c5c7b7 | ||
|
|
5a1f2ffac7 | ||
|
|
8eecb592e6 | ||
|
|
fb188d6aaa | ||
|
|
0d33fe8fe4 | ||
|
|
5b3b8b5bc3 | ||
|
|
17de7f2e56 | ||
|
|
03aec7a34e | ||
|
|
266d68be22 | ||
|
|
bfbdefe773 | ||
|
|
5e96cdb1d6 | ||
|
|
19ee47ceb2 | ||
|
|
2823607146 | ||
|
|
1869abd9df | ||
|
|
f070d184ea | ||
|
|
d59d552aae | ||
|
|
a370531f1d | ||
|
|
9ae1b455f4 | ||
|
|
ec0eb64ffd | ||
|
|
f31886e1ab | ||
|
|
7365831ec1 | ||
|
|
4a09b682b2 | ||
|
|
afbd52a91e | ||
|
|
1c6e14acb4 | ||
|
|
6968936c8f | ||
|
|
a571278145 | ||
|
|
e4e25394e2 | ||
|
|
fe47d7b9e3 | ||
|
|
4bb5bc6e32 | ||
|
|
49d951e96a | ||
|
|
9585a02959 | ||
|
|
a51fa5e4a2 | ||
|
|
bc0671440c | ||
|
|
1a07c3970f | ||
|
|
83c07b27f9 | ||
|
|
fbcf7d2fc3 | ||
|
|
b547ac1aed | ||
|
|
411f8a8d61 | ||
|
|
b3741a5cf4 | ||
|
|
b1cf524612 | ||
|
|
364c920fff | ||
|
|
e89ccee5f4 | ||
|
|
6a86e69cd4 | ||
|
|
ab2c086e93 | ||
|
|
b9c65e634c |
61
.github/workflows/release.yml
vendored
61
.github/workflows/release.yml
vendored
@@ -8,20 +8,75 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
release-mac-arm64:
|
||||
runs-on: macos-14
|
||||
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package and Publish macOS arm64 (unsigned DMG)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: |
|
||||
npx electron-builder --mac dmg --arm64 --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
|
||||
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.12
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -56,9 +56,15 @@ Thumbs.db
|
||||
*.aps
|
||||
|
||||
wcdb/
|
||||
xkey/
|
||||
server/
|
||||
*info
|
||||
概述.md
|
||||
chatlab-format.md
|
||||
*.bak
|
||||
AGENTS.md
|
||||
.claude/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.agents/
|
||||
resources/wx_send
|
||||
概述.md
|
||||
pnpm-lock.yaml
|
||||
|
||||
27
README.md
27
README.md
@@ -41,7 +41,28 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
- 年度报告与可视化概览
|
||||
- 导出聊天记录为 HTML 等格式
|
||||
- HTTP API 接口(供开发者集成)
|
||||
- 查看完整能力清单:[详细功能](#详细功能清单)
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
||||
|
||||
## 详细功能清单
|
||||
|
||||
当前版本已支持以下能力:
|
||||
|
||||
| 功能模块 | 说明 |
|
||||
|---------|------|
|
||||
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||
| **年度报告** | 生成按年统计的年度报告,或跨年度的长期历史报告 |
|
||||
| **双人报告** | 选择指定好友,基于双方聊天记录生成专属分析报告 |
|
||||
| **消息导出** | 将微信聊天记录导出为多种格式:JSON、HTML、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
|
||||
| **朋友圈** | 解密朋友圈图片、视频、实况;导出朋友圈内容;拦截朋友圈的删除与隐藏操作;突破时间访问限制 |
|
||||
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
|
||||
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API,便于对接外部系统、自动化脚本与二次开发 |
|
||||
|
||||
## HTTP API
|
||||
|
||||
@@ -55,13 +76,9 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
||||
- **访问地址**:`http://127.0.0.1:5031`
|
||||
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
||||
|
||||
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
||||
|
||||
## 面向开发者
|
||||
|
||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||
|
||||
@@ -105,7 +105,8 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
||||
"senderUsername": "wxid_sender",
|
||||
"mediaType": "image",
|
||||
"mediaFileName": "image_123.jpg",
|
||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
|
||||
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -140,7 +141,7 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
||||
"timestamp": 1738713600000,
|
||||
"type": 0,
|
||||
"content": "消息内容",
|
||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||
"mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg"
|
||||
}
|
||||
],
|
||||
"media": {
|
||||
@@ -153,7 +154,59 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取会话列表
|
||||
### 3. 访问导出媒体文件
|
||||
|
||||
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/media/{relativePath}
|
||||
```
|
||||
|
||||
**路径参数**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` |
|
||||
|
||||
**支持的媒体类型**
|
||||
|
||||
| 扩展名 | Content-Type |
|
||||
|--------|-------------|
|
||||
| `.png` | image/png |
|
||||
| `.jpg` / `.jpeg` | image/jpeg |
|
||||
| `.gif` | image/gif |
|
||||
| `.webp` | image/webp |
|
||||
| `.wav` | audio/wav |
|
||||
| `.mp3` | audio/mpeg |
|
||||
| `.mp4` | video/mp4 |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
# 访问导出的图片
|
||||
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg
|
||||
|
||||
# 访问导出的语音
|
||||
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav
|
||||
|
||||
# 访问导出的视频
|
||||
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4
|
||||
```
|
||||
|
||||
**响应**
|
||||
|
||||
成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。
|
||||
|
||||
失败时返回:
|
||||
```json
|
||||
{ "error": "Media not found" }
|
||||
```
|
||||
|
||||
> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。
|
||||
|
||||
---
|
||||
|
||||
### 4. 获取会话列表
|
||||
|
||||
获取所有会话列表。
|
||||
|
||||
|
||||
14
electron/entitlements.mac.plist
Normal file
14
electron/entitlements.mac.plist
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.debugger</key>
|
||||
<true/>
|
||||
<key>com.apple.security.get-task-allow</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -10,7 +10,7 @@ type WorkerPayload = {
|
||||
thumbOnly: boolean
|
||||
}
|
||||
|
||||
type Candidate = { score: number; path: string; isThumb: boolean; hasX: boolean }
|
||||
type Candidate = { score: number; path: string; isThumb: boolean }
|
||||
|
||||
const payload = workerData as WorkerPayload
|
||||
|
||||
@@ -18,16 +18,26 @@ function looksLikeMd5(value: string): boolean {
|
||||
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
||||
}
|
||||
|
||||
function stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
}
|
||||
}
|
||||
if (/[._][a-z]$/.test(lower)) {
|
||||
return lower.slice(0, -2)
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
function hasXVariant(baseLower: string): boolean {
|
||||
return /[._][a-z]$/.test(baseLower)
|
||||
return stripDatVariantSuffix(baseLower) !== baseLower
|
||||
}
|
||||
|
||||
function hasImageVariantSuffix(baseLower: string): boolean {
|
||||
return /[._][a-z]$/.test(baseLower)
|
||||
}
|
||||
|
||||
function isLikelyImageDatBase(baseLower: string): boolean {
|
||||
return hasImageVariantSuffix(baseLower) || looksLikeMd5(baseLower)
|
||||
return stripDatVariantSuffix(baseLower) !== baseLower
|
||||
}
|
||||
|
||||
function normalizeDatBase(name: string): string {
|
||||
@@ -35,10 +45,17 @@ function normalizeDatBase(name: string): string {
|
||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||
base = base.slice(0, -4)
|
||||
}
|
||||
while (/[._][a-z]$/.test(base)) {
|
||||
base = base.slice(0, -2)
|
||||
while (true) {
|
||||
const stripped = stripDatVariantSuffix(base)
|
||||
if (stripped === base) {
|
||||
return base
|
||||
}
|
||||
base = stripped
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function isLikelyImageDatBase(baseLower: string): boolean {
|
||||
return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower))
|
||||
}
|
||||
|
||||
function matchesDatName(fileName: string, datName: string): boolean {
|
||||
@@ -47,25 +64,23 @@ function matchesDatName(fileName: string, datName: string): boolean {
|
||||
const normalizedBase = normalizeDatBase(base)
|
||||
const normalizedTarget = normalizeDatBase(datName.toLowerCase())
|
||||
if (normalizedBase === normalizedTarget) return true
|
||||
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`)
|
||||
if (pattern.test(lower)) return true
|
||||
return lower.endsWith('.dat') && lower.includes(datName)
|
||||
return lower.endsWith('.dat') && lower.includes(normalizedTarget)
|
||||
}
|
||||
|
||||
function scoreDatName(fileName: string): number {
|
||||
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
|
||||
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
|
||||
return 2
|
||||
const lower = fileName.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (!hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
}
|
||||
|
||||
function isThumbnailDat(fileName: string): boolean {
|
||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
||||
}
|
||||
|
||||
function isHdDat(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
return base.endsWith('_hd') || base.endsWith('_h')
|
||||
return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.dat')
|
||||
}
|
||||
|
||||
function walkForDat(
|
||||
@@ -105,20 +120,15 @@ function walkForDat(
|
||||
if (!lower.endsWith('.dat')) continue
|
||||
const baseLower = lower.slice(0, -4)
|
||||
if (!isLikelyImageDatBase(baseLower)) continue
|
||||
if (!hasXVariant(baseLower)) continue
|
||||
if (!matchesDatName(lower, datName)) continue
|
||||
// 排除高清图片格式 (_hd, _h)
|
||||
if (isHdDat(lower)) continue
|
||||
matchedBases.add(baseLower)
|
||||
const isThumb = isThumbnailDat(lower)
|
||||
if (!allowThumbnail && isThumb) continue
|
||||
if (thumbOnly && !isThumb) continue
|
||||
const score = scoreDatName(lower)
|
||||
candidates.push({
|
||||
score,
|
||||
score: scoreDatName(lower),
|
||||
path: entryPath,
|
||||
isThumb,
|
||||
hasX: hasXVariant(baseLower)
|
||||
isThumb
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -126,10 +136,8 @@ function walkForDat(
|
||||
return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
|
||||
}
|
||||
|
||||
const withX = candidates.filter((item) => item.hasX)
|
||||
const basePool = withX.length ? withX : candidates
|
||||
const nonThumb = basePool.filter((item) => !item.isThumb)
|
||||
const finalPool = thumbOnly ? basePool : (nonThumb.length ? nonThumb : basePool)
|
||||
const nonThumb = candidates.filter((item) => !item.isThumb)
|
||||
const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates)
|
||||
|
||||
let best: { score: number; path: string } | null = null
|
||||
for (const item of finalPool) {
|
||||
|
||||
1170
electron/main.ts
1170
electron/main.ts
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 认证
|
||||
auth: {
|
||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
|
||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
|
||||
verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
|
||||
unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
|
||||
enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
|
||||
disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
|
||||
changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
|
||||
setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
|
||||
clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
|
||||
isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
|
||||
},
|
||||
|
||||
|
||||
@@ -62,13 +70,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
log: {
|
||||
getPath: () => ipcRenderer.invoke('log:getPath'),
|
||||
read: () => ipcRenderer.invoke('log:read'),
|
||||
clear: () => ipcRenderer.invoke('log:clear'),
|
||||
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
||||
},
|
||||
|
||||
diagnostics: {
|
||||
getExportCardLogs: (options?: { limit?: number }) =>
|
||||
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
|
||||
clearExportCardLogs: () =>
|
||||
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
|
||||
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
|
||||
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
|
||||
},
|
||||
|
||||
// 窗口控制
|
||||
window: {
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
maximize: () => ipcRenderer.send('window:maximize'),
|
||||
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
|
||||
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => {
|
||||
const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized)
|
||||
ipcRenderer.on('window:maximizeStateChanged', listener)
|
||||
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
||||
},
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||
@@ -78,10 +102,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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),
|
||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
|
||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
|
||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
||||
openSessionChatWindow: (
|
||||
sessionId: string,
|
||||
options?: {
|
||||
source?: 'chat' | 'export'
|
||||
initialDisplayName?: string
|
||||
initialAvatarUrl?: string
|
||||
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
) =>
|
||||
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
|
||||
},
|
||||
|
||||
// 数据库路径
|
||||
@@ -105,7 +139,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 密钥获取
|
||||
key: {
|
||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||
autoGetImageKey: (manualDir?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir),
|
||||
autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid),
|
||||
scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir),
|
||||
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
|
||||
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
|
||||
@@ -121,8 +156,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
chat: {
|
||||
connect: () => ipcRenderer.invoke('chat:connect'),
|
||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
||||
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
||||
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
||||
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
||||
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
|
||||
enrichSessionsContactInfo: (
|
||||
usernames: string[],
|
||||
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
|
||||
) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||
@@ -140,13 +181,31 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) =>
|
||||
ipcRenderer.invoke('chat:clearCurrentAccountData', options),
|
||||
close: () => ipcRenderer.invoke('chat:close'),
|
||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||
getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
|
||||
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
|
||||
getExportSessionStats: (
|
||||
sessionIds: string[],
|
||||
options?: {
|
||||
includeRelations?: boolean
|
||||
forceRefresh?: boolean
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
}
|
||||
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
|
||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', 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),
|
||||
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', 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) => {
|
||||
@@ -217,6 +276,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
groupAnalytics: {
|
||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
||||
getGroupMembersPanelData: (
|
||||
chatroomId: string,
|
||||
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||
) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options),
|
||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||
@@ -228,9 +291,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 年度报告
|
||||
annualReport: {
|
||||
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
|
||||
startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'),
|
||||
cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId),
|
||||
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
||||
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
||||
ipcRenderer.invoke('annualReport:exportImages', payload),
|
||||
onAvailableYearsProgress: (callback: (payload: {
|
||||
taskId: string
|
||||
years?: number[]
|
||||
done: boolean
|
||||
error?: string
|
||||
canceled?: boolean
|
||||
strategy?: 'cache' | 'native' | 'hybrid'
|
||||
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||
statusText?: string
|
||||
nativeElapsedMs?: number
|
||||
scanElapsedMs?: number
|
||||
totalElapsedMs?: number
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}) => void) => {
|
||||
ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress')
|
||||
},
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
||||
@@ -255,7 +338,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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) => {
|
||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => {
|
||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||
}
|
||||
@@ -277,6 +360,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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'),
|
||||
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
|
||||
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
|
||||
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
||||
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
|
||||
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),
|
||||
@@ -285,7 +372,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
|
||||
},
|
||||
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir')
|
||||
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir'),
|
||||
installBlockDeleteTrigger: () => ipcRenderer.invoke('sns:installBlockDeleteTrigger'),
|
||||
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
|
||||
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
|
||||
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
|
||||
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
||||
},
|
||||
|
||||
|
||||
// 数据收集
|
||||
cloud: {
|
||||
init: () => ipcRenderer.invoke('cloud:init'),
|
||||
recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName),
|
||||
getLogs: () => ipcRenderer.invoke('cloud:getLogs')
|
||||
},
|
||||
|
||||
// HTTP API 服务
|
||||
|
||||
@@ -76,16 +76,12 @@ class AnalyticsService {
|
||||
const map: Record<string, string> = {}
|
||||
if (usernames.length === 0) return map
|
||||
|
||||
// C++ 层不支持参数绑定,直接内联转义后的字符串值
|
||||
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 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>[]) {
|
||||
|
||||
@@ -85,7 +85,34 @@ export interface AnnualReportData {
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface AvailableYearsLoadProgress {
|
||||
years: number[]
|
||||
strategy: 'cache' | 'native' | 'hybrid'
|
||||
phase: 'cache' | 'native' | 'scan'
|
||||
statusText: string
|
||||
nativeElapsedMs: number
|
||||
scanElapsedMs: number
|
||||
totalElapsedMs: number
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}
|
||||
|
||||
interface AvailableYearsLoadMeta {
|
||||
strategy: 'cache' | 'native' | 'hybrid'
|
||||
nativeElapsedMs: number
|
||||
scanElapsedMs: number
|
||||
totalElapsedMs: number
|
||||
switched: boolean
|
||||
nativeTimedOut: boolean
|
||||
statusText: string
|
||||
}
|
||||
|
||||
class AnnualReportService {
|
||||
private readonly availableYearsCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly availableYearsScanConcurrency = 4
|
||||
private readonly availableYearsColumnCache = new Map<string, string>()
|
||||
private readonly availableYearsCache = new Map<string, { years: number[]; updatedAt: number }>()
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
@@ -181,6 +208,234 @@ class AnnualReportService {
|
||||
}
|
||||
}
|
||||
|
||||
private quoteSqlIdentifier(identifier: string): string {
|
||||
return `"${String(identifier || '').replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
private toUnixTimestamp(value: any): number {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n) || n <= 0) return 0
|
||||
// 兼容毫秒级时间戳
|
||||
const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n)
|
||||
return seconds > 0 ? seconds : 0
|
||||
}
|
||||
|
||||
private addYearsFromRange(years: Set<number>, firstTs: number, lastTs: number): boolean {
|
||||
let changed = false
|
||||
const currentYear = new Date().getFullYear()
|
||||
const minTs = firstTs > 0 ? firstTs : lastTs
|
||||
const maxTs = lastTs > 0 ? lastTs : firstTs
|
||||
if (minTs <= 0 || maxTs <= 0) return changed
|
||||
|
||||
const minYear = new Date(minTs * 1000).getFullYear()
|
||||
const maxYear = new Date(maxTs * 1000).getFullYear()
|
||||
for (let y = minYear; y <= maxYear; y++) {
|
||||
if (y >= 2010 && y <= currentYear && !years.has(y)) {
|
||||
years.add(y)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private normalizeAvailableYears(years: Iterable<number>): number[] {
|
||||
return Array.from(new Set(Array.from(years)))
|
||||
.filter((y) => Number.isFinite(y))
|
||||
.map((y) => Math.floor(y))
|
||||
.sort((a, b) => b - a)
|
||||
}
|
||||
|
||||
private async forEachWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
handler: (item: T, index: number) => Promise<void>,
|
||||
shouldStop?: () => boolean
|
||||
): Promise<void> {
|
||||
if (!items.length) return
|
||||
const workerCount = Math.max(1, Math.min(concurrency, items.length))
|
||||
let nextIndex = 0
|
||||
const workers: Promise<void>[] = []
|
||||
|
||||
for (let i = 0; i < workerCount; i++) {
|
||||
workers.push((async () => {
|
||||
while (true) {
|
||||
if (shouldStop?.()) break
|
||||
const current = nextIndex
|
||||
nextIndex += 1
|
||||
if (current >= items.length) break
|
||||
await handler(items[current], current)
|
||||
}
|
||||
})())
|
||||
}
|
||||
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
private async detectTimeColumn(dbPath: string, tableName: string): Promise<string | null> {
|
||||
const cacheKey = `${dbPath}\u0001${tableName}`
|
||||
if (this.availableYearsColumnCache.has(cacheKey)) {
|
||||
const cached = this.availableYearsColumnCache.get(cacheKey) || ''
|
||||
return cached || null
|
||||
}
|
||||
|
||||
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
|
||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
|
||||
this.availableYearsColumnCache.set(cacheKey, '')
|
||||
return null
|
||||
}
|
||||
|
||||
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
|
||||
const columns = new Set<string>()
|
||||
for (const row of result.rows as Record<string, any>[]) {
|
||||
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
|
||||
if (name) columns.add(name)
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (columns.has(candidate)) {
|
||||
this.availableYearsColumnCache.set(cacheKey, candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
this.availableYearsColumnCache.set(cacheKey, '')
|
||||
return null
|
||||
}
|
||||
|
||||
private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> {
|
||||
const cacheKey = `${dbPath}\u0001${tableName}`
|
||||
const cachedColumn = this.availableYearsColumnCache.get(cacheKey)
|
||||
const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time'
|
||||
const tried = new Set<string>()
|
||||
|
||||
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
|
||||
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
|
||||
const row = result.rows[0] as Record<string, any>
|
||||
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
|
||||
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
|
||||
return { first, last }
|
||||
}
|
||||
|
||||
tried.add(initialColumn)
|
||||
const quick = await queryByColumn(initialColumn)
|
||||
if (quick) {
|
||||
if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn)
|
||||
return quick
|
||||
}
|
||||
|
||||
const detectedColumn = await this.detectTimeColumn(dbPath, tableName)
|
||||
if (!detectedColumn || tried.has(detectedColumn)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return queryByColumn(detectedColumn)
|
||||
}
|
||||
|
||||
private async getAvailableYearsByTableScan(
|
||||
sessionIds: string[],
|
||||
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
|
||||
): Promise<number[]> {
|
||||
const years = new Set<number>()
|
||||
let lastEmittedSize = 0
|
||||
|
||||
const emitIfChanged = (force = false) => {
|
||||
if (!options?.onProgress) return
|
||||
const next = this.normalizeAvailableYears(years)
|
||||
if (!force && next.length === lastEmittedSize) return
|
||||
options.onProgress(next)
|
||||
lastEmittedSize = next.length
|
||||
}
|
||||
|
||||
const shouldCancel = () => options?.shouldCancel?.() === true
|
||||
|
||||
await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => {
|
||||
if (shouldCancel()) return
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const table of tableStats.tables as Record<string, any>[]) {
|
||||
if (shouldCancel()) return
|
||||
const tableName = String(table.table_name || table.name || '').trim()
|
||||
const dbPath = String(table.db_path || table.dbPath || '').trim()
|
||||
if (!tableName || !dbPath) continue
|
||||
|
||||
const range = await this.getTableTimeRange(dbPath, tableName)
|
||||
if (!range) continue
|
||||
const changed = this.addYearsFromRange(years, range.first, range.last)
|
||||
if (changed) emitIfChanged()
|
||||
}
|
||||
}, shouldCancel)
|
||||
|
||||
emitIfChanged(true)
|
||||
return this.normalizeAvailableYears(years)
|
||||
}
|
||||
|
||||
private async getAvailableYearsByEdgeScan(
|
||||
sessionIds: string[],
|
||||
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
|
||||
): Promise<number[]> {
|
||||
const years = new Set<number>()
|
||||
let lastEmittedSize = 0
|
||||
const shouldCancel = () => options?.shouldCancel?.() === true
|
||||
|
||||
const emitIfChanged = (force = false) => {
|
||||
if (!options?.onProgress) return
|
||||
const next = this.normalizeAvailableYears(years)
|
||||
if (!force && next.length === lastEmittedSize) return
|
||||
options.onProgress(next)
|
||||
lastEmittedSize = next.length
|
||||
}
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
if (shouldCancel()) break
|
||||
const first = await this.getEdgeMessageTime(sessionId, true)
|
||||
const last = await this.getEdgeMessageTime(sessionId, false)
|
||||
const changed = this.addYearsFromRange(years, first || 0, last || 0)
|
||||
if (changed) emitIfChanged()
|
||||
}
|
||||
emitIfChanged(true)
|
||||
return this.normalizeAvailableYears(years)
|
||||
}
|
||||
|
||||
private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string {
|
||||
return `${dbPath}\u0001${cleanedWxid}`
|
||||
}
|
||||
|
||||
private getCachedAvailableYears(cacheKey: string): number[] | null {
|
||||
const cached = this.availableYearsCache.get(cacheKey)
|
||||
if (!cached) return null
|
||||
if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) {
|
||||
this.availableYearsCache.delete(cacheKey)
|
||||
return null
|
||||
}
|
||||
return [...cached.years]
|
||||
}
|
||||
|
||||
private setCachedAvailableYears(cacheKey: string, years: number[]): void {
|
||||
const normalized = this.normalizeAvailableYears(years)
|
||||
|
||||
this.availableYearsCache.set(cacheKey, {
|
||||
years: normalized,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
|
||||
if (this.availableYearsCache.size > 8) {
|
||||
let oldestKey = ''
|
||||
let oldestTime = Number.POSITIVE_INFINITY
|
||||
for (const [key, val] of this.availableYearsCache) {
|
||||
if (val.updatedAt < oldestTime) {
|
||||
oldestTime = val.updatedAt
|
||||
oldestKey = key
|
||||
}
|
||||
}
|
||||
if (oldestKey) this.availableYearsCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
||||
let content = this.decodeMaybeCompressed(compressContent)
|
||||
if (!content || content.length === 0) {
|
||||
@@ -359,38 +614,226 @@ class AnnualReportService {
|
||||
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
|
||||
}
|
||||
|
||||
async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> {
|
||||
async getAvailableYears(params: {
|
||||
dbPath: string
|
||||
decryptKey: string
|
||||
wxid: string
|
||||
onProgress?: (payload: AvailableYearsLoadProgress) => void
|
||||
shouldCancel?: () => boolean
|
||||
nativeTimeoutMs?: number
|
||||
}): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> {
|
||||
try {
|
||||
const isCancelled = () => params.shouldCancel?.() === true
|
||||
const totalStartedAt = Date.now()
|
||||
let nativeElapsedMs = 0
|
||||
let scanElapsedMs = 0
|
||||
let switched = false
|
||||
let nativeTimedOut = false
|
||||
let latestYears: number[] = []
|
||||
|
||||
const emitProgress = (payload: {
|
||||
years?: number[]
|
||||
strategy: 'cache' | 'native' | 'hybrid'
|
||||
phase: 'cache' | 'native' | 'scan'
|
||||
statusText: string
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}) => {
|
||||
if (!params.onProgress) return
|
||||
if (Array.isArray(payload.years)) latestYears = payload.years
|
||||
params.onProgress({
|
||||
years: latestYears,
|
||||
strategy: payload.strategy,
|
||||
phase: payload.phase,
|
||||
statusText: payload.statusText,
|
||||
nativeElapsedMs,
|
||||
scanElapsedMs,
|
||||
totalElapsedMs: Date.now() - totalStartedAt,
|
||||
switched: payload.switched ?? switched,
|
||||
nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut
|
||||
})
|
||||
}
|
||||
|
||||
const buildMeta = (
|
||||
strategy: 'cache' | 'native' | 'hybrid',
|
||||
statusText: string
|
||||
): AvailableYearsLoadMeta => ({
|
||||
strategy,
|
||||
nativeElapsedMs,
|
||||
scanElapsedMs,
|
||||
totalElapsedMs: Date.now() - totalStartedAt,
|
||||
switched,
|
||||
nativeTimedOut,
|
||||
statusText
|
||||
})
|
||||
|
||||
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||
|
||||
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
||||
if (sessionIds.length === 0) {
|
||||
return { success: false, error: '未找到消息会话' }
|
||||
}
|
||||
|
||||
const fastYears = await wcdbService.getAvailableYears(sessionIds)
|
||||
if (fastYears.success && fastYears.data) {
|
||||
return { success: true, data: fastYears.data }
|
||||
}
|
||||
|
||||
const years = new Set<number>()
|
||||
for (const sessionId of sessionIds) {
|
||||
const first = await this.getEdgeMessageTime(sessionId, true)
|
||||
const last = await this.getEdgeMessageTime(sessionId, false)
|
||||
if (!first && !last) continue
|
||||
|
||||
const minYear = new Date((first || last || 0) * 1000).getFullYear()
|
||||
const maxYear = new Date((last || first || 0) * 1000).getFullYear()
|
||||
for (let y = minYear; y <= maxYear; y++) {
|
||||
if (y >= 2010 && y <= new Date().getFullYear()) years.add(y)
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') }
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid)
|
||||
const cached = this.getCachedAvailableYears(cacheKey)
|
||||
if (cached) {
|
||||
latestYears = cached
|
||||
emitProgress({
|
||||
years: cached,
|
||||
strategy: 'cache',
|
||||
phase: 'cache',
|
||||
statusText: '命中缓存,已快速加载年份数据'
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
data: cached,
|
||||
meta: buildMeta('cache', '命中缓存,已快速加载年份数据')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedYears = Array.from(years).sort((a, b) => b - a)
|
||||
return { success: true, data: sortedYears }
|
||||
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
||||
if (sessionIds.length === 0) {
|
||||
return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') }
|
||||
}
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
|
||||
const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000))
|
||||
const nativeStartedAt = Date.now()
|
||||
let nativeTicker: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
emitProgress({
|
||||
strategy: 'native',
|
||||
phase: 'native',
|
||||
statusText: '正在使用原生快速模式加载年份...'
|
||||
})
|
||||
nativeTicker = setInterval(() => {
|
||||
nativeElapsedMs = Date.now() - nativeStartedAt
|
||||
emitProgress({
|
||||
strategy: 'native',
|
||||
phase: 'native',
|
||||
statusText: '正在使用原生快速模式加载年份...'
|
||||
})
|
||||
}, 120)
|
||||
|
||||
const nativeRace = await Promise.race([
|
||||
wcdbService.getAvailableYears(sessionIds)
|
||||
.then((result) => ({ kind: 'result' as const, result }))
|
||||
.catch((error) => ({ kind: 'error' as const, error: String(error) })),
|
||||
new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs))
|
||||
])
|
||||
|
||||
if (nativeTicker) {
|
||||
clearInterval(nativeTicker)
|
||||
nativeTicker = null
|
||||
}
|
||||
nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt)
|
||||
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
|
||||
if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) {
|
||||
const years = this.normalizeAvailableYears(nativeRace.result.data)
|
||||
latestYears = years
|
||||
this.setCachedAvailableYears(cacheKey, years)
|
||||
emitProgress({
|
||||
years,
|
||||
strategy: 'native',
|
||||
phase: 'native',
|
||||
statusText: '原生快速模式加载完成'
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
data: years,
|
||||
meta: buildMeta('native', '原生快速模式加载完成')
|
||||
}
|
||||
}
|
||||
|
||||
switched = true
|
||||
nativeTimedOut = nativeRace.kind === 'timeout'
|
||||
emitProgress({
|
||||
strategy: 'hybrid',
|
||||
phase: 'native',
|
||||
statusText: nativeTimedOut
|
||||
? '原生快速模式超时,已自动切换到扫表兼容模式...'
|
||||
: '原生快速模式不可用,已自动切换到扫表兼容模式...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
|
||||
const scanStartedAt = Date.now()
|
||||
let scanTicker: ReturnType<typeof setInterval> | null = null
|
||||
scanTicker = setInterval(() => {
|
||||
scanElapsedMs = Date.now() - scanStartedAt
|
||||
emitProgress({
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: nativeTimedOut
|
||||
? '原生已超时,正在使用扫表兼容模式加载年份...'
|
||||
: '正在使用扫表兼容模式加载年份...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
}, 120)
|
||||
|
||||
let years = await this.getAvailableYearsByTableScan(sessionIds, {
|
||||
onProgress: (items) => {
|
||||
latestYears = items
|
||||
scanElapsedMs = Date.now() - scanStartedAt
|
||||
emitProgress({
|
||||
years: items,
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: nativeTimedOut
|
||||
? '原生已超时,正在使用扫表兼容模式加载年份...'
|
||||
: '正在使用扫表兼容模式加载年份...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
},
|
||||
shouldCancel: params.shouldCancel
|
||||
})
|
||||
|
||||
if (isCancelled()) {
|
||||
if (scanTicker) clearInterval(scanTicker)
|
||||
return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
}
|
||||
if (years.length === 0) {
|
||||
years = await this.getAvailableYearsByEdgeScan(sessionIds, {
|
||||
onProgress: (items) => {
|
||||
latestYears = items
|
||||
scanElapsedMs = Date.now() - scanStartedAt
|
||||
emitProgress({
|
||||
years: items,
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: '扫表结果为空,正在执行游标兜底扫描...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
},
|
||||
shouldCancel: params.shouldCancel
|
||||
})
|
||||
}
|
||||
if (scanTicker) {
|
||||
clearInterval(scanTicker)
|
||||
scanTicker = null
|
||||
}
|
||||
scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt)
|
||||
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
|
||||
this.setCachedAvailableYears(cacheKey, years)
|
||||
latestYears = years
|
||||
emitProgress({
|
||||
years,
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: '扫表兼容模式加载完成',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
data: years,
|
||||
meta: buildMeta('hybrid', '扫表兼容模式加载完成')
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
97
electron/services/cloudControlService.ts
Normal file
97
electron/services/cloudControlService.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { app } from 'electron'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
interface UsageStats {
|
||||
appVersion: string
|
||||
platform: string
|
||||
deviceId: string
|
||||
timestamp: number
|
||||
online: boolean
|
||||
pages: string[]
|
||||
}
|
||||
|
||||
class CloudControlService {
|
||||
private deviceId: string = ''
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private pages: Set<string> = new Set()
|
||||
|
||||
async init() {
|
||||
this.deviceId = this.getDeviceId()
|
||||
await wcdbService.cloudInit(300)
|
||||
await this.reportOnline()
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.reportOnline()
|
||||
}, 300000)
|
||||
}
|
||||
|
||||
private getDeviceId(): string {
|
||||
const crypto = require('crypto')
|
||||
const os = require('os')
|
||||
const machineId = os.hostname() + os.platform() + os.arch()
|
||||
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||
}
|
||||
|
||||
private async reportOnline() {
|
||||
const data: UsageStats = {
|
||||
appVersion: app.getVersion(),
|
||||
platform: this.getPlatformVersion(),
|
||||
deviceId: this.deviceId,
|
||||
timestamp: Date.now(),
|
||||
online: true,
|
||||
pages: Array.from(this.pages)
|
||||
}
|
||||
|
||||
await wcdbService.cloudReport(JSON.stringify(data))
|
||||
this.pages.clear()
|
||||
}
|
||||
|
||||
private getPlatformVersion(): string {
|
||||
const os = require('os')
|
||||
const platform = process.platform
|
||||
|
||||
if (platform === 'win32') {
|
||||
const release = os.release()
|
||||
const parts = release.split('.')
|
||||
const major = parseInt(parts[0])
|
||||
const minor = parseInt(parts[1] || '0')
|
||||
const build = parseInt(parts[2] || '0')
|
||||
|
||||
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
|
||||
if (major === 10 && minor === 0 && build >= 22000) {
|
||||
return 'Windows 11'
|
||||
} else if (major === 10) {
|
||||
return 'Windows 10'
|
||||
}
|
||||
return `Windows ${release}`
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
|
||||
// while cloud reporting expects the macOS product version (e.g. 26.3).
|
||||
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
|
||||
return `macOS ${macVersion}`
|
||||
}
|
||||
|
||||
return platform
|
||||
}
|
||||
|
||||
recordPage(pageName: string) {
|
||||
this.pages.add(pageName)
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
wcdbService.cloudStop()
|
||||
}
|
||||
|
||||
async getLogs() {
|
||||
return wcdbService.getLogs()
|
||||
}
|
||||
}
|
||||
|
||||
export const cloudControlService = new CloudControlService()
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { join } from 'path'
|
||||
import { app } from 'electron'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import crypto from 'crypto'
|
||||
import Store from 'electron-store'
|
||||
|
||||
// 加密前缀标记
|
||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||
|
||||
interface ConfigSchema {
|
||||
// 数据库相关
|
||||
dbPath: string // 数据库根目录 (xwechat_files)
|
||||
decryptKey: string // 解密密钥
|
||||
myWxid: string // 当前用户 wxid
|
||||
dbPath: string
|
||||
decryptKey: string
|
||||
myWxid: string
|
||||
onboardingDone: boolean
|
||||
imageXorKey: number
|
||||
imageAesKey: string
|
||||
@@ -14,7 +19,6 @@ interface ConfigSchema {
|
||||
|
||||
// 缓存相关
|
||||
cachePath: string
|
||||
|
||||
lastOpenedDb: string
|
||||
lastSession: string
|
||||
|
||||
@@ -34,8 +38,9 @@ interface ConfigSchema {
|
||||
|
||||
// 安全相关
|
||||
authEnabled: boolean
|
||||
authPassword: string // SHA-256 hash
|
||||
authPassword: string // SHA-256 hash(safeStorage 加密)
|
||||
authUseHello: boolean
|
||||
authHelloSecret: string // 原始密码(safeStorage 加密,Hello 解锁时使用)
|
||||
|
||||
// 更新相关
|
||||
ignoredUpdateVersion: string
|
||||
@@ -48,10 +53,23 @@ interface ConfigSchema {
|
||||
wordCloudExcludeWords: string[]
|
||||
}
|
||||
|
||||
// 需要 safeStorage 加密的字段(普通模式)
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
|
||||
// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密)
|
||||
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
|
||||
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
|
||||
export class ConfigService {
|
||||
private static instance: ConfigService
|
||||
private store!: Store<ConfigSchema>
|
||||
|
||||
// 锁定模式运行时状态
|
||||
private unlockedKeys: Map<string, any> = new Map()
|
||||
private unlockPassword: string | null = null
|
||||
|
||||
static getInstance(): ConfigService {
|
||||
if (!ConfigService.instance) {
|
||||
ConfigService.instance = new ConfigService()
|
||||
@@ -75,7 +93,6 @@ export class ConfigService {
|
||||
imageAesKey: '',
|
||||
wxidConfigs: {},
|
||||
cachePath: '',
|
||||
|
||||
lastOpenedDb: '',
|
||||
lastSession: '',
|
||||
theme: 'system',
|
||||
@@ -88,13 +105,12 @@ export class ConfigService {
|
||||
whisperDownloadSource: 'tsinghua',
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 2,
|
||||
exportDefaultConcurrency: 4,
|
||||
analyticsExcludedUsernames: [],
|
||||
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
authUseHello: false,
|
||||
|
||||
authHelloSecret: '',
|
||||
ignoredUpdateVersion: '',
|
||||
notificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
@@ -103,29 +119,556 @@ export class ConfigService {
|
||||
wordCloudExcludeWords: []
|
||||
}
|
||||
})
|
||||
this.migrateAuthFields()
|
||||
}
|
||||
|
||||
// === 状态查询 ===
|
||||
|
||||
isLockMode(): boolean {
|
||||
const raw: any = this.store.get('decryptKey')
|
||||
return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)
|
||||
}
|
||||
|
||||
isUnlocked(): boolean {
|
||||
return !this.isLockMode() || this.unlockedKeys.size > 0
|
||||
}
|
||||
|
||||
// === get / set ===
|
||||
|
||||
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
|
||||
return this.store.get(key)
|
||||
const raw = this.store.get(key)
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
const str = typeof raw === 'string' ? raw : ''
|
||||
if (!str || !str.startsWith(SAFE_PREFIX)) return raw
|
||||
return (this.safeDecrypt(str) === 'true') as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||
const str = typeof raw === 'string' ? raw : ''
|
||||
if (!str) return raw
|
||||
if (str.startsWith(LOCK_PREFIX)) {
|
||||
const cached = this.unlockedKeys.get(key as string)
|
||||
return (cached !== undefined ? cached : 0) as ConfigSchema[K]
|
||||
}
|
||||
if (!str.startsWith(SAFE_PREFIX)) return raw
|
||||
const num = Number(this.safeDecrypt(str))
|
||||
return (Number.isFinite(num) ? num : 0) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') {
|
||||
if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K]
|
||||
if (raw.startsWith(LOCK_PREFIX)) {
|
||||
const cached = this.unlockedKeys.get(key as string)
|
||||
return (cached !== undefined ? cached : '') as ConfigSchema[K]
|
||||
}
|
||||
return this.safeDecrypt(raw) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (key === 'wxidConfigs' && raw && typeof raw === 'object') {
|
||||
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
|
||||
this.store.set(key, value)
|
||||
let toStore = value
|
||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||
this.unlockedKeys.set(key as string, value)
|
||||
} else {
|
||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||
}
|
||||
} else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') {
|
||||
if (key === 'authPassword') {
|
||||
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||||
} else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) {
|
||||
toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K]
|
||||
this.unlockedKeys.set(key as string, value)
|
||||
} else {
|
||||
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||||
}
|
||||
} else if (key === 'wxidConfigs' && value && typeof value === 'object') {
|
||||
if (inLockMode) {
|
||||
toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K]
|
||||
} else {
|
||||
toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K]
|
||||
}
|
||||
}
|
||||
|
||||
this.store.set(key, toStore)
|
||||
}
|
||||
|
||||
// === 加密/解密工具 ===
|
||||
|
||||
private safeEncrypt(plaintext: string): string {
|
||||
if (!plaintext) return ''
|
||||
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||||
if (!safeStorage.isEncryptionAvailable()) return plaintext
|
||||
const encrypted = safeStorage.encryptString(plaintext)
|
||||
return SAFE_PREFIX + encrypted.toString('base64')
|
||||
}
|
||||
|
||||
private safeDecrypt(stored: string): string {
|
||||
if (!stored) return ''
|
||||
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||||
if (!safeStorage.isEncryptionAvailable()) return ''
|
||||
try {
|
||||
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||||
return safeStorage.decryptString(buf)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private lockEncrypt(plaintext: string, password: string): string {
|
||||
if (!plaintext) return ''
|
||||
const salt = crypto.randomBytes(16)
|
||||
const iv = crypto.randomBytes(12)
|
||||
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const authTag = cipher.getAuthTag()
|
||||
const combined = Buffer.concat([salt, iv, authTag, encrypted])
|
||||
return LOCK_PREFIX + combined.toString('base64')
|
||||
}
|
||||
|
||||
private lockDecrypt(stored: string, password: string): string | null {
|
||||
if (!stored || !stored.startsWith(LOCK_PREFIX)) return null
|
||||
try {
|
||||
const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
|
||||
const salt = combined.subarray(0, 16)
|
||||
const iv = combined.subarray(16, 28)
|
||||
const authTag = combined.subarray(28, 44)
|
||||
const ciphertext = combined.subarray(44)
|
||||
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
|
||||
decipher.setAuthTag(authTag)
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
return decrypted.toString('utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用)
|
||||
private verifyPasswordByDecrypt(password: string): boolean {
|
||||
// 依次尝试解密任意一个 lock: 字段,GCM authTag 会验证密码正确性
|
||||
const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey'] as const
|
||||
for (const key of lockFields) {
|
||||
const raw: any = this.store.get(key as any)
|
||||
if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
|
||||
const result = this.lockDecrypt(raw, password)
|
||||
// lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功
|
||||
return result !== null
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// === wxidConfigs 加密/解密 ===
|
||||
|
||||
private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||
const result: ConfigSchema['wxidConfigs'] = {}
|
||||
for (const [wxid, cfg] of Object.entries(configs)) {
|
||||
result[wxid] = { ...cfg }
|
||||
if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey)
|
||||
if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey)
|
||||
if (cfg.imageXorKey !== undefined) {
|
||||
(result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private decryptLockedWxidConfigs(password: string): void {
|
||||
const wxidConfigs = this.store.get('wxidConfigs')
|
||||
if (!wxidConfigs || typeof wxidConfigs !== 'object') return
|
||||
for (const [wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
|
||||
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(cfg.decryptKey, password)
|
||||
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, d)
|
||||
}
|
||||
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(cfg.imageAesKey, password)
|
||||
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, d)
|
||||
}
|
||||
if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(cfg.imageXorKey, password)
|
||||
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, Number(d))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||
const result: ConfigSchema['wxidConfigs'] = {}
|
||||
for (const [wxid, cfg] of Object.entries(configs) as [string, any][]) {
|
||||
result[wxid] = { ...cfg, updatedAt: cfg.updatedAt }
|
||||
// decryptKey
|
||||
if (typeof cfg.decryptKey === 'string') {
|
||||
if (cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||
result[wxid].decryptKey = this.unlockedKeys.get(`wxid:${wxid}:decryptKey`) ?? ''
|
||||
} else {
|
||||
result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey)
|
||||
}
|
||||
}
|
||||
// imageAesKey
|
||||
if (typeof cfg.imageAesKey === 'string') {
|
||||
if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||
result[wxid].imageAesKey = this.unlockedKeys.get(`wxid:${wxid}:imageAesKey`) ?? ''
|
||||
} else {
|
||||
result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey)
|
||||
}
|
||||
}
|
||||
// imageXorKey
|
||||
if (typeof cfg.imageXorKey === 'string') {
|
||||
if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
|
||||
result[wxid].imageXorKey = this.unlockedKeys.get(`wxid:${wxid}:imageXorKey`) ?? 0
|
||||
} else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) {
|
||||
const num = Number(this.safeDecrypt(cfg.imageXorKey))
|
||||
result[wxid].imageXorKey = Number.isFinite(num) ? num : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
private lockEncryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
|
||||
const result: ConfigSchema['wxidConfigs'] = {}
|
||||
for (const [wxid, cfg] of Object.entries(configs)) {
|
||||
result[wxid] = { ...cfg }
|
||||
if (cfg.decryptKey) result[wxid].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any
|
||||
if (cfg.imageAesKey) result[wxid].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any
|
||||
if (cfg.imageXorKey !== undefined) {
|
||||
(result[wxid] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// === 业务方法 ===
|
||||
|
||||
enableLock(password: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
// 先读取当前所有明文密钥
|
||||
const decryptKey = this.get('decryptKey')
|
||||
const imageAesKey = this.get('imageAesKey')
|
||||
const imageXorKey = this.get('imageXorKey')
|
||||
const wxidConfigs = this.get('wxidConfigs')
|
||||
|
||||
// 存储密码 hash(safeStorage 加密)
|
||||
const passwordHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||
this.store.set('authPassword', this.safeEncrypt(passwordHash) as any)
|
||||
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||
|
||||
// 设置运行时状态
|
||||
this.unlockPassword = password
|
||||
this.unlockedKeys.set('decryptKey', decryptKey)
|
||||
this.unlockedKeys.set('imageAesKey', imageAesKey)
|
||||
this.unlockedKeys.set('imageXorKey', imageXorKey)
|
||||
|
||||
// 用密码派生密钥重新加密所有敏感字段
|
||||
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any)
|
||||
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any)
|
||||
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any)
|
||||
|
||||
// 处理 wxidConfigs 中的嵌套密钥
|
||||
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
|
||||
this.store.set('wxidConfigs', lockedConfigs)
|
||||
for (const [wxid, cfg] of Object.entries(wxidConfigs)) {
|
||||
if (cfg.decryptKey) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, cfg.decryptKey)
|
||||
if (cfg.imageAesKey) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, cfg.imageAesKey)
|
||||
if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, cfg.imageXorKey)
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
unlock(password: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
// 验证密码
|
||||
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||
|
||||
if (storedHash && storedHash !== inputHash) {
|
||||
// authPassword 存在但密码不匹配
|
||||
return { success: false, error: '密码错误' }
|
||||
}
|
||||
|
||||
if (!storedHash) {
|
||||
// authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证
|
||||
const verified = this.verifyPasswordByDecrypt(password)
|
||||
if (!verified) {
|
||||
return { success: false, error: '密码错误' }
|
||||
}
|
||||
// 密码正确,自愈 authPassword
|
||||
const newHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
|
||||
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||
}
|
||||
|
||||
// 解密所有 lock: 字段到内存缓存
|
||||
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(rawDecryptKey, password)
|
||||
if (d !== null) this.unlockedKeys.set('decryptKey', d)
|
||||
}
|
||||
|
||||
const rawImageAesKey: any = this.store.get('imageAesKey')
|
||||
if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(rawImageAesKey, password)
|
||||
if (d !== null) this.unlockedKeys.set('imageAesKey', d)
|
||||
}
|
||||
|
||||
const rawImageXorKey: any = this.store.get('imageXorKey')
|
||||
if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) {
|
||||
const d = this.lockDecrypt(rawImageXorKey, password)
|
||||
if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d))
|
||||
}
|
||||
|
||||
// 解密 wxidConfigs 嵌套密钥
|
||||
this.decryptLockedWxidConfigs(password)
|
||||
|
||||
// 保留密码供 set() 使用
|
||||
this.unlockPassword = password
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
disableLock(password: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
// 验证密码
|
||||
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
|
||||
if (storedHash !== inputHash) {
|
||||
return { success: false, error: '密码错误' }
|
||||
}
|
||||
|
||||
// 先解密所有 lock: 字段
|
||||
if (this.unlockedKeys.size === 0) {
|
||||
this.unlock(password)
|
||||
}
|
||||
|
||||
// 将所有密钥转回 safe: 格式
|
||||
const decryptKey = this.unlockedKeys.get('decryptKey')
|
||||
const imageAesKey = this.unlockedKeys.get('imageAesKey')
|
||||
const imageXorKey = this.unlockedKeys.get('imageXorKey')
|
||||
|
||||
if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any)
|
||||
if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any)
|
||||
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any)
|
||||
|
||||
// 转换 wxidConfigs
|
||||
const wxidConfigs = this.get('wxidConfigs')
|
||||
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||
const safeConfigs = this.encryptWxidConfigs(wxidConfigs)
|
||||
this.store.set('wxidConfigs', safeConfigs)
|
||||
}
|
||||
|
||||
// 清除 auth 字段
|
||||
this.store.set('authEnabled', false as any)
|
||||
this.store.set('authPassword', '' as any)
|
||||
this.store.set('authUseHello', false as any)
|
||||
this.store.set('authHelloSecret', '' as any)
|
||||
|
||||
// 清除运行时状态
|
||||
this.unlockedKeys.clear()
|
||||
this.unlockPassword = null
|
||||
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
// 验证旧密码
|
||||
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
|
||||
const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex')
|
||||
if (storedHash !== oldHash) {
|
||||
return { success: false, error: '旧密码错误' }
|
||||
}
|
||||
|
||||
// 确保已解锁
|
||||
if (this.unlockedKeys.size === 0) {
|
||||
this.unlock(oldPassword)
|
||||
}
|
||||
|
||||
// 用新密码重新加密所有密钥
|
||||
const decryptKey = this.unlockedKeys.get('decryptKey')
|
||||
const imageAesKey = this.unlockedKeys.get('imageAesKey')
|
||||
const imageXorKey = this.unlockedKeys.get('imageXorKey')
|
||||
|
||||
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any)
|
||||
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any)
|
||||
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any)
|
||||
|
||||
// 重新加密 wxidConfigs
|
||||
const wxidConfigs = this.get('wxidConfigs')
|
||||
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
|
||||
this.unlockPassword = newPassword
|
||||
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
|
||||
this.store.set('wxidConfigs', lockedConfigs)
|
||||
}
|
||||
|
||||
// 更新密码 hash
|
||||
const newHash = crypto.createHash('sha256').update(newPassword).digest('hex')
|
||||
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
|
||||
|
||||
// 更新 Hello secret(如果启用了 Hello)
|
||||
const useHello = this.get('authUseHello')
|
||||
if (useHello) {
|
||||
this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any)
|
||||
}
|
||||
|
||||
this.unlockPassword = newPassword
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
// === Hello 相关 ===
|
||||
|
||||
setHelloSecret(password: string): void {
|
||||
this.store.set('authHelloSecret', this.safeEncrypt(password) as any)
|
||||
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||||
}
|
||||
|
||||
getHelloSecret(): string {
|
||||
const raw: any = this.store.get('authHelloSecret')
|
||||
if (!raw || typeof raw !== 'string') return ''
|
||||
return this.safeDecrypt(raw)
|
||||
}
|
||||
|
||||
clearHelloSecret(): void {
|
||||
this.store.set('authHelloSecret', '' as any)
|
||||
this.store.set('authUseHello', this.safeEncrypt('false') as any)
|
||||
}
|
||||
|
||||
// === 迁移 ===
|
||||
|
||||
private migrateAuthFields(): void {
|
||||
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||||
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||||
const rawEnabled: any = this.store.get('authEnabled')
|
||||
if (typeof rawEnabled === 'boolean') {
|
||||
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
|
||||
}
|
||||
|
||||
const rawUseHello: any = this.store.get('authUseHello')
|
||||
if (typeof rawUseHello === 'boolean') {
|
||||
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
|
||||
}
|
||||
|
||||
const rawPassword: any = this.store.get('authPassword')
|
||||
if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) {
|
||||
this.store.set('authPassword', this.safeEncrypt(rawPassword) as any)
|
||||
}
|
||||
|
||||
// 迁移敏感密钥字段(明文 → safe:)
|
||||
for (const key of LOCKABLE_STRING_KEYS) {
|
||||
const raw: any = this.store.get(key as any)
|
||||
if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) {
|
||||
this.store.set(key as any, this.safeEncrypt(raw) as any)
|
||||
}
|
||||
}
|
||||
|
||||
// imageXorKey: 数字 → safe:
|
||||
const rawXor: any = this.store.get('imageXorKey')
|
||||
if (typeof rawXor === 'number' && rawXor !== 0) {
|
||||
this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any)
|
||||
}
|
||||
|
||||
// wxidConfigs 中的嵌套密钥
|
||||
const wxidConfigs: any = this.store.get('wxidConfigs')
|
||||
if (wxidConfigs && typeof wxidConfigs === 'object') {
|
||||
let changed = false
|
||||
for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
|
||||
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) {
|
||||
cfg.decryptKey = this.safeEncrypt(cfg.decryptKey)
|
||||
changed = true
|
||||
}
|
||||
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
|
||||
cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey)
|
||||
changed = true
|
||||
}
|
||||
if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) {
|
||||
cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.store.set('wxidConfigs', wxidConfigs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === 验证 ===
|
||||
|
||||
verifyAuthEnabled(): boolean {
|
||||
// 先检查 authEnabled 字段
|
||||
const rawEnabled: any = this.store.get('authEnabled')
|
||||
if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) {
|
||||
if (this.safeDecrypt(rawEnabled) === 'true') return true
|
||||
}
|
||||
|
||||
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||||
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// === 工具方法 ===
|
||||
|
||||
/**
|
||||
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置
|
||||
*/
|
||||
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
|
||||
const wxid = this.get('myWxid')
|
||||
if (wxid) {
|
||||
const wxidConfigs = this.get('wxidConfigs')
|
||||
const cfg = wxidConfigs?.[wxid]
|
||||
if (cfg && (cfg.imageXorKey !== undefined || cfg.imageAesKey)) {
|
||||
return {
|
||||
xorKey: cfg.imageXorKey ?? this.get('imageXorKey'),
|
||||
aesKey: cfg.imageAesKey ?? this.get('imageAesKey')
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
xorKey: this.get('imageXorKey'),
|
||||
aesKey: this.get('imageAesKey')
|
||||
}
|
||||
}
|
||||
|
||||
getCacheBasePath(): string {
|
||||
const configured = this.get('cachePath')
|
||||
if (configured && configured.trim().length > 0) {
|
||||
return configured
|
||||
}
|
||||
return join(app.getPath('documents'), 'WeFlow')
|
||||
return join(app.getPath('userData'), 'cache')
|
||||
}
|
||||
|
||||
getAll(): ConfigSchema {
|
||||
getAll(): Partial<ConfigSchema> {
|
||||
return this.store.store
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear()
|
||||
this.unlockedKeys.clear()
|
||||
this.unlockPassword = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,13 @@ export class DbPathService {
|
||||
const possiblePaths: string[] = []
|
||||
const home = homedir()
|
||||
|
||||
// 微信4.x 数据目录
|
||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||
// macOS 微信路径(固定)
|
||||
if (process.platform === 'darwin') {
|
||||
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
|
||||
} else {
|
||||
// Windows 微信4.x 数据目录
|
||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||
}
|
||||
|
||||
|
||||
for (const path of possiblePaths) {
|
||||
@@ -193,6 +198,9 @@ export class DbPathService {
|
||||
*/
|
||||
getDefaultPath(): string {
|
||||
const home = homedir()
|
||||
if (process.platform === 'darwin') {
|
||||
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
|
||||
}
|
||||
return join(home, 'Documents', 'xwechat_files')
|
||||
}
|
||||
}
|
||||
|
||||
354
electron/services/exportCardDiagnosticsService.ts
Normal file
354
electron/services/exportCardDiagnosticsService.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
|
||||
export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker'
|
||||
export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout'
|
||||
|
||||
export interface ExportCardDiagLogEntry {
|
||||
id: string
|
||||
ts: number
|
||||
source: ExportCardDiagSource
|
||||
level: ExportCardDiagLevel
|
||||
message: string
|
||||
traceId?: string
|
||||
stepId?: string
|
||||
stepName?: string
|
||||
status?: ExportCardDiagStatus
|
||||
durationMs?: number
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ActiveStepState {
|
||||
key: string
|
||||
traceId: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
source: ExportCardDiagSource
|
||||
startedAt: number
|
||||
lastUpdatedAt: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface StepStartInput {
|
||||
traceId: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
source: ExportCardDiagSource
|
||||
level?: ExportCardDiagLevel
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface StepEndInput {
|
||||
traceId: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
source: ExportCardDiagSource
|
||||
status?: Extract<ExportCardDiagStatus, 'done' | 'failed' | 'timeout'>
|
||||
level?: ExportCardDiagLevel
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface LogInput {
|
||||
ts?: number
|
||||
source: ExportCardDiagSource
|
||||
level?: ExportCardDiagLevel
|
||||
message: string
|
||||
traceId?: string
|
||||
stepId?: string
|
||||
stepName?: string
|
||||
status?: ExportCardDiagStatus
|
||||
durationMs?: number
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ExportCardDiagSnapshot {
|
||||
logs: ExportCardDiagLogEntry[]
|
||||
activeSteps: Array<{
|
||||
traceId: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
source: ExportCardDiagSource
|
||||
elapsedMs: number
|
||||
stallMs: number
|
||||
startedAt: number
|
||||
lastUpdatedAt: number
|
||||
message?: string
|
||||
}>
|
||||
summary: {
|
||||
totalLogs: number
|
||||
activeStepCount: number
|
||||
errorCount: number
|
||||
warnCount: number
|
||||
timeoutCount: number
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
}
|
||||
|
||||
export class ExportCardDiagnosticsService {
|
||||
private readonly maxLogs = 6000
|
||||
private logs: ExportCardDiagLogEntry[] = []
|
||||
private activeSteps = new Map<string, ActiveStepState>()
|
||||
private seq = 0
|
||||
|
||||
private nextId(ts: number): string {
|
||||
this.seq += 1
|
||||
return `export-card-diag-${ts}-${this.seq}`
|
||||
}
|
||||
|
||||
private trimLogs() {
|
||||
if (this.logs.length <= this.maxLogs) return
|
||||
const drop = this.logs.length - this.maxLogs
|
||||
this.logs.splice(0, drop)
|
||||
}
|
||||
|
||||
log(input: LogInput): ExportCardDiagLogEntry {
|
||||
const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now()
|
||||
const entry: ExportCardDiagLogEntry = {
|
||||
id: this.nextId(ts),
|
||||
ts,
|
||||
source: input.source,
|
||||
level: input.level || 'info',
|
||||
message: input.message,
|
||||
traceId: input.traceId,
|
||||
stepId: input.stepId,
|
||||
stepName: input.stepName,
|
||||
status: input.status,
|
||||
durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined,
|
||||
data: input.data
|
||||
}
|
||||
|
||||
this.logs.push(entry)
|
||||
this.trimLogs()
|
||||
|
||||
if (entry.traceId && entry.stepId && entry.stepName) {
|
||||
const key = `${entry.traceId}::${entry.stepId}`
|
||||
if (entry.status === 'running') {
|
||||
const previous = this.activeSteps.get(key)
|
||||
this.activeSteps.set(key, {
|
||||
key,
|
||||
traceId: entry.traceId,
|
||||
stepId: entry.stepId,
|
||||
stepName: entry.stepName,
|
||||
source: entry.source,
|
||||
startedAt: previous?.startedAt || entry.ts,
|
||||
lastUpdatedAt: entry.ts,
|
||||
message: entry.message
|
||||
})
|
||||
} else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') {
|
||||
this.activeSteps.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
stepStart(input: StepStartInput): ExportCardDiagLogEntry {
|
||||
return this.log({
|
||||
source: input.source,
|
||||
level: input.level || 'info',
|
||||
message: input.message || `${input.stepName} 开始`,
|
||||
traceId: input.traceId,
|
||||
stepId: input.stepId,
|
||||
stepName: input.stepName,
|
||||
status: 'running',
|
||||
data: input.data
|
||||
})
|
||||
}
|
||||
|
||||
stepEnd(input: StepEndInput): ExportCardDiagLogEntry {
|
||||
return this.log({
|
||||
source: input.source,
|
||||
level: input.level || (input.status === 'done' ? 'info' : 'warn'),
|
||||
message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`,
|
||||
traceId: input.traceId,
|
||||
stepId: input.stepId,
|
||||
stepName: input.stepName,
|
||||
status: input.status || 'done',
|
||||
durationMs: input.durationMs,
|
||||
data: input.data
|
||||
})
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.logs = []
|
||||
this.activeSteps.clear()
|
||||
}
|
||||
|
||||
snapshot(limit = 1200): ExportCardDiagSnapshot {
|
||||
const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200
|
||||
const logs = this.logs.slice(-capped)
|
||||
const now = Date.now()
|
||||
|
||||
const activeSteps = Array.from(this.activeSteps.values())
|
||||
.map(step => ({
|
||||
traceId: step.traceId,
|
||||
stepId: step.stepId,
|
||||
stepName: step.stepName,
|
||||
source: step.source,
|
||||
startedAt: step.startedAt,
|
||||
lastUpdatedAt: step.lastUpdatedAt,
|
||||
elapsedMs: Math.max(0, now - step.startedAt),
|
||||
stallMs: Math.max(0, now - step.lastUpdatedAt),
|
||||
message: step.message
|
||||
}))
|
||||
.sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt)
|
||||
|
||||
let errorCount = 0
|
||||
let warnCount = 0
|
||||
let timeoutCount = 0
|
||||
for (const item of logs) {
|
||||
if (item.level === 'error') errorCount += 1
|
||||
if (item.level === 'warn') warnCount += 1
|
||||
if (item.status === 'timeout') timeoutCount += 1
|
||||
}
|
||||
|
||||
return {
|
||||
logs,
|
||||
activeSteps,
|
||||
summary: {
|
||||
totalLogs: this.logs.length,
|
||||
activeStepCount: activeSteps.length,
|
||||
errorCount,
|
||||
warnCount,
|
||||
timeoutCount,
|
||||
lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] {
|
||||
const result: ExportCardDiagLogEntry[] = []
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const row = item as Record<string, unknown>
|
||||
const tsRaw = row.ts ?? row.timestamp
|
||||
const tsNum = Number(tsRaw)
|
||||
const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now()
|
||||
|
||||
const sourceRaw = String(row.source || 'frontend')
|
||||
const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker'
|
||||
? sourceRaw
|
||||
: 'frontend'
|
||||
const levelRaw = String(row.level || 'info')
|
||||
const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error'
|
||||
? levelRaw
|
||||
: 'info'
|
||||
|
||||
const statusRaw = String(row.status || '')
|
||||
const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout'
|
||||
? statusRaw
|
||||
: undefined
|
||||
|
||||
const durationRaw = Number(row.durationMs)
|
||||
result.push({
|
||||
id: String(row.id || this.nextId(ts)),
|
||||
ts,
|
||||
source,
|
||||
level,
|
||||
message: String(row.message || ''),
|
||||
traceId: typeof row.traceId === 'string' ? row.traceId : undefined,
|
||||
stepId: typeof row.stepId === 'string' ? row.stepId : undefined,
|
||||
stepName: typeof row.stepName === 'string' ? row.stepName : undefined,
|
||||
status,
|
||||
durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined,
|
||||
data: row.data && typeof row.data === 'object' ? row.data as Record<string, unknown> : undefined
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private serializeLogEntry(log: ExportCardDiagLogEntry): string {
|
||||
return JSON.stringify(log)
|
||||
}
|
||||
|
||||
private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string {
|
||||
const total = logs.length
|
||||
let errorCount = 0
|
||||
let warnCount = 0
|
||||
let timeoutCount = 0
|
||||
let frontendCount = 0
|
||||
let backendCount = 0
|
||||
let mainCount = 0
|
||||
let workerCount = 0
|
||||
|
||||
for (const item of logs) {
|
||||
if (item.level === 'error') errorCount += 1
|
||||
if (item.level === 'warn') warnCount += 1
|
||||
if (item.status === 'timeout') timeoutCount += 1
|
||||
if (item.source === 'frontend') frontendCount += 1
|
||||
if (item.source === 'backend') backendCount += 1
|
||||
if (item.source === 'main') mainCount += 1
|
||||
if (item.source === 'worker') workerCount += 1
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('WeFlow 导出卡片诊断摘要')
|
||||
lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`)
|
||||
lines.push(`日志总数: ${total}`)
|
||||
lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`)
|
||||
lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`)
|
||||
lines.push(`当前活跃步骤: ${activeSteps.length}`)
|
||||
|
||||
if (activeSteps.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('活跃步骤:')
|
||||
for (const step of activeSteps.slice(0, 12)) {
|
||||
lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12)
|
||||
if (latestErrors.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('最近异常:')
|
||||
for (const item of latestErrors) {
|
||||
lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{
|
||||
success: boolean
|
||||
filePath?: string
|
||||
summaryPath?: string
|
||||
count?: number
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : [])
|
||||
const merged = [...this.logs, ...normalizedFrontend]
|
||||
.sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id))
|
||||
|
||||
const lines = merged.map(item => this.serializeLogEntry(item)).join('\n')
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8')
|
||||
|
||||
const ext = extname(filePath)
|
||||
const baseName = ext ? basename(filePath, ext) : basename(filePath)
|
||||
const summaryPath = join(dirname(filePath), `${baseName}.txt`)
|
||||
const snapshot = this.snapshot(1500)
|
||||
const summaryText = this.buildSummaryText(merged, snapshot.activeSteps)
|
||||
await writeFile(summaryPath, summaryText, 'utf8')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
summaryPath,
|
||||
count: merged.length
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const exportCardDiagnosticsService = new ExportCardDiagnosticsService()
|
||||
229
electron/services/exportContentStatsCacheService.ts
Normal file
229
electron/services/exportContentStatsCacheService.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
const CACHE_VERSION = 1
|
||||
const MAX_SCOPE_ENTRIES = 12
|
||||
const MAX_SESSION_ENTRIES_PER_SCOPE = 6000
|
||||
|
||||
export interface ExportContentSessionStatsEntry {
|
||||
updatedAt: number
|
||||
hasAny: boolean
|
||||
hasVoice: boolean
|
||||
hasImage: boolean
|
||||
hasVideo: boolean
|
||||
hasEmoji: boolean
|
||||
mediaReady: boolean
|
||||
}
|
||||
|
||||
export interface ExportContentScopeStatsEntry {
|
||||
updatedAt: number
|
||||
sessions: Record<string, ExportContentSessionStatsEntry>
|
||||
}
|
||||
|
||||
interface ExportContentStatsStore {
|
||||
version: number
|
||||
scopes: Record<string, ExportContentScopeStatsEntry>
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||
return Math.max(0, Math.floor(value))
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown, fallback = false): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeSessionStatsEntry(raw: unknown): ExportContentSessionStatsEntry | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||
if (updatedAt === undefined) return null
|
||||
return {
|
||||
updatedAt,
|
||||
hasAny: toBoolean(source.hasAny, false),
|
||||
hasVoice: toBoolean(source.hasVoice, false),
|
||||
hasImage: toBoolean(source.hasImage, false),
|
||||
hasVideo: toBoolean(source.hasVideo, false),
|
||||
hasEmoji: toBoolean(source.hasEmoji, false),
|
||||
mediaReady: toBoolean(source.mediaReady, false)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeScopeStatsEntry(raw: unknown): ExportContentScopeStatsEntry | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||
if (updatedAt === undefined) return null
|
||||
|
||||
const sessionsRaw = source.sessions
|
||||
if (!sessionsRaw || typeof sessionsRaw !== 'object') {
|
||||
return {
|
||||
updatedAt,
|
||||
sessions: {}
|
||||
}
|
||||
}
|
||||
|
||||
const sessions: Record<string, ExportContentSessionStatsEntry> = {}
|
||||
for (const [sessionId, entryRaw] of Object.entries(sessionsRaw as Record<string, unknown>)) {
|
||||
const normalized = normalizeSessionStatsEntry(entryRaw)
|
||||
if (!normalized) continue
|
||||
sessions[sessionId] = normalized
|
||||
}
|
||||
|
||||
return {
|
||||
updatedAt,
|
||||
sessions
|
||||
}
|
||||
}
|
||||
|
||||
function cloneScope(scope: ExportContentScopeStatsEntry): ExportContentScopeStatsEntry {
|
||||
return {
|
||||
updatedAt: scope.updatedAt,
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(scope.sessions).map(([sessionId, entry]) => [sessionId, { ...entry }])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class ExportContentStatsCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private store: ExportContentStatsStore = {
|
||||
version: CACHE_VERSION,
|
||||
scopes: {}
|
||||
}
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: ConfigService.getInstance().getCacheBasePath()
|
||||
this.cacheFilePath = join(basePath, 'export-content-stats.json')
|
||||
this.ensureCacheDir()
|
||||
this.load()
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const payload = parsed as Record<string, unknown>
|
||||
const scopesRaw = payload.scopes
|
||||
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const scopes: Record<string, ExportContentScopeStatsEntry> = {}
|
||||
for (const [scopeKey, scopeRaw] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||
const normalizedScope = normalizeScopeStatsEntry(scopeRaw)
|
||||
if (!normalizedScope) continue
|
||||
scopes[scopeKey] = normalizedScope
|
||||
}
|
||||
|
||||
this.store = {
|
||||
version: CACHE_VERSION,
|
||||
scopes
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ExportContentStatsCacheService: 载入缓存失败', error)
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
}
|
||||
}
|
||||
|
||||
getScope(scopeKey: string): ExportContentScopeStatsEntry | undefined {
|
||||
if (!scopeKey) return undefined
|
||||
const rawScope = this.store.scopes[scopeKey]
|
||||
if (!rawScope) return undefined
|
||||
const normalizedScope = normalizeScopeStatsEntry(rawScope)
|
||||
if (!normalizedScope) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
this.persist()
|
||||
return undefined
|
||||
}
|
||||
this.store.scopes[scopeKey] = normalizedScope
|
||||
return cloneScope(normalizedScope)
|
||||
}
|
||||
|
||||
setScope(scopeKey: string, scope: ExportContentScopeStatsEntry): void {
|
||||
if (!scopeKey) return
|
||||
const normalized = normalizeScopeStatsEntry(scope)
|
||||
if (!normalized) return
|
||||
this.store.scopes[scopeKey] = normalized
|
||||
this.trimScope(scopeKey)
|
||||
this.trimScopes()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
deleteSession(scopeKey: string, sessionId: string): void {
|
||||
if (!scopeKey || !sessionId) return
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
if (!(sessionId in scope.sessions)) return
|
||||
delete scope.sessions[sessionId]
|
||||
if (Object.keys(scope.sessions).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
} else {
|
||||
scope.updatedAt = Date.now()
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearScope(scopeKey: string): void {
|
||||
if (!scopeKey) return
|
||||
if (!this.store.scopes[scopeKey]) return
|
||||
delete this.store.scopes[scopeKey]
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
try {
|
||||
rmSync(this.cacheFilePath, { force: true })
|
||||
} catch (error) {
|
||||
console.error('ExportContentStatsCacheService: 清理缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
private trimScope(scopeKey: string): void {
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
|
||||
const entries = Object.entries(scope.sessions)
|
||||
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
|
||||
|
||||
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
scope.sessions = Object.fromEntries(entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE))
|
||||
}
|
||||
|
||||
private trimScopes(): void {
|
||||
const scopeEntries = Object.entries(this.store.scopes)
|
||||
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||
|
||||
scopeEntries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
this.store.scopes = Object.fromEntries(scopeEntries.slice(0, MAX_SCOPE_ENTRIES))
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
this.ensureCacheDir()
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('ExportContentStatsCacheService: 持久化缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
electron/services/exportRecordService.ts
Normal file
95
electron/services/exportRecordService.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export interface ExportRecord {
|
||||
exportTime: number
|
||||
format: string
|
||||
messageCount: number
|
||||
sourceLatestMessageTimestamp?: number
|
||||
outputPath?: string
|
||||
}
|
||||
|
||||
type RecordStore = Record<string, ExportRecord[]>
|
||||
|
||||
class ExportRecordService {
|
||||
private filePath: string | null = null
|
||||
private loaded = false
|
||||
private store: RecordStore = {}
|
||||
|
||||
private resolveFilePath(): string {
|
||||
if (this.filePath) return this.filePath
|
||||
const userDataPath = app.getPath('userData')
|
||||
fs.mkdirSync(userDataPath, { recursive: true })
|
||||
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
|
||||
return this.filePath
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
const filePath = this.resolveFilePath()
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.store = parsed as RecordStore
|
||||
}
|
||||
} catch {
|
||||
this.store = {}
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
const filePath = this.resolveFilePath()
|
||||
fs.writeFileSync(filePath, JSON.stringify(this.store), 'utf-8')
|
||||
} catch {
|
||||
// ignore persist errors to avoid blocking export flow
|
||||
}
|
||||
}
|
||||
|
||||
getLatestRecord(sessionId: string, format: string): ExportRecord | null {
|
||||
this.ensureLoaded()
|
||||
const records = this.store[sessionId]
|
||||
if (!records || records.length === 0) return null
|
||||
for (let i = records.length - 1; i >= 0; i--) {
|
||||
const record = records[i]
|
||||
if (record && record.format === format) return record
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
saveRecord(
|
||||
sessionId: string,
|
||||
format: string,
|
||||
messageCount: number,
|
||||
extra?: {
|
||||
sourceLatestMessageTimestamp?: number
|
||||
outputPath?: string
|
||||
}
|
||||
): void {
|
||||
this.ensureLoaded()
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return
|
||||
if (!this.store[normalizedSessionId]) {
|
||||
this.store[normalizedSessionId] = []
|
||||
}
|
||||
const list = this.store[normalizedSessionId]
|
||||
list.push({
|
||||
exportTime: Date.now(),
|
||||
format,
|
||||
messageCount,
|
||||
sourceLatestMessageTimestamp: extra?.sourceLatestMessageTimestamp,
|
||||
outputPath: extra?.outputPath
|
||||
})
|
||||
// keep the latest 30 records per session
|
||||
if (list.length > 30) {
|
||||
this.store[normalizedSessionId] = list.slice(-30)
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
|
||||
export const exportRecordService = new ExportRecordService()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,12 @@ export interface GroupMember {
|
||||
alias?: string
|
||||
remark?: string
|
||||
groupNickname?: string
|
||||
isOwner?: boolean
|
||||
}
|
||||
|
||||
export interface GroupMembersPanelEntry extends GroupMember {
|
||||
isFriend: boolean
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export interface GroupMessageRank {
|
||||
@@ -43,8 +49,28 @@ export interface GroupMediaStats {
|
||||
total: number
|
||||
}
|
||||
|
||||
interface GroupMemberContactInfo {
|
||||
remark: string
|
||||
nickName: string
|
||||
alias: string
|
||||
username: string
|
||||
userName: string
|
||||
encryptUsername: string
|
||||
encryptUserName: string
|
||||
localType: number
|
||||
}
|
||||
|
||||
class GroupAnalyticsService {
|
||||
private configService: ConfigService
|
||||
private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly groupMembersPanelMembersTimeoutMs = 12 * 1000
|
||||
private readonly groupMembersPanelFullTimeoutMs = 25 * 1000
|
||||
private readonly groupMembersPanelCache = new Map<string, { updatedAt: number; data: GroupMembersPanelEntry[] }>()
|
||||
private readonly groupMembersPanelInFlight = new Map<
|
||||
string,
|
||||
Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }>
|
||||
>()
|
||||
private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
@@ -89,6 +115,128 @@ class GroupAnalyticsService {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private resolveMemberUsername(
|
||||
candidate: unknown,
|
||||
memberLookup: Map<string, string>
|
||||
): string | null {
|
||||
if (typeof candidate !== 'string') return null
|
||||
const raw = candidate.trim()
|
||||
if (!raw) return null
|
||||
if (memberLookup.has(raw)) return memberLookup.get(raw) || null
|
||||
const cleaned = this.cleanAccountDirName(raw)
|
||||
if (memberLookup.has(cleaned)) return memberLookup.get(cleaned) || null
|
||||
|
||||
const parts = raw.split(/[,\s;|]+/).filter(Boolean)
|
||||
for (const part of parts) {
|
||||
if (memberLookup.has(part)) return memberLookup.get(part) || null
|
||||
const normalizedPart = this.cleanAccountDirName(part)
|
||||
if (memberLookup.has(normalizedPart)) return memberLookup.get(normalizedPart) || null
|
||||
}
|
||||
|
||||
if ((raw.startsWith('{') || raw.startsWith('[')) && raw.length < 4096) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return this.extractOwnerUsername(parsed, memberLookup, 0)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private extractOwnerUsername(
|
||||
value: unknown,
|
||||
memberLookup: Map<string, string>,
|
||||
depth: number
|
||||
): string | null {
|
||||
if (depth > 4 || value == null) return null
|
||||
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return null
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return this.resolveMemberUsername(value, memberLookup)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const owner = this.extractOwnerUsername(item, memberLookup, depth + 1)
|
||||
if (owner) return owner
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') return null
|
||||
const row = value as Record<string, unknown>
|
||||
|
||||
for (const [key, entry] of Object.entries(row)) {
|
||||
const keyLower = key.toLowerCase()
|
||||
if (!keyLower.includes('owner') && !keyLower.includes('host') && !keyLower.includes('creator')) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof entry === 'boolean') {
|
||||
if (entry && typeof row.username === 'string') {
|
||||
const owner = this.resolveMemberUsername(row.username, memberLookup)
|
||||
if (owner) return owner
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const owner = this.extractOwnerUsername(entry, memberLookup, depth + 1)
|
||||
if (owner) return owner
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async detectGroupOwnerUsername(
|
||||
chatroomId: string,
|
||||
members: Array<{ username: string; [key: string]: unknown }>
|
||||
): Promise<string | undefined> {
|
||||
const memberLookup = new Map<string, string>()
|
||||
for (const member of members) {
|
||||
const username = String(member.username || '').trim()
|
||||
if (!username) continue
|
||||
const cleaned = this.cleanAccountDirName(username)
|
||||
memberLookup.set(username, username)
|
||||
memberLookup.set(cleaned, username)
|
||||
}
|
||||
if (memberLookup.size === 0) return undefined
|
||||
|
||||
const tryResolve = (candidate: unknown): string | undefined => {
|
||||
const owner = this.extractOwnerUsername(candidate, memberLookup, 0)
|
||||
return owner || undefined
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
const owner = tryResolve(member)
|
||||
if (owner) return owner
|
||||
}
|
||||
|
||||
try {
|
||||
const groupContact = await wcdbService.getContact(chatroomId)
|
||||
if (groupContact.success && groupContact.contact) {
|
||||
const owner = tryResolve(groupContact.contact)
|
||||
if (owner) return owner
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||
const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`)
|
||||
if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) {
|
||||
const owner = tryResolve(roomResult.rows[0])
|
||||
if (owner) return owner
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
@@ -296,6 +444,203 @@ class GroupAnalyticsService {
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
private toNonNegativeInteger(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
return Math.max(0, Math.floor(parsed))
|
||||
}
|
||||
|
||||
private pickStringField(row: Record<string, unknown>, keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = row[key]
|
||||
if (value == null) continue
|
||||
const text = String(value).trim()
|
||||
if (text) return text
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private pickIntegerField(row: Record<string, unknown>, keys: string[], fallback: number = 0): number {
|
||||
for (const key of keys) {
|
||||
const value = row[key]
|
||||
if (value == null || value === '') continue
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return Math.floor(parsed)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private buildGroupMembersPanelCacheKey(chatroomId: string, includeMessageCounts: boolean): string {
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim())
|
||||
const mode = includeMessageCounts ? 'full' : 'members'
|
||||
return `${dbPath}::${wxid}::${chatroomId}::${mode}`
|
||||
}
|
||||
|
||||
private pruneGroupMembersPanelCache(maxEntries: number = 80): void {
|
||||
if (this.groupMembersPanelCache.size <= maxEntries) return
|
||||
const entries = Array.from(this.groupMembersPanelCache.entries())
|
||||
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)
|
||||
const removeCount = this.groupMembersPanelCache.size - maxEntries
|
||||
for (let i = 0; i < removeCount; i += 1) {
|
||||
this.groupMembersPanelCache.delete(entries[i][0])
|
||||
}
|
||||
}
|
||||
|
||||
private async withPromiseTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
timeoutResult: T
|
||||
): Promise<T> {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
return promise
|
||||
}
|
||||
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const timeoutPromise = new Promise<T>((resolve) => {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
resolve(timeoutResult)
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise])
|
||||
} finally {
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async buildGroupMemberContactLookup(usernames: string[]): Promise<Map<string, GroupMemberContactInfo>> {
|
||||
const lookup = new Map<string, GroupMemberContactInfo>()
|
||||
const candidates = this.buildIdCandidates(usernames)
|
||||
if (candidates.length === 0) return lookup
|
||||
|
||||
const appendContactsToLookup = (rows: Record<string, unknown>[]) => {
|
||||
for (const row of rows) {
|
||||
const contact: GroupMemberContactInfo = {
|
||||
remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']),
|
||||
nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']),
|
||||
alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']),
|
||||
username: this.pickStringField(row, ['username', 'WCDB_CT_username']),
|
||||
userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']),
|
||||
encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']),
|
||||
encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']),
|
||||
localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
|
||||
}
|
||||
const lookupKeys = this.buildIdCandidates([
|
||||
contact.username,
|
||||
contact.userName,
|
||||
contact.encryptUsername,
|
||||
contact.encryptUserName,
|
||||
contact.alias
|
||||
])
|
||||
for (const key of lookupKeys) {
|
||||
const normalized = key.toLowerCase()
|
||||
if (!lookup.has(normalized)) {
|
||||
lookup.set(normalized, contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const batchSize = 200
|
||||
for (let i = 0; i < candidates.length; i += batchSize) {
|
||||
const batch = candidates.slice(i, i + batchSize)
|
||||
if (batch.length === 0) continue
|
||||
|
||||
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
||||
const lightweightSql = `
|
||||
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type
|
||||
FROM contact
|
||||
WHERE username IN (${inList})
|
||||
`
|
||||
let result = await wcdbService.execQuery('contact', null, lightweightSql)
|
||||
if (!result.success || !result.rows) {
|
||||
// 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失
|
||||
result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`)
|
||||
}
|
||||
if (!result.success || !result.rows) continue
|
||||
appendContactsToLookup(result.rows as Record<string, unknown>[])
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
private resolveContactByCandidates(
|
||||
lookup: Map<string, GroupMemberContactInfo>,
|
||||
candidates: Array<string | undefined | null>
|
||||
): GroupMemberContactInfo | undefined {
|
||||
const ids = this.buildIdCandidates(candidates)
|
||||
for (const id of ids) {
|
||||
const hit = lookup.get(id.toLowerCase())
|
||||
if (hit) return hit
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async buildGroupMessageCountLookup(chatroomId: string): Promise<Map<string, number>> {
|
||||
const lookup = new Map<string, number>()
|
||||
const result = await wcdbService.getGroupStats(chatroomId, 0, 0)
|
||||
if (!result.success || !result.data) return lookup
|
||||
|
||||
const sessionData = result.data?.sessions?.[chatroomId]
|
||||
if (!sessionData || !sessionData.senders) return lookup
|
||||
|
||||
const idMap = result.data.idMap || {}
|
||||
for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record<string, number>)) {
|
||||
const username = String(idMap[senderId] || senderId || '').trim()
|
||||
if (!username) continue
|
||||
const count = this.toNonNegativeInteger(rawCount)
|
||||
const keys = this.buildIdCandidates([username])
|
||||
for (const key of keys) {
|
||||
const normalized = key.toLowerCase()
|
||||
const prev = lookup.get(normalized) || 0
|
||||
if (count > prev) {
|
||||
lookup.set(normalized, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
private resolveMessageCountByCandidates(
|
||||
lookup: Map<string, number>,
|
||||
candidates: Array<string | undefined | null>
|
||||
): number {
|
||||
let maxCount = 0
|
||||
const ids = this.buildIdCandidates(candidates)
|
||||
for (const id of ids) {
|
||||
const count = lookup.get(id.toLowerCase())
|
||||
if (typeof count === 'number' && count > maxCount) {
|
||||
maxCount = count
|
||||
}
|
||||
}
|
||||
return maxCount
|
||||
}
|
||||
|
||||
private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean {
|
||||
const normalizedWxid = String(wxid || '').trim().toLowerCase()
|
||||
if (!normalizedWxid) return false
|
||||
if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false
|
||||
if (this.friendExcludeNames.has(normalizedWxid)) return false
|
||||
if (!contact) return false
|
||||
return contact.localType === 1
|
||||
}
|
||||
|
||||
private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] {
|
||||
return members.sort((a, b) => {
|
||||
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
|
||||
if (ownerDiff !== 0) return ownerDiff
|
||||
|
||||
const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend))
|
||||
if (friendDiff !== 0) return friendDiff
|
||||
|
||||
if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount
|
||||
return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')
|
||||
})
|
||||
}
|
||||
|
||||
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
||||
const idCandidates = this.buildIdCandidates(candidates)
|
||||
if (idCandidates.length === 0) return ''
|
||||
@@ -483,6 +828,167 @@ class GroupAnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadGroupMembersPanelDataFresh(
|
||||
chatroomId: string,
|
||||
includeMessageCounts: boolean
|
||||
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> {
|
||||
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
|
||||
[key: string]: unknown
|
||||
}>
|
||||
if (members.length === 0) return { success: true, data: [] }
|
||||
|
||||
const usernames = members
|
||||
.map((member) => String(member.username || '').trim())
|
||||
.filter(Boolean)
|
||||
if (usernames.length === 0) return { success: true, data: [] }
|
||||
|
||||
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||
const contactLookupPromise = this.buildGroupMemberContactLookup(usernames)
|
||||
const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members)
|
||||
const messageCountLookupPromise = includeMessageCounts
|
||||
? this.buildGroupMessageCountLookup(chatroomId)
|
||||
: Promise.resolve(new Map<string, number>())
|
||||
|
||||
const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([
|
||||
displayNamesPromise,
|
||||
contactLookupPromise,
|
||||
ownerPromise,
|
||||
messageCountLookupPromise
|
||||
])
|
||||
|
||||
const nicknameCandidates = this.buildIdCandidates([
|
||||
...members.map((member) => member.username),
|
||||
...members.map((member) => member.originalName),
|
||||
...Array.from(contactLookup.values()).map((contact) => contact?.username),
|
||||
...Array.from(contactLookup.values()).map((contact) => contact?.userName),
|
||||
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername),
|
||||
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName),
|
||||
...Array.from(contactLookup.values()).map((contact) => contact?.alias)
|
||||
])
|
||||
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||
let myGroupMessageCountHint: number | undefined
|
||||
|
||||
const data: GroupMembersPanelEntry[] = members
|
||||
.map((member) => {
|
||||
const wxid = String(member.username || '').trim()
|
||||
if (!wxid) return null
|
||||
|
||||
const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName])
|
||||
const nickname = contact?.nickName || ''
|
||||
const remark = contact?.remark || ''
|
||||
const alias = contact?.alias || ''
|
||||
const normalizedWxid = this.cleanAccountDirName(wxid)
|
||||
const lookupCandidates = this.buildIdCandidates([
|
||||
wxid,
|
||||
member.originalName as string | undefined,
|
||||
contact?.username,
|
||||
contact?.userName,
|
||||
contact?.encryptUsername,
|
||||
contact?.encryptUserName,
|
||||
alias
|
||||
])
|
||||
if (normalizedWxid === myWxid) {
|
||||
lookupCandidates.push(myWxid)
|
||||
}
|
||||
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
|
||||
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||
|
||||
return {
|
||||
username: wxid,
|
||||
displayName,
|
||||
nickname,
|
||||
alias,
|
||||
remark,
|
||||
groupNickname,
|
||||
avatarUrl: member.avatarUrl,
|
||||
isOwner: Boolean(ownerUsername && ownerUsername === wxid),
|
||||
isFriend: this.isFriendMember(wxid, contact),
|
||||
messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates)
|
||||
}
|
||||
})
|
||||
.filter((member): member is GroupMembersPanelEntry => Boolean(member))
|
||||
|
||||
if (includeMessageCounts && myWxid) {
|
||||
const selfEntry = data.find((member) => this.cleanAccountDirName(member.username) === myWxid)
|
||||
if (selfEntry && Number.isFinite(selfEntry.messageCount)) {
|
||||
myGroupMessageCountHint = Math.max(0, Math.floor(selfEntry.messageCount))
|
||||
}
|
||||
}
|
||||
|
||||
if (includeMessageCounts && Number.isFinite(myGroupMessageCountHint)) {
|
||||
void chatService.setGroupMyMessageCountHint(chatroomId, myGroupMessageCountHint as number)
|
||||
}
|
||||
|
||||
return { success: true, data: this.sortGroupMembersPanelEntries(data) }
|
||||
}
|
||||
|
||||
async getGroupMembersPanelData(
|
||||
chatroomId: string,
|
||||
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> {
|
||||
try {
|
||||
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||
|
||||
const forceRefresh = Boolean(options?.forceRefresh)
|
||||
const includeMessageCounts = options?.includeMessageCounts !== false
|
||||
const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId, includeMessageCounts)
|
||||
const now = Date.now()
|
||||
const cached = this.groupMembersPanelCache.get(cacheKey)
|
||||
if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) {
|
||||
return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt }
|
||||
}
|
||||
|
||||
if (!forceRefresh) {
|
||||
const pending = this.groupMembersPanelInFlight.get(cacheKey)
|
||||
if (pending) return pending
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success) return { success: false, error: conn.error }
|
||||
|
||||
const timeoutMs = includeMessageCounts
|
||||
? this.groupMembersPanelFullTimeoutMs
|
||||
: this.groupMembersPanelMembersTimeoutMs
|
||||
const fresh = await this.withPromiseTimeout(
|
||||
this.loadGroupMembersPanelDataFresh(normalizedChatroomId, includeMessageCounts),
|
||||
timeoutMs,
|
||||
{
|
||||
success: false,
|
||||
error: includeMessageCounts
|
||||
? '群成员发言统计加载超时,请稍后重试'
|
||||
: '群成员列表加载超时,请稍后重试'
|
||||
}
|
||||
)
|
||||
if (!fresh.success || !fresh.data) {
|
||||
return { success: false, error: fresh.error || '获取群成员面板数据失败' }
|
||||
}
|
||||
|
||||
const updatedAt = Date.now()
|
||||
this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data })
|
||||
this.pruneGroupMembersPanelCache()
|
||||
return { success: true, data: fresh.data, fromCache: false, updatedAt }
|
||||
})().finally(() => {
|
||||
this.groupMembersPanelInFlight.delete(cacheKey)
|
||||
})
|
||||
|
||||
this.groupMembersPanelInFlight.set(cacheKey, requestPromise)
|
||||
return await requestPromise
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
@@ -497,6 +1003,7 @@ class GroupAnalyticsService {
|
||||
username: string
|
||||
avatarUrl?: string
|
||||
originalName?: string
|
||||
[key: string]: unknown
|
||||
}>
|
||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||
|
||||
@@ -543,6 +1050,7 @@ class GroupAnalyticsService {
|
||||
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||
|
||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||
const ownerUsername = await this.detectGroupOwnerUsername(chatroomId, members)
|
||||
const data: GroupMember[] = members.map((m) => {
|
||||
const wxid = m.username || ''
|
||||
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||
@@ -572,7 +1080,8 @@ class GroupAnalyticsService {
|
||||
alias,
|
||||
remark,
|
||||
groupNickname,
|
||||
avatarUrl: m.avatarUrl
|
||||
avatarUrl: m.avatarUrl,
|
||||
isOwner: Boolean(ownerUsername && ownerUsername === wxid)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
204
electron/services/groupMyMessageCountCacheService.ts
Normal file
204
electron/services/groupMyMessageCountCacheService.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
const CACHE_VERSION = 1
|
||||
const MAX_GROUP_ENTRIES_PER_SCOPE = 3000
|
||||
const MAX_SCOPE_ENTRIES = 12
|
||||
|
||||
export interface GroupMyMessageCountCacheEntry {
|
||||
updatedAt: number
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
interface GroupMyMessageCountScopeMap {
|
||||
[chatroomId: string]: GroupMyMessageCountCacheEntry
|
||||
}
|
||||
|
||||
interface GroupMyMessageCountCacheStore {
|
||||
version: number
|
||||
scopes: Record<string, GroupMyMessageCountScopeMap>
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||
return Math.max(0, Math.floor(value))
|
||||
}
|
||||
|
||||
function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||
const messageCount = toNonNegativeInt(source.messageCount)
|
||||
if (updatedAt === undefined || messageCount === undefined) return null
|
||||
return {
|
||||
updatedAt,
|
||||
messageCount
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupMyMessageCountCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private store: GroupMyMessageCountCacheStore = {
|
||||
version: CACHE_VERSION,
|
||||
scopes: {}
|
||||
}
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: ConfigService.getInstance().getCacheBasePath()
|
||||
this.cacheFilePath = join(basePath, 'group-my-message-counts.json')
|
||||
this.ensureCacheDir()
|
||||
this.load()
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const payload = parsed as Record<string, unknown>
|
||||
const scopesRaw = payload.scopes
|
||||
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const scopes: Record<string, GroupMyMessageCountScopeMap> = {}
|
||||
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||
if (!scopeValue || typeof scopeValue !== 'object') continue
|
||||
const normalizedScope: GroupMyMessageCountScopeMap = {}
|
||||
for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
|
||||
const entry = normalizeEntry(entryRaw)
|
||||
if (!entry) continue
|
||||
normalizedScope[chatroomId] = entry
|
||||
}
|
||||
if (Object.keys(normalizedScope).length > 0) {
|
||||
scopes[scopeKey] = normalizedScope
|
||||
}
|
||||
}
|
||||
|
||||
this.store = {
|
||||
version: CACHE_VERSION,
|
||||
scopes
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('GroupMyMessageCountCacheService: 载入缓存失败', error)
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
}
|
||||
}
|
||||
|
||||
get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined {
|
||||
if (!scopeKey || !chatroomId) return undefined
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return undefined
|
||||
const entry = normalizeEntry(scope[chatroomId])
|
||||
if (!entry) {
|
||||
delete scope[chatroomId]
|
||||
if (Object.keys(scope).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
}
|
||||
this.persist()
|
||||
return undefined
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void {
|
||||
if (!scopeKey || !chatroomId) return
|
||||
const normalized = normalizeEntry(entry)
|
||||
if (!normalized) return
|
||||
|
||||
if (!this.store.scopes[scopeKey]) {
|
||||
this.store.scopes[scopeKey] = {}
|
||||
}
|
||||
|
||||
const existing = this.store.scopes[scopeKey][chatroomId]
|
||||
if (existing && existing.updatedAt > normalized.updatedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
this.store.scopes[scopeKey][chatroomId] = normalized
|
||||
this.trimScope(scopeKey)
|
||||
this.trimScopes()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
delete(scopeKey: string, chatroomId: string): void {
|
||||
if (!scopeKey || !chatroomId) return
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
if (!(chatroomId in scope)) return
|
||||
delete scope[chatroomId]
|
||||
if (Object.keys(scope).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearScope(scopeKey: string): void {
|
||||
if (!scopeKey) return
|
||||
if (!this.store.scopes[scopeKey]) return
|
||||
delete this.store.scopes[scopeKey]
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
try {
|
||||
rmSync(this.cacheFilePath, { force: true })
|
||||
} catch (error) {
|
||||
console.error('GroupMyMessageCountCacheService: 清理缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
private trimScope(scopeKey: string): void {
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
const entries = Object.entries(scope)
|
||||
if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return
|
||||
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
const trimmed: GroupMyMessageCountScopeMap = {}
|
||||
for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) {
|
||||
trimmed[chatroomId] = entry
|
||||
}
|
||||
this.store.scopes[scopeKey] = trimmed
|
||||
}
|
||||
|
||||
private trimScopes(): void {
|
||||
const scopeEntries = Object.entries(this.store.scopes)
|
||||
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||
scopeEntries.sort((a, b) => {
|
||||
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
|
||||
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
|
||||
return bUpdatedAt - aUpdatedAt
|
||||
})
|
||||
|
||||
const trimmedScopes: Record<string, GroupMyMessageCountScopeMap> = {}
|
||||
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
|
||||
trimmedScopes[scopeKey] = scopeMap
|
||||
}
|
||||
this.store.scopes = trimmedScopes
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('GroupMyMessageCountCacheService: 保存缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { chatService, Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { videoService } from './videoService'
|
||||
import { imageDecryptService } from './imageDecryptService'
|
||||
|
||||
// ChatLab 格式定义
|
||||
interface ChatLabHeader {
|
||||
@@ -69,6 +70,7 @@ interface ApiExportedMedia {
|
||||
kind: MediaKind
|
||||
fileName: string
|
||||
fullPath: string
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
// ChatLab 消息类型映射
|
||||
@@ -100,6 +102,7 @@ class HttpService {
|
||||
private port: number = 5031
|
||||
private running: boolean = false
|
||||
private connections: Set<import('net').Socket> = new Set()
|
||||
private connectionMutex: boolean = false
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
@@ -120,9 +123,20 @@ class HttpService {
|
||||
|
||||
// 跟踪所有连接,以便关闭时能强制断开
|
||||
this.server.on('connection', (socket) => {
|
||||
this.connections.add(socket)
|
||||
// 使用互斥锁防止并发修改
|
||||
if (!this.connectionMutex) {
|
||||
this.connectionMutex = true
|
||||
this.connections.add(socket)
|
||||
this.connectionMutex = false
|
||||
}
|
||||
|
||||
socket.on('close', () => {
|
||||
this.connections.delete(socket)
|
||||
// 使用互斥锁防止并发修改
|
||||
if (!this.connectionMutex) {
|
||||
this.connectionMutex = true
|
||||
this.connections.delete(socket)
|
||||
this.connectionMutex = false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -150,11 +164,20 @@ class HttpService {
|
||||
async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
// 强制关闭所有活动连接
|
||||
for (const socket of this.connections) {
|
||||
socket.destroy()
|
||||
}
|
||||
// 使用互斥锁保护连接集合操作
|
||||
this.connectionMutex = true
|
||||
const socketsToClose = Array.from(this.connections)
|
||||
this.connections.clear()
|
||||
this.connectionMutex = false
|
||||
|
||||
// 强制关闭所有活动连接
|
||||
for (const socket of socketsToClose) {
|
||||
try {
|
||||
socket.destroy()
|
||||
} catch (err) {
|
||||
console.error('[HttpService] Error destroying socket:', err)
|
||||
}
|
||||
}
|
||||
|
||||
this.server.close(() => {
|
||||
this.running = false
|
||||
@@ -215,6 +238,8 @@ class HttpService {
|
||||
await this.handleSessions(url, res)
|
||||
} else if (pathname === '/api/v1/contacts') {
|
||||
await this.handleContacts(url, res)
|
||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||
this.handleMediaRequest(pathname, res)
|
||||
} else {
|
||||
this.sendError(res, 404, 'Not Found')
|
||||
}
|
||||
@@ -224,6 +249,40 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
||||
const mediaBasePath = this.getApiMediaExportPath()
|
||||
const relativePath = pathname.replace('/api/v1/media/', '')
|
||||
const fullPath = path.join(mediaBasePath, relativePath)
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
this.sendError(res, 404, 'Media not found')
|
||||
return
|
||||
}
|
||||
|
||||
const ext = path.extname(fullPath).toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.wav': 'audio/wav',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4'
|
||||
}
|
||||
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||
|
||||
try {
|
||||
const fileBuffer = fs.readFileSync(fullPath)
|
||||
res.setHeader('Content-Type', contentType)
|
||||
res.setHeader('Content-Length', fileBuffer.length)
|
||||
res.writeHead(200)
|
||||
res.end(fileBuffer)
|
||||
} catch (e) {
|
||||
this.sendError(res, 500, 'Failed to read media file')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取消息(循环游标直到满足 limit)
|
||||
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
||||
@@ -281,6 +340,7 @@ class HttpService {
|
||||
const trimmedRows = allRows.slice(0, limit)
|
||||
const finalHasMore = hasMore || allRows.length > limit
|
||||
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
||||
await this.backfillMissingSenderUsernames(talker, messages)
|
||||
return { success: true, messages, hasMore: finalHasMore }
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
@@ -300,6 +360,41 @@ class HttpService {
|
||||
return Math.min(Math.max(parsed, min), max)
|
||||
}
|
||||
|
||||
private async backfillMissingSenderUsernames(talker: string, messages: Message[]): Promise<void> {
|
||||
if (!talker.endsWith('@chatroom')) return
|
||||
|
||||
const targets = messages.filter((msg) => !String(msg.senderUsername || '').trim())
|
||||
if (targets.length === 0) return
|
||||
|
||||
const myWxid = (this.configService.get('myWxid') || '').trim()
|
||||
for (const msg of targets) {
|
||||
const localId = Number(msg.localId || 0)
|
||||
if (Number.isFinite(localId) && localId > 0) {
|
||||
try {
|
||||
const detail = await wcdbService.getMessageById(talker, localId)
|
||||
if (detail.success && detail.message) {
|
||||
const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0]
|
||||
if (hydrated?.senderUsername) {
|
||||
msg.senderUsername = hydrated.senderUsername
|
||||
}
|
||||
if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) {
|
||||
msg.isSend = hydrated.isSend
|
||||
}
|
||||
if (!msg.rawContent && hydrated?.rawContent) {
|
||||
msg.rawContent = hydrated.rawContent
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HttpService] backfill sender failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
|
||||
msg.senderUsername = myWxid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
||||
for (const key of keys) {
|
||||
const raw = url.searchParams.get(key)
|
||||
@@ -359,7 +454,7 @@ class HttpService {
|
||||
const queryOffset = keyword ? 0 : offset
|
||||
const queryLimit = keyword ? 10000 : limit
|
||||
|
||||
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true)
|
||||
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false)
|
||||
if (!result.success || !result.messages) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||
return
|
||||
@@ -555,19 +650,44 @@ class HttpService {
|
||||
): 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)
|
||||
const result = await imageDecryptService.decryptImage({
|
||||
sessionId: talker,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName,
|
||||
force: true
|
||||
})
|
||||
if (result.success && result.localPath) {
|
||||
let imagePath = result.localPath
|
||||
if (imagePath.startsWith('data:')) {
|
||||
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
||||
if (base64Match) {
|
||||
const imageBuffer = Buffer.from(base64Match[1], '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)
|
||||
}
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
}
|
||||
} else if (fs.existsSync(imagePath)) {
|
||||
const imageBuffer = fs.readFileSync(imagePath)
|
||||
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.copyFileSync(imagePath, fullPath)
|
||||
}
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
}
|
||||
return { kind: 'image', fileName, fullPath }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,7 +706,8 @@ class HttpService {
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
||||
}
|
||||
return { kind: 'voice', fileName, fullPath }
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/voices/${fileName}`
|
||||
return { kind: 'voice', fileName, fullPath, relativePath }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,7 +722,8 @@ class HttpService {
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.copyFileSync(info.videoUrl, fullPath)
|
||||
}
|
||||
return { kind: 'video', fileName, fullPath }
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/videos/${fileName}`
|
||||
return { kind: 'video', fileName, fullPath, relativePath }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,7 +738,8 @@ class HttpService {
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.copyFileSync(result.localPath, fullPath)
|
||||
}
|
||||
return { kind: 'emoji', fileName, fullPath }
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/emojis/${fileName}`
|
||||
return { kind: 'emoji', fileName, fullPath, relativePath }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -640,7 +763,8 @@ class HttpService {
|
||||
parsedContent: msg.parsedContent,
|
||||
mediaType: media?.kind,
|
||||
mediaFileName: media?.fileName,
|
||||
mediaPath: media?.fullPath
|
||||
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
||||
mediaLocalPath: media?.fullPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,6 +814,49 @@ class HttpService {
|
||||
return {}
|
||||
}
|
||||
|
||||
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
|
||||
if (!sender) return ''
|
||||
return groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || ''
|
||||
}
|
||||
|
||||
private resolveChatLabSenderInfo(
|
||||
msg: Message,
|
||||
talkerId: string,
|
||||
talkerName: string,
|
||||
myWxid: string,
|
||||
isGroup: boolean,
|
||||
senderNames: Record<string, string>,
|
||||
groupNicknamesMap: Map<string, string>
|
||||
): { sender: string; accountName: string; groupNickname?: string } {
|
||||
let sender = String(msg.senderUsername || '').trim()
|
||||
let usedUnknownPlaceholder = false
|
||||
const sameAsMe = sender && myWxid && sender.toLowerCase() === myWxid.toLowerCase()
|
||||
const isSelf = msg.isSend === 1 || sameAsMe
|
||||
|
||||
if (!sender && isSelf && myWxid) {
|
||||
sender = myWxid
|
||||
}
|
||||
|
||||
if (!sender) {
|
||||
if (msg.localType === 10000 || msg.localType === 266287972401) {
|
||||
sender = talkerId
|
||||
} else {
|
||||
sender = `unknown_sender_${msg.localId || msg.createTime || 0}`
|
||||
usedUnknownPlaceholder = true
|
||||
}
|
||||
}
|
||||
|
||||
const groupNickname = isGroup ? this.lookupGroupNickname(groupNicknamesMap, sender) : ''
|
||||
const displayName = senderNames[sender] || groupNickname || (usedUnknownPlaceholder ? '' : sender)
|
||||
const accountName = isSelf ? '我' : (displayName || '未知发送者')
|
||||
|
||||
return {
|
||||
sender,
|
||||
accountName,
|
||||
groupNickname: groupNickname || undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 ChatLab 格式
|
||||
*/
|
||||
@@ -729,41 +896,29 @@ class HttpService {
|
||||
// 构建成员列表
|
||||
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 senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
|
||||
if (!memberMap.has(senderInfo.sender)) {
|
||||
memberMap.set(senderInfo.sender, {
|
||||
platformId: senderInfo.sender,
|
||||
accountName: senderInfo.accountName,
|
||||
groupNickname: senderInfo.groupNickname
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 转换消息
|
||||
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()) || '')
|
||||
: ''
|
||||
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
|
||||
|
||||
return {
|
||||
sender,
|
||||
accountName,
|
||||
groupNickname: groupNickname || undefined,
|
||||
sender: senderInfo.sender,
|
||||
accountName: senderInfo.accountName,
|
||||
groupNickname: senderInfo.groupNickname,
|
||||
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
|
||||
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||
@@ -11,7 +11,29 @@ import { wcdbService } from './wcdbService'
|
||||
// 获取 ffmpeg-static 的路径
|
||||
function getStaticFfmpegPath(): string | null {
|
||||
try {
|
||||
// 优先处理打包后的路径
|
||||
// 方法1: 直接 require ffmpeg-static
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ffmpegStatic = require('ffmpeg-static')
|
||||
|
||||
if (typeof ffmpegStatic === 'string') {
|
||||
// 修复:如果路径包含 app.asar(打包后),自动替换为 app.asar.unpacked
|
||||
let fixedPath = ffmpegStatic
|
||||
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
|
||||
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
|
||||
}
|
||||
|
||||
if (existsSync(fixedPath)) {
|
||||
return fixedPath
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2: 手动构建路径(开发环境)
|
||||
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(devPath)) {
|
||||
return devPath
|
||||
}
|
||||
|
||||
// 方法3: 打包后的路径
|
||||
if (app.isPackaged) {
|
||||
const resourcesPath = process.resourcesPath
|
||||
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
@@ -20,20 +42,6 @@ function getStaticFfmpegPath(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// 方法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
|
||||
@@ -240,7 +248,9 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
const xorKeyRaw = this.configService.get('imageXorKey') as unknown
|
||||
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
|
||||
const imageKeys = this.configService.getImageKeysForCurrentWxid()
|
||||
const xorKeyRaw = imageKeys.xorKey
|
||||
// 支持十六进制格式(如 0x53)和十进制格式
|
||||
let xorKey: number
|
||||
if (typeof xorKeyRaw === 'number') {
|
||||
@@ -257,7 +267,7 @@ export class ImageDecryptService {
|
||||
return { success: false, error: '未配置图片解密密钥' }
|
||||
}
|
||||
|
||||
const aesKeyRaw = this.configService.get('imageAesKey')
|
||||
const aesKeyRaw = imageKeys.aesKey
|
||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||
@@ -280,14 +290,14 @@ export class ImageDecryptService {
|
||||
await writeFile(outputPath, decrypted)
|
||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||
|
||||
// 对于 hevc 格式,返回错误提示
|
||||
if (finalExt === '.hevc') {
|
||||
return {
|
||||
success: false,
|
||||
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
|
||||
error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志',
|
||||
isThumb: this.isThumbnailPath(datPath)
|
||||
}
|
||||
}
|
||||
|
||||
const isThumb = this.isThumbnailPath(datPath)
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||
if (!isThumb) {
|
||||
@@ -381,7 +391,7 @@ export class ImageDecryptService {
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
@@ -395,29 +405,61 @@ export class ImageDecryptService {
|
||||
const allowThumbnail = options?.allowThumbnail ?? true
|
||||
const skipResolvedCache = options?.skipResolvedCache ?? false
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
||||
accountDir,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
allowThumbnail,
|
||||
skipResolvedCache
|
||||
})
|
||||
|
||||
if (!skipResolvedCache) {
|
||||
if (imageMd5) {
|
||||
const cached = this.resolvedCache.get(imageMd5)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
this.cacheDatPath(accountDir, imageMd5, preferred)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred)
|
||||
return preferred
|
||||
}
|
||||
}
|
||||
if (imageDatName) {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
this.cacheDatPath(accountDir, imageDatName, preferred)
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred)
|
||||
return preferred
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
|
||||
if (imageMd5) {
|
||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// 优先通过 hardlink.db 查询
|
||||
if (imageMd5) {
|
||||
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
|
||||
if (hardlinkPath) {
|
||||
const isThumb = this.isThumbnailPath(hardlinkPath)
|
||||
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
|
||||
const isThumb = this.isThumbnailPath(preferredPath)
|
||||
if (allowThumbnail || !isThumb) {
|
||||
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: hardlinkPath })
|
||||
this.cacheDatPath(accountDir, imageMd5, hardlinkPath)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||
return hardlinkPath
|
||||
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath })
|
||||
this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||
return preferredPath
|
||||
}
|
||||
// hardlink 找到的是缩略图,但要求高清图
|
||||
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
|
||||
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
|
||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
@@ -431,16 +473,19 @@ export class ImageDecryptService {
|
||||
this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId })
|
||||
const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
||||
if (fallbackPath) {
|
||||
const isThumb = this.isThumbnailPath(fallbackPath)
|
||||
const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail)
|
||||
const isThumb = this.isThumbnailPath(preferredPath)
|
||||
if (allowThumbnail || !isThumb) {
|
||||
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: fallbackPath })
|
||||
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
|
||||
return fallbackPath
|
||||
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
|
||||
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||
return preferredPath
|
||||
}
|
||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||
const hdPath = this.findHdVariantInSameDir(fallbackPath)
|
||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
return null
|
||||
@@ -453,14 +498,15 @@ export class ImageDecryptService {
|
||||
this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId })
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
||||
if (hardlinkPath) {
|
||||
const isThumb = this.isThumbnailPath(hardlinkPath)
|
||||
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
|
||||
const isThumb = this.isThumbnailPath(preferredPath)
|
||||
if (allowThumbnail || !isThumb) {
|
||||
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: hardlinkPath })
|
||||
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||
return hardlinkPath
|
||||
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath })
|
||||
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||
return preferredPath
|
||||
}
|
||||
// hardlink 找到的是缩略图,但要求高清图
|
||||
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
|
||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
@@ -479,9 +525,10 @@ export class ImageDecryptService {
|
||||
if (!skipResolvedCache) {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(cached)) {
|
||||
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(cached)
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
}
|
||||
}
|
||||
@@ -583,9 +630,7 @@ export class ImageDecryptService {
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
private looksLikeMd5(value: string): boolean {
|
||||
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
||||
}
|
||||
|
||||
|
||||
private resolveHardlinkDbPath(accountDir: string): string | null {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
@@ -772,7 +817,8 @@ export class ImageDecryptService {
|
||||
const key = `${accountDir}|${datName}`
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached)) {
|
||||
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
}
|
||||
|
||||
const root = join(accountDir, 'msg', 'attach')
|
||||
@@ -781,7 +827,7 @@ export class ImageDecryptService {
|
||||
// 优化1:快速概率性查找
|
||||
// 包含:1. 基于文件名的前缀猜测 (旧版)
|
||||
// 2. 基于日期的最近月份扫描 (新版无索引时)
|
||||
const fastHit = await this.fastProbabilisticSearch(root, datName)
|
||||
const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail)
|
||||
if (fastHit) {
|
||||
this.resolvedCache.set(key, fastHit)
|
||||
return fastHit
|
||||
@@ -801,33 +847,28 @@ export class ImageDecryptService {
|
||||
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
|
||||
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
|
||||
*/
|
||||
private async fastProbabilisticSearch(root: string, datName: string): Promise<string | null> {
|
||||
private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): 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 baseName = this.normalizeDatBase(lowerName)
|
||||
const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail)
|
||||
|
||||
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 targetName of targetNames) {
|
||||
candidates.push(
|
||||
join(root, dir1, dir2, targetName),
|
||||
join(root, dir1, dir2, 'Img', targetName),
|
||||
join(root, dir1, dir2, 'mg', targetName),
|
||||
join(root, dir1, dir2, 'Image', targetName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of candidates) {
|
||||
@@ -837,7 +878,7 @@ export class ImageDecryptService {
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// --- 策略 B: 新版 Session 哈希路径猜测 ---
|
||||
// --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 ---
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true })
|
||||
const sessionDirs = entries
|
||||
@@ -854,13 +895,6 @@ export class ImageDecryptService {
|
||||
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)
|
||||
@@ -890,36 +924,13 @@ export class ImageDecryptService {
|
||||
|
||||
/**
|
||||
* 在同一目录下查找高清图变体
|
||||
* 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat
|
||||
* 优先 `_h`,再回退其他非缩略图变体
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
const fileName = basename(thumbPath)
|
||||
return this.findPreferredDatVariantInDir(dir, fileName, false)
|
||||
} catch { }
|
||||
return null
|
||||
}
|
||||
@@ -969,56 +980,86 @@ export class ImageDecryptService {
|
||||
void worker.terminate()
|
||||
resolve(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private matchesDatName(fileName: string, datName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
const normalizedBase = this.normalizeDatBase(base)
|
||||
const normalizedTarget = this.normalizeDatBase(datName.toLowerCase())
|
||||
if (normalizedBase === normalizedTarget) return true
|
||||
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`, 'i')
|
||||
if (pattern.test(lower)) return true
|
||||
return lower.endsWith('.dat') && lower.includes(datName)
|
||||
private stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
}
|
||||
}
|
||||
if (/[._][a-z]$/.test(lower)) {
|
||||
return lower.slice(0, -2)
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
private scoreDatName(fileName: string): number {
|
||||
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
|
||||
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
|
||||
return 2
|
||||
private getDatVariantPriority(name: string): number {
|
||||
const lower = name.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (!this.hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (this.isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
}
|
||||
|
||||
private isThumbnailDat(fileName: string): boolean {
|
||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
||||
private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] {
|
||||
if (!baseName) return []
|
||||
const names = [
|
||||
`${baseName}_h.dat`,
|
||||
`${baseName}.h.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_hd.dat`,
|
||||
`${baseName}.hd.dat`,
|
||||
`${baseName}_c.dat`,
|
||||
`${baseName}.c.dat`
|
||||
]
|
||||
if (allowThumbnail) {
|
||||
names.push(
|
||||
`${baseName}_thumb.dat`,
|
||||
`${baseName}.thumb.dat`,
|
||||
`${baseName}_t.dat`,
|
||||
`${baseName}.t.dat`
|
||||
)
|
||||
}
|
||||
return Array.from(new Set(names))
|
||||
}
|
||||
|
||||
private hasXVariant(baseLower: string): boolean {
|
||||
return /[._][a-z]$/.test(baseLower)
|
||||
private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dirPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const target = this.normalizeDatBase(baseName.toLowerCase())
|
||||
let bestPath: string | null = null
|
||||
let bestScore = Number.NEGATIVE_INFINITY
|
||||
for (const entry of entries) {
|
||||
const lower = entry.toLowerCase()
|
||||
if (!lower.endsWith('.dat')) continue
|
||||
if (!allowThumbnail && this.isThumbnailDat(lower)) continue
|
||||
const baseLower = lower.slice(0, -4)
|
||||
if (this.normalizeDatBase(baseLower) !== target) continue
|
||||
const score = this.getDatVariantPriority(lower)
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestPath = join(dirPath, entry)
|
||||
}
|
||||
}
|
||||
return bestPath
|
||||
}
|
||||
|
||||
private isThumbnailPath(filePath: string): boolean {
|
||||
const lower = basename(filePath).toLowerCase()
|
||||
if (this.isThumbnailDat(lower)) return true
|
||||
const ext = extname(lower)
|
||||
const base = ext ? lower.slice(0, -ext.length) : lower
|
||||
// 支持新命名 _thumb 和旧命名 _t
|
||||
return base.endsWith('_t') || base.endsWith('_thumb')
|
||||
}
|
||||
|
||||
private isHdPath(filePath: string): boolean {
|
||||
const lower = basename(filePath).toLowerCase()
|
||||
const ext = extname(lower)
|
||||
const base = ext ? lower.slice(0, -ext.length) : lower
|
||||
return base.endsWith('_hd') || base.endsWith('_h')
|
||||
}
|
||||
|
||||
private hasImageVariantSuffix(baseLower: string): boolean {
|
||||
return /[._][a-z]$/.test(baseLower)
|
||||
}
|
||||
|
||||
private isLikelyImageDatBase(baseLower: string): boolean {
|
||||
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
|
||||
private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string {
|
||||
const lower = datPath.toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return datPath
|
||||
const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail)
|
||||
return preferred || datPath
|
||||
}
|
||||
|
||||
private normalizeDatBase(name: string): string {
|
||||
@@ -1026,33 +1067,25 @@ export class ImageDecryptService {
|
||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||
base = base.slice(0, -4)
|
||||
}
|
||||
while (/[._][a-z]$/.test(base)) {
|
||||
base = base.slice(0, -2)
|
||||
for (;;) {
|
||||
const stripped = this.stripDatVariantSuffix(base)
|
||||
if (stripped === base) {
|
||||
return base
|
||||
}
|
||||
base = stripped
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
private sanitizeDirName(name: string): string {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return 'unknown'
|
||||
return trimmed.replace(/[<>:"/\\|?*]/g, '_')
|
||||
private hasImageVariantSuffix(baseLower: string): boolean {
|
||||
return this.stripDatVariantSuffix(baseLower) !== baseLower
|
||||
}
|
||||
|
||||
private resolveTimeDir(datPath: string): string {
|
||||
const parts = datPath.split(/[\\/]+/)
|
||||
for (const part of parts) {
|
||||
if (/^\d{4}-\d{2}$/.test(part)) return part
|
||||
}
|
||||
try {
|
||||
const stat = statSync(datPath)
|
||||
const year = stat.mtime.getFullYear()
|
||||
const month = String(stat.mtime.getMonth() + 1).padStart(2, '0')
|
||||
return `${year}-${month}`
|
||||
} catch {
|
||||
return 'unknown-time'
|
||||
}
|
||||
private isLikelyImageDatBase(baseLower: string): boolean {
|
||||
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower))
|
||||
}
|
||||
|
||||
|
||||
|
||||
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
|
||||
const allRoots = this.getAllCacheRoots()
|
||||
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
|
||||
@@ -1237,24 +1270,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dirPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const target = this.normalizeDatBase(baseName.toLowerCase())
|
||||
for (const entry of entries) {
|
||||
const lower = entry.toLowerCase()
|
||||
if (!lower.endsWith('.dat')) continue
|
||||
if (this.isThumbnailDat(lower)) continue
|
||||
const baseLower = lower.slice(0, -4)
|
||||
// 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的)
|
||||
if (!this.hasXVariant(baseLower)) continue
|
||||
if (this.normalizeDatBase(baseLower) !== target) continue
|
||||
return join(dirPath, entry)
|
||||
}
|
||||
return null
|
||||
return this.findPreferredDatVariantInDir(dirPath, baseName, false)
|
||||
}
|
||||
|
||||
private isNonThumbnailVariantDat(datPath: string): boolean {
|
||||
@@ -1262,8 +1278,7 @@ export class ImageDecryptService {
|
||||
if (!lower.endsWith('.dat')) return false
|
||||
if (this.isThumbnailDat(lower)) return false
|
||||
const baseLower = lower.slice(0, -4)
|
||||
// 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的)
|
||||
return this.hasXVariant(baseLower)
|
||||
return this.isLikelyImageDatBase(baseLower)
|
||||
}
|
||||
|
||||
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
|
||||
@@ -1287,14 +1302,14 @@ export class ImageDecryptService {
|
||||
private async ensureCacheIndexed(): Promise<void> {
|
||||
if (this.cacheIndexed) return
|
||||
if (this.cacheIndexing) return this.cacheIndexing
|
||||
this.cacheIndexing = new Promise((resolve) => {
|
||||
this.cacheIndexing = (async () => {
|
||||
// 扫描所有可能的缓存根目录
|
||||
const allRoots = this.getAllCacheRoots()
|
||||
this.logInfo('开始索引缓存', { roots: allRoots.length })
|
||||
|
||||
for (const root of allRoots) {
|
||||
try {
|
||||
this.indexCacheDir(root, 3, 0) // 增加深度到3,支持 sessionId/YYYY-MM 结构
|
||||
this.indexCacheDir(root, 3, 0) // 增加深度到 3,支持 sessionId/YYYY-MM 结构
|
||||
} catch (e) {
|
||||
this.logError('索引目录失败', e, { root })
|
||||
}
|
||||
@@ -1303,8 +1318,7 @@ export class ImageDecryptService {
|
||||
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
|
||||
this.cacheIndexed = true
|
||||
this.cacheIndexing = null
|
||||
resolve()
|
||||
})
|
||||
})()
|
||||
return this.cacheIndexing
|
||||
}
|
||||
|
||||
@@ -1507,14 +1521,14 @@ export class ImageDecryptService {
|
||||
|
||||
private bytesToInt32(bytes: Buffer): number {
|
||||
if (bytes.length !== 4) {
|
||||
throw new Error('需要4个字节')
|
||||
throw new Error('需要 4 个字节')
|
||||
}
|
||||
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
|
||||
}
|
||||
|
||||
asciiKey16(keyString: string): Buffer {
|
||||
if (keyString.length < 16) {
|
||||
throw new Error('AES密钥至少需要16个字符')
|
||||
throw new Error('AES密钥至少需要 16 个字符')
|
||||
}
|
||||
return Buffer.from(keyString, 'ascii').subarray(0, 16)
|
||||
}
|
||||
@@ -1706,25 +1720,28 @@ export class ImageDecryptService {
|
||||
|
||||
// 提取 HEVC NALU 裸流
|
||||
const hevcData = this.extractHevcNalu(buffer)
|
||||
if (!hevcData || hevcData.length < 100) {
|
||||
return { data: buffer, isWxgf: true }
|
||||
}
|
||||
// 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据
|
||||
const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4)
|
||||
this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', {
|
||||
naluExtracted: !!(hevcData && hevcData.length >= 100),
|
||||
feedSize: feedData.length
|
||||
})
|
||||
|
||||
// 尝试用 ffmpeg 转换
|
||||
try {
|
||||
const jpgData = await this.convertHevcToJpg(hevcData)
|
||||
const jpgData = await this.convertHevcToJpg(feedData)
|
||||
if (jpgData && jpgData.length > 0) {
|
||||
return { data: jpgData, isWxgf: false }
|
||||
}
|
||||
} catch {
|
||||
// ffmpeg 转换失败
|
||||
} catch (e) {
|
||||
this.logError('unwrapWxgf: ffmpeg 转换失败', e)
|
||||
}
|
||||
|
||||
return { data: hevcData, isWxgf: true }
|
||||
return { data: feedData, isWxgf: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
||||
* 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦
|
||||
*/
|
||||
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||
const nalUnits: Buffer[] = []
|
||||
@@ -1787,53 +1804,133 @@ export class ImageDecryptService {
|
||||
/**
|
||||
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
||||
*/
|
||||
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
||||
private async convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
||||
const ffmpeg = this.getFfmpegPath()
|
||||
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
||||
|
||||
const tmpDir = join(app.getPath('temp'), 'weflow_hevc')
|
||||
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
|
||||
const ts = Date.now()
|
||||
const tmpInput = join(tmpDir, `hevc_${ts}.hevc`)
|
||||
const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`)
|
||||
|
||||
try {
|
||||
await writeFile(tmpInput, hevcData)
|
||||
|
||||
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
|
||||
const attempts: { label: string; inputArgs: string[] }[] = [
|
||||
{ label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
|
||||
{ label: 'auto detect', inputArgs: ['-i', tmpInput] },
|
||||
]
|
||||
|
||||
for (const attempt of attempts) {
|
||||
// 清理上一轮的输出
|
||||
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
|
||||
|
||||
const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label)
|
||||
if (result) return result
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (e) {
|
||||
this.logError('ffmpeg 转换异常', e)
|
||||
return null
|
||||
} finally {
|
||||
try { if (existsSync(tmpInput)) require('fs').unlinkSync(tmpInput) } catch {}
|
||||
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise<Buffer | null> {
|
||||
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'],
|
||||
const args = [
|
||||
'-hide_banner', '-loglevel', 'error',
|
||||
...inputArgs,
|
||||
'-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
|
||||
]
|
||||
this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') })
|
||||
|
||||
const proc = spawn(ffmpeg, args, {
|
||||
stdio: ['ignore', 'ignore', '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)
|
||||
}
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL')
|
||||
this.logError(`ffmpeg [${label}] 超时(15s)`)
|
||||
resolve(null)
|
||||
}, 15000)
|
||||
|
||||
proc.on('error', (err: Error) => {
|
||||
this.logInfo('ffmpeg 进程错误', { error: err.message })
|
||||
proc.on('close', (code: number) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0 && existsSync(tmpOutput)) {
|
||||
try {
|
||||
const jpgBuf = readFileSync(tmpOutput)
|
||||
if (jpgBuf.length > 0) {
|
||||
this.logInfo(`ffmpeg [${label}] 成功`, { outputSize: jpgBuf.length })
|
||||
resolve(jpgBuf)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
this.logError(`ffmpeg [${label}] 读取输出失败`, e)
|
||||
}
|
||||
}
|
||||
const errMsg = Buffer.concat(errChunks).toString().trim()
|
||||
this.logInfo(`ffmpeg [${label}] 失败`, { code, error: errMsg })
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
proc.stdin.write(hevcData)
|
||||
proc.stdin.end()
|
||||
proc.on('error', (err: Error) => {
|
||||
clearTimeout(timer)
|
||||
this.logError(`ffmpeg [${label}] 进程错误`, err)
|
||||
resolve(null)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private looksLikeMd5(s: string): boolean {
|
||||
return /^[a-f0-9]{32}$/i.test(s)
|
||||
}
|
||||
|
||||
private isThumbnailDat(name: string): boolean {
|
||||
const lower = name.toLowerCase()
|
||||
return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat')
|
||||
}
|
||||
|
||||
private hasXVariant(base: string): boolean {
|
||||
const lower = base.toLowerCase()
|
||||
return this.stripDatVariantSuffix(lower) !== lower
|
||||
}
|
||||
|
||||
private isHdPath(p: string): boolean {
|
||||
return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h')
|
||||
}
|
||||
|
||||
private isThumbnailPath(p: string): boolean {
|
||||
const lower = p.toLowerCase()
|
||||
return lower.includes('_thumb') || lower.includes('_t') || lower.includes('.t.')
|
||||
}
|
||||
|
||||
private sanitizeDirName(s: string): string {
|
||||
return s.replace(/[<>:"/\\|?*]/g, '_').trim() || 'unknown'
|
||||
}
|
||||
|
||||
private resolveTimeDir(filePath: string): string {
|
||||
try {
|
||||
const stats = statSync(filePath)
|
||||
const d = new Date(stats.mtime)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
} catch {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原有的解密到文件方法(用于兼容)
|
||||
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
||||
const version = this.getDatVersion(inputPath)
|
||||
@@ -1846,7 +1943,7 @@ export class ImageDecryptService {
|
||||
decrypted = this.decryptDatV4(inputPath, xorKey, key)
|
||||
} else {
|
||||
if (!aesKey || aesKey.length !== 16) {
|
||||
throw new Error('V4版本需要16字节AES密钥')
|
||||
throw new Error('V4版本需要 16 字节 AES 密钥')
|
||||
}
|
||||
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1046
electron/services/keyServiceMac.ts
Normal file
1046
electron/services/keyServiceMac.ts
Normal file
File diff suppressed because it is too large
Load Diff
293
electron/services/sessionStatsCacheService.ts
Normal file
293
electron/services/sessionStatsCacheService.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
const CACHE_VERSION = 2
|
||||
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
|
||||
const MAX_SCOPE_ENTRIES = 12
|
||||
|
||||
export interface SessionStatsCacheStats {
|
||||
totalMessages: number
|
||||
voiceMessages: number
|
||||
imageMessages: number
|
||||
videoMessages: number
|
||||
emojiMessages: number
|
||||
transferMessages: number
|
||||
redPacketMessages: number
|
||||
callMessages: number
|
||||
firstTimestamp?: number
|
||||
lastTimestamp?: number
|
||||
privateMutualGroups?: number
|
||||
groupMemberCount?: number
|
||||
groupMyMessages?: number
|
||||
groupActiveSpeakers?: number
|
||||
groupMutualFriends?: number
|
||||
}
|
||||
|
||||
export interface SessionStatsCacheEntry {
|
||||
updatedAt: number
|
||||
includeRelations: boolean
|
||||
stats: SessionStatsCacheStats
|
||||
}
|
||||
|
||||
interface SessionStatsScopeMap {
|
||||
[sessionId: string]: SessionStatsCacheEntry
|
||||
}
|
||||
|
||||
interface SessionStatsCacheStore {
|
||||
version: number
|
||||
scopes: Record<string, SessionStatsScopeMap>
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||
return Math.max(0, Math.floor(value))
|
||||
}
|
||||
|
||||
function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
|
||||
const totalMessages = toNonNegativeInt(source.totalMessages)
|
||||
const voiceMessages = toNonNegativeInt(source.voiceMessages)
|
||||
const imageMessages = toNonNegativeInt(source.imageMessages)
|
||||
const videoMessages = toNonNegativeInt(source.videoMessages)
|
||||
const emojiMessages = toNonNegativeInt(source.emojiMessages)
|
||||
const transferMessages = toNonNegativeInt(source.transferMessages)
|
||||
const redPacketMessages = toNonNegativeInt(source.redPacketMessages)
|
||||
const callMessages = toNonNegativeInt(source.callMessages)
|
||||
|
||||
if (
|
||||
totalMessages === undefined ||
|
||||
voiceMessages === undefined ||
|
||||
imageMessages === undefined ||
|
||||
videoMessages === undefined ||
|
||||
emojiMessages === undefined ||
|
||||
transferMessages === undefined ||
|
||||
redPacketMessages === undefined ||
|
||||
callMessages === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalized: SessionStatsCacheStats = {
|
||||
totalMessages,
|
||||
voiceMessages,
|
||||
imageMessages,
|
||||
videoMessages,
|
||||
emojiMessages,
|
||||
transferMessages,
|
||||
redPacketMessages,
|
||||
callMessages
|
||||
}
|
||||
|
||||
const firstTimestamp = toNonNegativeInt(source.firstTimestamp)
|
||||
if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp
|
||||
|
||||
const lastTimestamp = toNonNegativeInt(source.lastTimestamp)
|
||||
if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp
|
||||
|
||||
const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups)
|
||||
if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups
|
||||
|
||||
const groupMemberCount = toNonNegativeInt(source.groupMemberCount)
|
||||
if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount
|
||||
|
||||
const groupMyMessages = toNonNegativeInt(source.groupMyMessages)
|
||||
if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages
|
||||
|
||||
const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers)
|
||||
if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers
|
||||
|
||||
const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends)
|
||||
if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||
const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false
|
||||
const stats = normalizeStats(source.stats)
|
||||
|
||||
if (updatedAt === undefined || !stats) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
updatedAt,
|
||||
includeRelations,
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionStatsCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private store: SessionStatsCacheStore = {
|
||||
version: CACHE_VERSION,
|
||||
scopes: {}
|
||||
}
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: ConfigService.getInstance().getCacheBasePath()
|
||||
this.cacheFilePath = join(basePath, 'session-stats.json')
|
||||
this.ensureCacheDir()
|
||||
this.load()
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const payload = parsed as Record<string, unknown>
|
||||
const version = Number(payload.version)
|
||||
if (!Number.isFinite(version) || version !== CACHE_VERSION) {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
const scopesRaw = payload.scopes
|
||||
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const scopes: Record<string, SessionStatsScopeMap> = {}
|
||||
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||
if (!scopeValue || typeof scopeValue !== 'object') continue
|
||||
const normalizedScope: SessionStatsScopeMap = {}
|
||||
for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
|
||||
const entry = normalizeEntry(entryRaw)
|
||||
if (!entry) continue
|
||||
normalizedScope[sessionId] = entry
|
||||
}
|
||||
if (Object.keys(normalizedScope).length > 0) {
|
||||
scopes[scopeKey] = normalizedScope
|
||||
}
|
||||
}
|
||||
|
||||
this.store = {
|
||||
version: CACHE_VERSION,
|
||||
scopes
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('SessionStatsCacheService: 载入缓存失败', error)
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
}
|
||||
}
|
||||
|
||||
get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined {
|
||||
if (!scopeKey || !sessionId) return undefined
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return undefined
|
||||
const entry = normalizeEntry(scope[sessionId])
|
||||
if (!entry) {
|
||||
delete scope[sessionId]
|
||||
if (Object.keys(scope).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
}
|
||||
this.persist()
|
||||
return undefined
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void {
|
||||
if (!scopeKey || !sessionId) return
|
||||
const normalized = normalizeEntry(entry)
|
||||
if (!normalized) return
|
||||
|
||||
if (!this.store.scopes[scopeKey]) {
|
||||
this.store.scopes[scopeKey] = {}
|
||||
}
|
||||
this.store.scopes[scopeKey][sessionId] = normalized
|
||||
|
||||
this.trimScope(scopeKey)
|
||||
this.trimScopes()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
delete(scopeKey: string, sessionId: string): void {
|
||||
if (!scopeKey || !sessionId) return
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
if (!(sessionId in scope)) return
|
||||
|
||||
delete scope[sessionId]
|
||||
if (Object.keys(scope).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearScope(scopeKey: string): void {
|
||||
if (!scopeKey) return
|
||||
if (!this.store.scopes[scopeKey]) return
|
||||
delete this.store.scopes[scopeKey]
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
try {
|
||||
rmSync(this.cacheFilePath, { force: true })
|
||||
} catch (error) {
|
||||
console.error('SessionStatsCacheService: 清理缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
private trimScope(scopeKey: string): void {
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
const entries = Object.entries(scope)
|
||||
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
|
||||
|
||||
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
const trimmed: SessionStatsScopeMap = {}
|
||||
for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) {
|
||||
trimmed[sessionId] = entry
|
||||
}
|
||||
this.store.scopes[scopeKey] = trimmed
|
||||
}
|
||||
|
||||
private trimScopes(): void {
|
||||
const scopeEntries = Object.entries(this.store.scopes)
|
||||
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||
|
||||
scopeEntries.sort((a, b) => {
|
||||
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
|
||||
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
|
||||
return bUpdatedAt - aUpdatedAt
|
||||
})
|
||||
|
||||
const trimmedScopes: Record<string, SessionStatsScopeMap> = {}
|
||||
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
|
||||
trimmedScopes[scopeKey] = scopeMap
|
||||
}
|
||||
this.store.scopes = trimmedScopes
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('SessionStatsCacheService: 保存缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import Database from 'better-sqlite3'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
export interface VideoInfo {
|
||||
@@ -18,6 +18,16 @@ class VideoService {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
private log(message: string, meta?: Record<string, unknown>): void {
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logDir = join(app.getPath('userData'), 'logs')
|
||||
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
||||
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库根目录
|
||||
*/
|
||||
@@ -60,52 +70,22 @@ class VideoService {
|
||||
|
||||
/**
|
||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
||||
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
* 使用 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
|
||||
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath })
|
||||
|
||||
// 方法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) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!wxid) {
|
||||
this.log('queryVideoFileName: wxid 为空')
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
// 使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
if (dbPath) {
|
||||
// 检查 dbPath 是否已经包含 wxid
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||
@@ -113,10 +93,8 @@ class VideoService {
|
||||
|
||||
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'))
|
||||
}
|
||||
@@ -124,27 +102,29 @@ class VideoService {
|
||||
for (const p of encryptedDbPaths) {
|
||||
if (existsSync(p)) {
|
||||
try {
|
||||
this.log('尝试加密 hardlink.db', { path: p })
|
||||
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(/\.[^.]+$/, '')
|
||||
this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||
return realMd5
|
||||
}
|
||||
}
|
||||
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||
}
|
||||
} else {
|
||||
this.log('加密 hardlink.db 不存在', { path: p })
|
||||
}
|
||||
}
|
||||
}
|
||||
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -170,12 +150,16 @@ class VideoService {
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
|
||||
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
|
||||
|
||||
if (!dbPath || !wxid || !videoMd5) {
|
||||
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
// 先尝试从数据库查询真正的视频文件名
|
||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
||||
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
|
||||
|
||||
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
@@ -184,50 +168,89 @@ class VideoService {
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
|
||||
|
||||
if (!existsSync(videoBaseDir)) {
|
||||
this.log('getVideoInfo: 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)) // 从最新的目录开始查找
|
||||
.sort((a, b) => b.localeCompare(a))
|
||||
|
||||
this.log('扫描目录', { dirs: yearMonthDirs })
|
||||
|
||||
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)) {
|
||||
// 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw,但封面不带)
|
||||
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
|
||||
const coverPath = join(dirPath, `${baseMd5}.jpg`)
|
||||
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
||||
|
||||
// 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名
|
||||
const allFiles = readdirSync(dirPath)
|
||||
const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase()))
|
||||
this.log('找到视频,相关文件列表', {
|
||||
videoPath,
|
||||
coverExists: existsSync(coverPath),
|
||||
thumbExists: existsSync(thumbPath),
|
||||
relatedFiles,
|
||||
coverPath,
|
||||
thumbPath
|
||||
})
|
||||
|
||||
return {
|
||||
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
||||
videoUrl: videoPath,
|
||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个)
|
||||
this.log('未找到视频,开始全目录扫描', {
|
||||
lookingForOriginal: `${videoMd5}.mp4`,
|
||||
lookingForResolved: `${realVideoMd5}.mp4`,
|
||||
hardlinkResolved: realVideoMd5 !== videoMd5
|
||||
})
|
||||
for (const yearMonth of yearMonthDirs) {
|
||||
const dirPath = join(videoBaseDir, yearMonth)
|
||||
try {
|
||||
const allFiles = readdirSync(dirPath)
|
||||
const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10)
|
||||
// 检查原始 md5 是否部分匹配(前8位)
|
||||
const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase()))
|
||||
this.log(`目录 ${yearMonth} 扫描结果`, {
|
||||
totalFiles: allFiles.length,
|
||||
mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length,
|
||||
sampleMp4: mp4Files,
|
||||
partialMatchByOriginalMd5: partialMatch
|
||||
})
|
||||
} catch (e) {
|
||||
this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) })
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
this.log('getVideoInfo 遍历出错', { error: String(e) })
|
||||
}
|
||||
|
||||
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
@@ -235,41 +258,59 @@ class VideoService {
|
||||
* 根据消息内容解析视频MD5
|
||||
*/
|
||||
parseVideoMd5(content: string): string | undefined {
|
||||
|
||||
// 打印前500字符看看 XML 结构
|
||||
|
||||
if (!content) return undefined
|
||||
|
||||
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
|
||||
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
|
||||
|
||||
try {
|
||||
// 提取所有可能的 md5 值进行日志
|
||||
const allMd5s: string[] = []
|
||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
|
||||
// 收集所有 md5 相关属性,方便对比
|
||||
const allMd5Attrs: 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]}`)
|
||||
allMd5Attrs.push(match[0])
|
||||
}
|
||||
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
|
||||
|
||||
// 方法1:从 <videomsg md5="..."> 提取(收到的视频)
|
||||
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (videoMsgMd5Match) {
|
||||
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
|
||||
return videoMsgMd5Match[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 提取 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()
|
||||
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
||||
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (rawMd5Match) {
|
||||
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
||||
return rawMd5Match[1].toLowerCase()
|
||||
}
|
||||
|
||||
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
// 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
|
||||
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (attrMatch) {
|
||||
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
||||
return attrMatch[1].toLowerCase()
|
||||
}
|
||||
|
||||
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||
if (md5Match) {
|
||||
return md5Match[1].toLowerCase()
|
||||
// 方法4:<md5>...</md5> 标签
|
||||
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||
if (md5TagMatch) {
|
||||
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
|
||||
return md5TagMatch[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 方法5:兜底取 rawmd5 属性(任意位置)
|
||||
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (rawMd5Fallback) {
|
||||
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
|
||||
return rawMd5Fallback[1].toLowerCase()
|
||||
}
|
||||
|
||||
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
|
||||
} catch (e) {
|
||||
console.error('[VideoService] 解析视频MD5失败:', e)
|
||||
this.log('parseVideoMd5 异常', { error: String(e) })
|
||||
}
|
||||
|
||||
return undefined
|
||||
|
||||
@@ -48,6 +48,38 @@ export class VoiceTranscribeService {
|
||||
private recognizer: OfflineRecognizer | null = null
|
||||
private isInitializing = false
|
||||
|
||||
private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env }
|
||||
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||
const candidates = [
|
||||
join(__dirname, '..', 'node_modules', platformPkg),
|
||||
join(__dirname, 'node_modules', platformPkg),
|
||||
join(process.cwd(), 'node_modules', platformPkg),
|
||||
process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||
].filter((item): item is string => Boolean(item) && existsSync(item))
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const key = 'DYLD_LIBRARY_PATH'
|
||||
const existing = env[key] || ''
|
||||
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||
env[key] = Array.from(new Set(merged)).join(':')
|
||||
if (candidates.length === 0) {
|
||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||
}
|
||||
} else if (process.platform === 'linux') {
|
||||
const key = 'LD_LIBRARY_PATH'
|
||||
const existing = env[key] || ''
|
||||
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||
env[key] = Array.from(new Set(merged)).join(':')
|
||||
if (candidates.length === 0) {
|
||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||
}
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
private resolveModelDir(): string {
|
||||
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||
if (configured) return configured
|
||||
@@ -206,17 +238,20 @@ export class VoiceTranscribeService {
|
||||
}
|
||||
}
|
||||
|
||||
const { Worker } = require('worker_threads')
|
||||
const { fork } = require('child_process')
|
||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
modelPath,
|
||||
tokensPath,
|
||||
wavData,
|
||||
sampleRate: 16000,
|
||||
languages: supportedLanguages
|
||||
}
|
||||
const worker = fork(workerPath, [], {
|
||||
env: this.buildTranscribeWorkerEnv(),
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||
serialization: 'advanced'
|
||||
})
|
||||
worker.send({
|
||||
modelPath,
|
||||
tokensPath,
|
||||
wavData,
|
||||
sampleRate: 16000,
|
||||
languages: supportedLanguages
|
||||
})
|
||||
|
||||
let finalTranscript = ''
|
||||
@@ -227,11 +262,13 @@ export class VoiceTranscribeService {
|
||||
} else if (msg.type === 'final') {
|
||||
finalTranscript = msg.text
|
||||
resolve({ success: true, transcript: finalTranscript })
|
||||
worker.terminate()
|
||||
worker.disconnect()
|
||||
worker.kill()
|
||||
} else if (msg.type === 'error') {
|
||||
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
||||
resolve({ success: false, error: msg.error })
|
||||
worker.terminate()
|
||||
worker.disconnect()
|
||||
worker.kill()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -458,8 +495,18 @@ export class VoiceTranscribeService {
|
||||
|
||||
writer.on('error', (err) => {
|
||||
clearInterval(speedInterval)
|
||||
// 确保在错误情况下也关闭文件句柄
|
||||
writer.destroy()
|
||||
reject(err)
|
||||
})
|
||||
|
||||
response.on('error', (err) => {
|
||||
clearInterval(speedInterval)
|
||||
// 确保在响应错误时也关闭文件句柄
|
||||
writer.destroy()
|
||||
reject(err)
|
||||
})
|
||||
|
||||
response.pipe(writer)
|
||||
})
|
||||
request.on('error', reject)
|
||||
|
||||
@@ -1,8 +1,51 @@
|
||||
import { join, dirname, basename } from 'path'
|
||||
import { join, dirname, basename } from 'path'
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||
let lastDllInitError: string | null = null
|
||||
|
||||
/**
|
||||
* 解析 extra_buffer(protobuf)中的免打扰状态
|
||||
* - field 12 (tag 0x60): 值非0 = 免打扰
|
||||
* 折叠状态通过 contact.flag & 0x10000000 判断
|
||||
*/
|
||||
function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } {
|
||||
if (!raw) return { isMuted: false }
|
||||
// execQuery 返回的 BLOB 列是十六进制字符串,需要先解码
|
||||
const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw
|
||||
if (buf.length === 0) return { isMuted: false }
|
||||
let isMuted = false
|
||||
let i = 0
|
||||
const len = buf.length
|
||||
|
||||
const readVarint = (): number => {
|
||||
let result = 0, shift = 0
|
||||
while (i < len) {
|
||||
const b = buf[i++]
|
||||
result |= (b & 0x7f) << shift
|
||||
shift += 7
|
||||
if (!(b & 0x80)) break
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
while (i < len) {
|
||||
const tag = readVarint()
|
||||
const fieldNum = tag >>> 3
|
||||
const wireType = tag & 0x07
|
||||
if (wireType === 0) {
|
||||
const val = readVarint()
|
||||
if (fieldNum === 12 && val !== 0) isMuted = true
|
||||
} else if (wireType === 2) {
|
||||
const sz = readVarint()
|
||||
i += sz
|
||||
} else if (wireType === 5) { i += 4
|
||||
} else if (wireType === 1) { i += 8
|
||||
} else { break }
|
||||
}
|
||||
return { isMuted }
|
||||
}
|
||||
export function getLastDllInitError(): string | null {
|
||||
return lastDllInitError
|
||||
}
|
||||
@@ -18,6 +61,7 @@ export class WcdbCore {
|
||||
private currentPath: string | null = null
|
||||
private currentKey: string | null = null
|
||||
private currentWxid: string | null = null
|
||||
private currentDbStoragePath: string | null = null
|
||||
|
||||
// 函数引用
|
||||
private wcdbInitProtection: any = null
|
||||
@@ -41,6 +85,7 @@ export class WcdbCore {
|
||||
private wcdbGetMessageTables: any = null
|
||||
private wcdbGetMessageMeta: any = null
|
||||
private wcdbGetContact: any = null
|
||||
private wcdbGetContactStatus: any = null
|
||||
private wcdbGetMessageTableStats: any = null
|
||||
private wcdbGetAggregateStats: any = null
|
||||
private wcdbGetAvailableYears: any = null
|
||||
@@ -63,10 +108,17 @@ export class WcdbCore {
|
||||
private wcdbGetVoiceData: any = null
|
||||
private wcdbGetSnsTimeline: any = null
|
||||
private wcdbGetSnsAnnualStats: any = null
|
||||
private wcdbInstallSnsBlockDeleteTrigger: any = null
|
||||
private wcdbUninstallSnsBlockDeleteTrigger: any = null
|
||||
private wcdbCheckSnsBlockDeleteTrigger: any = null
|
||||
private wcdbDeleteSnsPost: any = null
|
||||
private wcdbVerifyUser: any = null
|
||||
private wcdbStartMonitorPipe: any = null
|
||||
private wcdbStopMonitorPipe: any = null
|
||||
private wcdbGetMonitorPipeName: any = null
|
||||
private wcdbCloudInit: any = null
|
||||
private wcdbCloudReport: any = null
|
||||
private wcdbCloudStop: any = null
|
||||
|
||||
private monitorPipeClient: any = null
|
||||
private monitorCallback: ((type: string, json: string) => void) | null = null
|
||||
@@ -78,14 +130,17 @@ export class WcdbCore {
|
||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||
private logTimer: NodeJS.Timeout | null = null
|
||||
private lastLogTail: string | null = null
|
||||
private lastResolvedLogPath: string | null = null
|
||||
|
||||
setPaths(resourcesPath: string, userDataPath: string): void {
|
||||
this.resourcesPath = resourcesPath
|
||||
this.userDataPath = userDataPath
|
||||
this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true)
|
||||
}
|
||||
|
||||
setLogEnabled(enabled: boolean): void {
|
||||
this.logEnabled = enabled
|
||||
this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true)
|
||||
if (this.isLogEnabled() && this.initialized) {
|
||||
this.startLogPolling()
|
||||
} else {
|
||||
@@ -93,7 +148,7 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用命名管道 IPC
|
||||
// 使用命名管道/socket IPC (Windows: Named Pipe, macOS: Unix Socket)
|
||||
startMonitor(callback: (type: string, json: string) => void): boolean {
|
||||
if (!this.wcdbStartMonitorPipe) {
|
||||
return false
|
||||
@@ -118,7 +173,6 @@ export class WcdbCore {
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this.connectMonitorPipe(pipePath)
|
||||
return true
|
||||
} catch (e) {
|
||||
@@ -135,13 +189,18 @@ export class WcdbCore {
|
||||
setTimeout(() => {
|
||||
if (!this.monitorCallback) return
|
||||
|
||||
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
|
||||
})
|
||||
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {})
|
||||
|
||||
let buffer = ''
|
||||
this.monitorPipeClient.on('data', (data: Buffer) => {
|
||||
buffer += data.toString('utf8')
|
||||
const lines = buffer.split('\n')
|
||||
const rawChunk = data.toString('utf8')
|
||||
// macOS 侧可能使用 '\0' 或无换行分隔,统一归一化并兜底拆包
|
||||
const normalizedChunk = rawChunk
|
||||
.replace(/\u0000/g, '\n')
|
||||
.replace(/}\s*{/g, '}\n{')
|
||||
|
||||
buffer += normalizedChunk
|
||||
const lines = buffer.split(/\r?\n/)
|
||||
buffer = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
@@ -153,9 +212,22 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:如果没有分隔符但已形成完整 JSON,则直接上报
|
||||
const tail = buffer.trim()
|
||||
if (tail.startsWith('{') && tail.endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(tail)
|
||||
this.monitorCallback?.(parsed.action || 'update', tail)
|
||||
buffer = ''
|
||||
} catch {
|
||||
// 不可解析则继续等待下一块数据
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.monitorPipeClient.on('error', () => {
|
||||
// 保持静默,与现有错误处理策略一致
|
||||
})
|
||||
|
||||
this.monitorPipeClient.on('close', () => {
|
||||
@@ -201,9 +273,13 @@ export class WcdbCore {
|
||||
|
||||
|
||||
/**
|
||||
* 获取 DLL 路径
|
||||
* 获取库文件路径(跨平台)
|
||||
*/
|
||||
private getDllPath(): string {
|
||||
const isMac = process.platform === 'darwin'
|
||||
const libName = isMac ? 'libwcdb_api.dylib' : 'wcdb_api.dll'
|
||||
const subDir = isMac ? 'macos' : ''
|
||||
|
||||
const envDllPath = process.env.WCDB_DLL_PATH
|
||||
if (envDllPath && envDllPath.length > 0) {
|
||||
return envDllPath
|
||||
@@ -215,22 +291,22 @@ export class WcdbCore {
|
||||
|
||||
const candidates = [
|
||||
// 环境变量指定 resource 目录
|
||||
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, 'wcdb_api.dll') : null,
|
||||
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null,
|
||||
// 显式 setPaths 设置的路径
|
||||
this.resourcesPath ? join(this.resourcesPath, 'wcdb_api.dll') : null,
|
||||
// text/resources/wcdb_api.dll (打包常见结构)
|
||||
join(resourcesPath, 'resources', 'wcdb_api.dll'),
|
||||
// items/resourcesPath/wcdb_api.dll (扁平结构)
|
||||
join(resourcesPath, 'wcdb_api.dll'),
|
||||
this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null,
|
||||
// resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
|
||||
join(resourcesPath, 'resources', subDir, libName),
|
||||
// resources/libwcdb_api.dylib 或 resources/wcdb_api.dll (扁平结构)
|
||||
join(resourcesPath, subDir, libName),
|
||||
// CWD fallback
|
||||
join(process.cwd(), 'resources', 'wcdb_api.dll')
|
||||
join(process.cwd(), 'resources', subDir, libName)
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path
|
||||
}
|
||||
|
||||
return candidates[0] || 'wcdb_api.dll'
|
||||
return candidates[0] || libName
|
||||
}
|
||||
|
||||
private isLogEnabled(): boolean {
|
||||
@@ -242,14 +318,97 @@ export class WcdbCore {
|
||||
private writeLog(message: string, force = false): void {
|
||||
if (!force && !this.isLogEnabled()) return
|
||||
const line = `[${new Date().toISOString()}] ${message}`
|
||||
// 同时输出到控制台和文件
|
||||
|
||||
const candidates: string[] = []
|
||||
if (this.userDataPath) candidates.push(join(this.userDataPath, 'logs', 'wcdb.log'))
|
||||
if (process.env.WCDB_LOG_DIR) candidates.push(join(process.env.WCDB_LOG_DIR, 'logs', 'wcdb.log'))
|
||||
candidates.push(join(process.cwd(), 'logs', 'wcdb.log'))
|
||||
candidates.push(join(tmpdir(), 'weflow-wcdb.log'))
|
||||
|
||||
const uniq = Array.from(new Set(candidates))
|
||||
for (const filePath of uniq) {
|
||||
try {
|
||||
const dir = dirname(filePath)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
appendFileSync(filePath, line + '\n', { encoding: 'utf8' })
|
||||
this.lastResolvedLogPath = filePath
|
||||
return
|
||||
} catch (e) {
|
||||
console.error(`[wcdbCore] writeLog failed path=${filePath}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[wcdbCore] writeLog failed for all candidates:', uniq.join(' | '))
|
||||
}
|
||||
|
||||
private formatSqlForLog(sql: string, maxLen = 240): string {
|
||||
const compact = String(sql || '').replace(/\s+/g, ' ').trim()
|
||||
if (compact.length <= maxLen) return compact
|
||||
return compact.slice(0, maxLen) + '...'
|
||||
}
|
||||
|
||||
private async dumpDbStatus(tag: string): Promise<void> {
|
||||
try {
|
||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
||||
const dir = join(base, 'logs')
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' })
|
||||
} catch { }
|
||||
if (!this.ensureReady()) {
|
||||
this.writeLog(`[diag:${tag}] db_status skipped: not connected`, true)
|
||||
return
|
||||
}
|
||||
if (!this.wcdbGetDbStatus) {
|
||||
this.writeLog(`[diag:${tag}] db_status skipped: api not supported`, true)
|
||||
return
|
||||
}
|
||||
const outPtr = [null as any]
|
||||
const rc = this.wcdbGetDbStatus(this.handle, outPtr)
|
||||
if (rc !== 0 || !outPtr[0]) {
|
||||
this.writeLog(`[diag:${tag}] db_status failed rc=${rc} outPtr=${outPtr[0] ? 'set' : 'null'}`, true)
|
||||
return
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) {
|
||||
this.writeLog(`[diag:${tag}] db_status decode failed`, true)
|
||||
return
|
||||
}
|
||||
this.writeLog(`[diag:${tag}] db_status=${jsonStr}`, true)
|
||||
} catch (e) {
|
||||
this.writeLog(`[diag:${tag}] db_status exception: ${String(e)}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
private async runPostOpenDiagnostics(dbPath: string, dbStoragePath: string | null, sessionDbPath: string | null, wxid: string): Promise<void> {
|
||||
try {
|
||||
this.writeLog(`[diag:open] input dbPath=${dbPath} wxid=${wxid}`, true)
|
||||
this.writeLog(`[diag:open] resolved dbStorage=${dbStoragePath || 'null'}`, true)
|
||||
this.writeLog(`[diag:open] resolved sessionDb=${sessionDbPath || 'null'}`, true)
|
||||
if (!dbStoragePath) return
|
||||
try {
|
||||
const entries = readdirSync(dbStoragePath)
|
||||
const sample = entries.slice(0, 20).join(',')
|
||||
this.writeLog(`[diag:open] dbStorage entries(${entries.length}) sample=${sample}`, true)
|
||||
} catch (e) {
|
||||
this.writeLog(`[diag:open] list dbStorage failed: ${String(e)}`, true)
|
||||
}
|
||||
|
||||
const contactProbe = await this.execQuery(
|
||||
'contact',
|
||||
null,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name LIMIT 50"
|
||||
)
|
||||
if (contactProbe.success) {
|
||||
const names = (contactProbe.rows || []).map((r: any) => String(r?.name || '')).filter(Boolean)
|
||||
this.writeLog(`[diag:open] contact sqlite_master rows=${names.length} names=${names.join(',')}`, true)
|
||||
} else {
|
||||
this.writeLog(`[diag:open] contact sqlite_master failed: ${contactProbe.error || 'unknown'}`, true)
|
||||
}
|
||||
|
||||
const contactCount = await this.execQuery('contact', null, 'SELECT COUNT(1) AS cnt FROM contact')
|
||||
if (contactCount.success && Array.isArray(contactCount.rows) && contactCount.rows.length > 0) {
|
||||
this.writeLog(`[diag:open] contact count=${String((contactCount.rows[0] as any)?.cnt ?? '')}`, true)
|
||||
} else {
|
||||
this.writeLog(`[diag:open] contact count failed: ${contactCount.error || 'unknown'}`, true)
|
||||
}
|
||||
} catch (e) {
|
||||
this.writeLog(`[diag:open] post-open diagnostics exception: ${String(e)}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,6 +485,51 @@ export class WcdbCore {
|
||||
return null
|
||||
}
|
||||
|
||||
private isRealDbFileName(name: string): boolean {
|
||||
const lower = String(name || '').toLowerCase()
|
||||
if (!lower.endsWith('.db')) return false
|
||||
if (lower.endsWith('.db-shm')) return false
|
||||
if (lower.endsWith('.db-wal')) return false
|
||||
if (lower.endsWith('.db-journal')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
private resolveContactDbPath(): string | null {
|
||||
const dbStorage = this.currentDbStoragePath || this.resolveDbStoragePath(this.currentPath || '', this.currentWxid || '')
|
||||
if (!dbStorage) return null
|
||||
const contactDir = join(dbStorage, 'Contact')
|
||||
if (!existsSync(contactDir)) return null
|
||||
|
||||
const preferred = [
|
||||
join(contactDir, 'contact.db'),
|
||||
join(contactDir, 'Contact.db')
|
||||
]
|
||||
for (const p of preferred) {
|
||||
if (existsSync(p)) return p
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(contactDir)
|
||||
const cands = entries
|
||||
.filter((name) => this.isRealDbFileName(name))
|
||||
.map((name) => join(contactDir, name))
|
||||
if (cands.length > 0) return cands[0]
|
||||
} catch { }
|
||||
return null
|
||||
}
|
||||
|
||||
private pickFirstStringField(row: Record<string, any>, candidates: string[]): string {
|
||||
for (const key of candidates) {
|
||||
const v = row[key]
|
||||
if (typeof v === 'string' && v.trim()) return v
|
||||
if (v !== null && v !== undefined) {
|
||||
const s = String(v).trim()
|
||||
if (s) return s
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 WCDB
|
||||
*/
|
||||
@@ -335,31 +539,49 @@ export class WcdbCore {
|
||||
try {
|
||||
this.koffi = require('koffi')
|
||||
const dllPath = this.getDllPath()
|
||||
this.writeLog(`[bootstrap] initialize platform=${process.platform} dllPath=${dllPath} resourcesPath=${this.resourcesPath || ''} userDataPath=${this.userDataPath || ''}`, true)
|
||||
|
||||
if (!existsSync(dllPath)) {
|
||||
console.error('WCDB DLL 不存在:', dllPath)
|
||||
this.writeLog(`[bootstrap] initialize failed: dll not found path=${dllPath}`, true)
|
||||
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 isMac = process.platform === 'darwin'
|
||||
|
||||
// 预加载依赖库
|
||||
if (isMac) {
|
||||
const wcdbCorePath = join(dllDir, 'libWCDB.dylib')
|
||||
if (existsSync(wcdbCorePath)) {
|
||||
try {
|
||||
this.koffi.load(wcdbCorePath)
|
||||
this.writeLog('预加载 libWCDB.dylib 成功')
|
||||
} catch (e) {
|
||||
console.warn('预加载 libWCDB.dylib 失败(可能不是致命的):', e)
|
||||
this.writeLog(`预加载 libWCDB.dylib 失败: ${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)}`)
|
||||
} else {
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +595,8 @@ export class WcdbCore {
|
||||
const resourcePaths = [
|
||||
dllDir, // DLL 所在目录
|
||||
dirname(dllDir), // 上级目录
|
||||
process.resourcesPath, // 打包后 Contents/Resources
|
||||
process.resourcesPath ? join(process.resourcesPath as string, 'resources') : null, // Contents/Resources/resources
|
||||
this.resourcesPath, // 配置的资源路径
|
||||
join(process.cwd(), 'resources') // 开发环境
|
||||
].filter(Boolean)
|
||||
@@ -483,6 +707,13 @@ export class WcdbCore {
|
||||
// wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json)
|
||||
this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)')
|
||||
|
||||
// wcdb_status wcdb_get_contact_status(wcdb_handle handle, const char* usernames_json, char** out_json)
|
||||
try {
|
||||
this.wcdbGetContactStatus = this.lib.func('int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetContactStatus = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json)
|
||||
this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
||||
|
||||
@@ -600,6 +831,34 @@ export class WcdbCore {
|
||||
this.wcdbGetSnsAnnualStats = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
||||
try {
|
||||
this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
|
||||
} catch {
|
||||
this.wcdbInstallSnsBlockDeleteTrigger = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_uninstall_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
||||
try {
|
||||
this.wcdbUninstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_uninstall_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
|
||||
} catch {
|
||||
this.wcdbUninstallSnsBlockDeleteTrigger = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_check_sns_block_delete_trigger(wcdb_handle handle, int32_t* out_installed)
|
||||
try {
|
||||
this.wcdbCheckSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_check_sns_block_delete_trigger(int64 handle, _Out_ int32* outInstalled)')
|
||||
} catch {
|
||||
this.wcdbCheckSnsBlockDeleteTrigger = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_delete_sns_post(wcdb_handle handle, const char* post_id, char** out_error)
|
||||
try {
|
||||
this.wcdbDeleteSnsPost = this.lib.func('int32 wcdb_delete_sns_post(int64 handle, const char* postId, _Out_ void** outError)')
|
||||
} catch {
|
||||
this.wcdbDeleteSnsPost = null
|
||||
}
|
||||
|
||||
// Named pipe IPC for monitoring (replaces callback)
|
||||
try {
|
||||
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
||||
@@ -620,12 +879,33 @@ export class WcdbCore {
|
||||
this.wcdbVerifyUser = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_cloud_init(int32_t interval_seconds)
|
||||
try {
|
||||
this.wcdbCloudInit = this.lib.func('int32 wcdb_cloud_init(int32 intervalSeconds)')
|
||||
} catch {
|
||||
this.wcdbCloudInit = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_cloud_report(const char* stats_json)
|
||||
try {
|
||||
this.wcdbCloudReport = this.lib.func('int32 wcdb_cloud_report(const char* statsJson)')
|
||||
} catch {
|
||||
this.wcdbCloudReport = null
|
||||
}
|
||||
|
||||
// void wcdb_cloud_stop()
|
||||
try {
|
||||
this.wcdbCloudStop = this.lib.func('void wcdb_cloud_stop()')
|
||||
} catch {
|
||||
this.wcdbCloudStop = null
|
||||
}
|
||||
|
||||
|
||||
// 初始化
|
||||
const initResult = this.wcdbInit()
|
||||
if (initResult !== 0) {
|
||||
console.error('WCDB 初始化失败:', initResult)
|
||||
lastDllInitError = `初始化失败(错误码: ${initResult})`
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -876,7 +1156,7 @@ export class WcdbCore {
|
||||
}
|
||||
|
||||
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
|
||||
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
|
||||
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`, true)
|
||||
|
||||
if (!dbStoragePath || !existsSync(dbStoragePath)) {
|
||||
console.error('数据库目录不存在:', dbPath)
|
||||
@@ -885,7 +1165,7 @@ export class WcdbCore {
|
||||
}
|
||||
|
||||
const sessionDbPath = this.findSessionDb(dbStoragePath)
|
||||
this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`)
|
||||
this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`, true)
|
||||
if (!sessionDbPath) {
|
||||
console.error('未找到 session.db 文件')
|
||||
this.writeLog('open failed: session.db not found')
|
||||
@@ -911,6 +1191,7 @@ export class WcdbCore {
|
||||
this.currentPath = dbPath
|
||||
this.currentKey = hexKey
|
||||
this.currentWxid = wxid
|
||||
this.currentDbStoragePath = dbStoragePath
|
||||
this.initialized = true
|
||||
if (this.wcdbSetMyWxid && wxid) {
|
||||
try {
|
||||
@@ -922,7 +1203,9 @@ export class WcdbCore {
|
||||
if (this.isLogEnabled()) {
|
||||
this.startLogPolling()
|
||||
}
|
||||
this.writeLog(`open ok handle=${handle}`)
|
||||
this.writeLog(`open ok handle=${handle}`, true)
|
||||
await this.dumpDbStatus('open')
|
||||
await this.runPostOpenDiagnostics(dbPath, dbStoragePath, sessionDbPath, wxid)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('打开数据库异常:', e)
|
||||
@@ -947,6 +1230,7 @@ export class WcdbCore {
|
||||
this.currentPath = null
|
||||
this.currentKey = null
|
||||
this.currentWxid = null
|
||||
this.currentDbStoragePath = null
|
||||
this.initialized = false
|
||||
this.stopLogPolling()
|
||||
}
|
||||
@@ -1024,7 +1308,7 @@ export class WcdbCore {
|
||||
}
|
||||
try {
|
||||
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
|
||||
const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0)
|
||||
const openRes = await this.openMessageCursor(sessionId, limit, true, minTime, 0)
|
||||
if (!openRes.success || !openRes.cursor) {
|
||||
return { success: false, error: openRes.error }
|
||||
}
|
||||
@@ -1062,12 +1346,71 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
|
||||
const normalizedSessionIds = Array.from(
|
||||
new Set(
|
||||
(sessionIds || [])
|
||||
.map((id) => String(id || '').trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
)
|
||||
if (normalizedSessionIds.length === 0) {
|
||||
return { success: true, counts: {} }
|
||||
}
|
||||
|
||||
try {
|
||||
const counts: Record<string, number> = {}
|
||||
for (let i = 0; i < normalizedSessionIds.length; i += 1) {
|
||||
const sessionId = normalizedSessionIds[i]
|
||||
const outCount = [0]
|
||||
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
|
||||
counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0
|
||||
|
||||
if (i > 0 && i % 160 === 0) {
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
}
|
||||
return { success: true, counts }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
if (usernames.length === 0) return { success: true, map: {} }
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
const uniq = Array.from(new Set(usernames.map((x) => String(x || '').trim()).filter(Boolean)))
|
||||
if (uniq.length === 0) return { success: true, map: {} }
|
||||
const inList = uniq.map((u) => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
|
||||
const q = await this.execQuery('contact', null, sql)
|
||||
if (!q.success) return { success: false, error: q.error || '获取昵称失败' }
|
||||
const map: Record<string, string> = {}
|
||||
for (const row of (q.rows || []) as Array<Record<string, any>>) {
|
||||
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
|
||||
if (!username) continue
|
||||
const display = this.pickFirstStringField(row, [
|
||||
'remark', 'Remark',
|
||||
'nick_name', 'nickName', 'nickname', 'NickName',
|
||||
'alias', 'Alias'
|
||||
]) || username
|
||||
map[username] = display
|
||||
}
|
||||
// 保证每个请求用户名至少有回退值
|
||||
for (const u of uniq) {
|
||||
if (!map[u]) map[u] = u
|
||||
}
|
||||
return { success: true, map }
|
||||
}
|
||||
|
||||
// 让出控制权,避免阻塞事件循环
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
@@ -1116,6 +1459,34 @@ export class WcdbCore {
|
||||
return { success: true, map: resultMap }
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const inList = toFetch.map((u) => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
|
||||
const q = await this.execQuery('contact', null, sql)
|
||||
if (!q.success) {
|
||||
if (Object.keys(resultMap).length > 0) {
|
||||
return { success: true, map: resultMap, error: q.error || '获取头像失败' }
|
||||
}
|
||||
return { success: false, error: q.error || '获取头像失败' }
|
||||
}
|
||||
|
||||
for (const row of (q.rows || []) as Array<Record<string, any>>) {
|
||||
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
|
||||
if (!username) continue
|
||||
const url = this.pickFirstStringField(row, [
|
||||
'big_head_img_url', 'bigHeadImgUrl', 'bigHeadUrl', 'big_head_url',
|
||||
'small_head_img_url', 'smallHeadImgUrl', 'smallHeadUrl', 'small_head_url',
|
||||
'head_img_url', 'headImgUrl',
|
||||
'avatar_url', 'avatarUrl'
|
||||
])
|
||||
if (url) {
|
||||
resultMap[username] = url
|
||||
this.avatarUrlCache.set(username, { url, updatedAt: now })
|
||||
}
|
||||
}
|
||||
return { success: true, map: resultMap }
|
||||
}
|
||||
|
||||
// 让出控制权,避免阻塞事件循环
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
@@ -1324,10 +1695,42 @@ export class WcdbCore {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
const safe = String(username || '').replace(/'/g, "''")
|
||||
const sql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1`
|
||||
const q = await this.execQuery('contact', null, sql)
|
||||
if (!q.success) {
|
||||
return { success: false, error: q.error || '获取联系人失败' }
|
||||
}
|
||||
const row = Array.isArray(q.rows) && q.rows.length > 0 ? q.rows[0] : null
|
||||
if (!row) {
|
||||
return { success: false, error: `联系人不存在: ${username}` }
|
||||
}
|
||||
return { success: true, contact: row }
|
||||
}
|
||||
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetContact(this.handle, username, outPtr)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `获取联系人失败: ${result}` }
|
||||
this.writeLog(`[diag:getContact] primary api failed username=${username} code=${result} outPtr=${outPtr[0] ? 'set' : 'null'}`, true)
|
||||
await this.dumpDbStatus('getContact-primary-fail')
|
||||
await this.printLogs(true)
|
||||
|
||||
// Fallback: 直接查询 contact 表,便于区分是接口失败还是 contact 库本身不可读。
|
||||
const safe = String(username || '').replace(/'/g, "''")
|
||||
const fallbackSql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1`
|
||||
const fallback = await this.execQuery('contact', null, fallbackSql)
|
||||
if (fallback.success) {
|
||||
const row = Array.isArray(fallback.rows) ? fallback.rows[0] : null
|
||||
if (row) {
|
||||
this.writeLog(`[diag:getContact] fallback sql hit username=${username}`, true)
|
||||
return { success: true, contact: row }
|
||||
}
|
||||
this.writeLog(`[diag:getContact] fallback sql no row username=${username}`, true)
|
||||
return { success: false, error: `联系人不存在: ${username}` }
|
||||
}
|
||||
this.writeLog(`[diag:getContact] fallback sql failed username=${username} err=${fallback.error || 'unknown'}`, true)
|
||||
return { success: false, error: `获取联系人失败: ${result}; fallback=${fallback.error || 'unknown'}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析联系人失败' }
|
||||
@@ -1338,6 +1741,36 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
try {
|
||||
// 分批查询,避免 SQL 过长(execQuery 不支持参数绑定,直接拼 SQL)
|
||||
const BATCH = 200
|
||||
const map: Record<string, { isFolded: boolean; isMuted: boolean }> = {}
|
||||
for (let i = 0; i < usernames.length; i += BATCH) {
|
||||
const batch = usernames.slice(i, i + BATCH)
|
||||
const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||
const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})`
|
||||
const result = await this.execQuery('contact', null, sql)
|
||||
if (!result.success || !result.rows) continue
|
||||
for (const row of result.rows) {
|
||||
const uname: string = row.username
|
||||
// 折叠:flag bit 28 (0x10000000)
|
||||
const flag = parseInt(row.flag ?? '0', 10)
|
||||
const isFolded = (flag & 0x10000000) !== 0
|
||||
// 免打扰:extra_buffer field 12 非0
|
||||
const { isMuted } = parseExtraBuffer(row.extra_buffer)
|
||||
map[uname] = { isFolded, isMuted }
|
||||
}
|
||||
}
|
||||
return { success: true, map }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
@@ -1620,22 +2053,57 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
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, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
try {
|
||||
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
||||
|
||||
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
|
||||
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
|
||||
// TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定
|
||||
if (params && params.length > 0) {
|
||||
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)')
|
||||
}
|
||||
|
||||
const normalizedKind = String(kind || '').toLowerCase()
|
||||
const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
|
||||
let effectivePath = path || ''
|
||||
if (normalizedKind === 'contact' && !effectivePath) {
|
||||
const resolvedContactDb = this.resolveContactDbPath()
|
||||
if (resolvedContactDb) {
|
||||
effectivePath = resolvedContactDb
|
||||
this.writeLog(`[diag:execQuery] contact path override -> ${effectivePath}`, true)
|
||||
} else {
|
||||
this.writeLog('[diag:execQuery] contact path override miss: Contact/contact.db not found', true)
|
||||
}
|
||||
}
|
||||
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
|
||||
const result = this.wcdbExecQuery(this.handle, kind, effectivePath, sql, outPtr)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
if (isContactQuery) {
|
||||
this.writeLog(`[diag:execQuery] contact query failed code=${result} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true)
|
||||
await this.dumpDbStatus('execQuery-contact-fail')
|
||||
await this.printLogs(true)
|
||||
}
|
||||
return { success: false, error: `执行查询失败: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析查询结果失败' }
|
||||
const rows = JSON.parse(jsonStr)
|
||||
if (isContactQuery) {
|
||||
const count = Array.isArray(rows) ? rows.length : -1
|
||||
this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true)
|
||||
}
|
||||
return { success: true, rows }
|
||||
} catch (e) {
|
||||
const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
|
||||
if (isContactQuery) {
|
||||
this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true)
|
||||
await this.dumpDbStatus('execQuery-contact-exception')
|
||||
}
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -1721,8 +2189,57 @@ export class WcdbCore {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Windows Hello
|
||||
* 数据收集初始化
|
||||
*/
|
||||
async cloudInit(intervalSeconds: number = 600): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.initialized) {
|
||||
const initOk = await this.initialize()
|
||||
if (!initOk) return { success: false, error: 'WCDB init failed' }
|
||||
}
|
||||
if (!this.wcdbCloudInit) {
|
||||
return { success: false, error: 'Cloud init API not supported by DLL' }
|
||||
}
|
||||
try {
|
||||
const result = this.wcdbCloudInit(intervalSeconds)
|
||||
if (result !== 0) {
|
||||
return { success: false, error: `Cloud init failed: ${result}` }
|
||||
}
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.initialized) {
|
||||
const initOk = await this.initialize()
|
||||
if (!initOk) return { success: false, error: 'WCDB init failed' }
|
||||
}
|
||||
if (!this.wcdbCloudReport) {
|
||||
return { success: false, error: 'Cloud report API not supported by DLL' }
|
||||
}
|
||||
try {
|
||||
const result = this.wcdbCloudReport(statsJson || '')
|
||||
if (result !== 0) {
|
||||
return { success: false, error: `Cloud report failed: ${result}` }
|
||||
}
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
cloudStop(): { success: boolean; error?: string } {
|
||||
if (!this.wcdbCloudStop) {
|
||||
return { success: false, error: 'Cloud stop API not supported by DLL' }
|
||||
}
|
||||
try {
|
||||
this.wcdbCloudStop()
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.initialized) {
|
||||
const initOk = await this.initialize()
|
||||
@@ -1805,6 +2322,94 @@ export class WcdbCore {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 为朋友圈安装删除
|
||||
*/
|
||||
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||
try {
|
||||
const outPtr = [null]
|
||||
const status = this.wcdbInstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
||||
let msg = ''
|
||||
if (outPtr[0]) {
|
||||
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||
}
|
||||
if (status === 1) {
|
||||
// DLL 返回 1 表示已安装
|
||||
return { success: true, alreadyInstalled: true }
|
||||
}
|
||||
if (status !== 0) {
|
||||
return { success: false, error: msg || `DLL error ${status}` }
|
||||
}
|
||||
return { success: true, alreadyInstalled: false }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭朋友圈删除拦截
|
||||
*/
|
||||
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||
try {
|
||||
const outPtr = [null]
|
||||
const status = this.wcdbUninstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
||||
let msg = ''
|
||||
if (outPtr[0]) {
|
||||
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||
}
|
||||
if (status !== 0) {
|
||||
return { success: false, error: msg || `DLL error ${status}` }
|
||||
}
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询朋友圈删除拦截是否已安装
|
||||
*/
|
||||
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||
try {
|
||||
const outInstalled = [0]
|
||||
const status = this.wcdbCheckSnsBlockDeleteTrigger(this.handle, outInstalled)
|
||||
if (status !== 0) {
|
||||
return { success: false, error: `DLL error ${status}` }
|
||||
}
|
||||
return { success: true, installed: outInstalled[0] === 1 }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||
try {
|
||||
const outPtr = [null]
|
||||
const status = this.wcdbDeleteSnsPost(this.handle, postId, outPtr)
|
||||
let msg = ''
|
||||
if (outPtr[0]) {
|
||||
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||
}
|
||||
if (status !== 0) {
|
||||
return { success: false, error: msg || `DLL error ${status}` }
|
||||
}
|
||||
return { success: true }
|
||||
} catch (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 未连接' }
|
||||
@@ -1885,4 +2490,3 @@ export class WcdbCore {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ export class WcdbService {
|
||||
*/
|
||||
setMonitor(callback: (type: string, json: string) => void): void {
|
||||
this.monitorListener = callback;
|
||||
this.callWorker('setMonitor').catch(() => { });
|
||||
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,6 +218,10 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageCount', { sessionId })
|
||||
}
|
||||
|
||||
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
return this.callWorker('getMessageCounts', { sessionIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人昵称
|
||||
*/
|
||||
@@ -290,6 +294,13 @@ export class WcdbService {
|
||||
return this.callWorker('getContact', { username })
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取联系人 extra_buffer 状态(isFolded/isMuted)
|
||||
*/
|
||||
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||
return this.callWorker('getContactStatus', { usernames })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聚合统计数据
|
||||
*/
|
||||
@@ -361,10 +372,10 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询
|
||||
* 执行 SQL 查询(支持参数化查询)
|
||||
*/
|
||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('execQuery', { kind, path, sql })
|
||||
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('execQuery', { kind, path, sql, params })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -416,6 +427,34 @@ export class WcdbService {
|
||||
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装朋友圈删除拦截
|
||||
*/
|
||||
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||
return this.callWorker('installSnsBlockDeleteTrigger')
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载朋友圈删除拦截
|
||||
*/
|
||||
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('uninstallSnsBlockDeleteTrigger')
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询朋友圈删除拦截是否已安装
|
||||
*/
|
||||
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||
return this.callWorker('checkSnsBlockDeleteTrigger')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库直接删除朋友圈记录
|
||||
*/
|
||||
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('deleteSnsPost', { postId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 DLL 内部日志
|
||||
*/
|
||||
@@ -444,6 +483,27 @@ export class WcdbService {
|
||||
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据收集:初始化
|
||||
*/
|
||||
async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('cloudInit', { intervalSeconds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据收集:上报数据
|
||||
*/
|
||||
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('cloudReport', { statsJson })
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据收集:停止
|
||||
*/
|
||||
cloudStop(): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('cloudStop', {})
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
interface WorkerParams {
|
||||
modelPath: string
|
||||
tokensPath: string
|
||||
wavData: Buffer
|
||||
wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] }
|
||||
sampleRate: number
|
||||
languages?: string[]
|
||||
}
|
||||
|
||||
function appendLibrarySearchPath(libDir: string): void {
|
||||
if (!existsSync(libDir)) return
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const current = process.env.DYLD_LIBRARY_PATH || ''
|
||||
const paths = current.split(':').filter(Boolean)
|
||||
if (!paths.includes(libDir)) {
|
||||
process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
const current = process.env.LD_LIBRARY_PATH || ''
|
||||
const paths = current.split(':').filter(Boolean)
|
||||
if (!paths.includes(libDir)) {
|
||||
process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prepareSherpaRuntimeEnv(): void {
|
||||
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||
const resourcesPath = (process as any).resourcesPath as string | undefined
|
||||
|
||||
const candidates = [
|
||||
// Dev: /project/dist-electron -> /project/node_modules/...
|
||||
join(__dirname, '..', 'node_modules', platformPkg),
|
||||
// Fallback for alternate layouts
|
||||
join(__dirname, 'node_modules', platformPkg),
|
||||
join(process.cwd(), 'node_modules', platformPkg),
|
||||
// Packaged app: Resources/app.asar.unpacked/node_modules/...
|
||||
resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||
].filter(Boolean)
|
||||
|
||||
for (const dir of candidates) {
|
||||
appendLibrarySearchPath(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// 语言标记映射
|
||||
const LANGUAGE_TAGS: Record<string, string> = {
|
||||
'zh': '<|zh|>',
|
||||
@@ -95,22 +138,60 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!parentPort) {
|
||||
return;
|
||||
const isForkProcess = !parentPort
|
||||
const emit = (msg: any) => {
|
||||
if (parentPort) {
|
||||
parentPort.postMessage(msg)
|
||||
return
|
||||
}
|
||||
if (typeof process.send === 'function') {
|
||||
process.send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => {
|
||||
if (Buffer.isBuffer(data)) return data
|
||||
if (data instanceof Uint8Array) return Buffer.from(data)
|
||||
if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) {
|
||||
return Buffer.from((data as any).data)
|
||||
}
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
|
||||
const readParams = async (): Promise<WorkerParams | null> => {
|
||||
if (parentPort) {
|
||||
return workerData as WorkerParams
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (value: WorkerParams | null) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
process.once('message', (msg) => finish(msg as WorkerParams))
|
||||
process.once('disconnect', () => finish(null))
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
prepareSherpaRuntimeEnv()
|
||||
const params = await readParams()
|
||||
if (!params) return
|
||||
|
||||
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
||||
let sherpa: any;
|
||||
try {
|
||||
sherpa = require('sherpa-onnx-node');
|
||||
} catch (requireError) {
|
||||
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||
emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||
if (isForkProcess) process.exit(1)
|
||||
return;
|
||||
}
|
||||
|
||||
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
|
||||
const wavData = Buffer.from(rawWavData);
|
||||
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params
|
||||
const wavData = normalizeBuffer(rawWavData);
|
||||
// 确保有有效的语言列表,默认只允许中文
|
||||
let allowedLanguages = languages || ['zh']
|
||||
if (allowedLanguages.length === 0) {
|
||||
@@ -151,16 +232,18 @@ async function run() {
|
||||
if (isLanguageAllowed(result, allowedLanguages)) {
|
||||
const processedText = richTranscribePostProcess(result.text)
|
||||
|
||||
parentPort.postMessage({ type: 'final', text: processedText })
|
||||
emit({ type: 'final', text: processedText })
|
||||
if (isForkProcess) process.exit(0)
|
||||
} else {
|
||||
|
||||
parentPort.postMessage({ type: 'final', text: '' })
|
||||
emit({ type: 'final', text: '' })
|
||||
if (isForkProcess) process.exit(0)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ type: 'error', error: String(error) })
|
||||
emit({ type: 'error', error: String(error) })
|
||||
if (isForkProcess) process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
|
||||
114
electron/utils/LRUCache.ts
Normal file
114
electron/utils/LRUCache.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* LRU (Least Recently Used) Cache implementation for memory management
|
||||
*/
|
||||
export class LRUCache<K, V> {
|
||||
private cache: Map<K, V>
|
||||
private maxSize: number
|
||||
|
||||
constructor(maxSize: number = 100) {
|
||||
this.maxSize = maxSize
|
||||
this.cache = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value from cache
|
||||
*/
|
||||
get(key: K): V | undefined {
|
||||
const value = this.cache.get(key)
|
||||
if (value !== undefined) {
|
||||
// Move to end (most recently used)
|
||||
this.cache.delete(key)
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Set value in cache
|
||||
*/
|
||||
set(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
// Update existing
|
||||
this.cache.delete(key)
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
// Remove least recently used (first item)
|
||||
const firstKey = this.cache.keys().next().value
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key exists
|
||||
*/
|
||||
has(key: K): boolean {
|
||||
return this.cache.has(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete key from cache
|
||||
*/
|
||||
delete(key: K): boolean {
|
||||
return this.cache.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache entries
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cache size
|
||||
*/
|
||||
get size(): number {
|
||||
return this.cache.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys (for debugging)
|
||||
*/
|
||||
keys(): IterableIterator<K> {
|
||||
return this.cache.keys()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values (for debugging)
|
||||
*/
|
||||
values(): IterableIterator<V> {
|
||||
return this.cache.values()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entries (for iteration support)
|
||||
*/
|
||||
entries(): IterableIterator<[K, V]> {
|
||||
return this.cache.entries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Make LRUCache iterable (for...of support)
|
||||
*/
|
||||
[Symbol.iterator](): IterableIterator<[K, V]> {
|
||||
return this.cache.entries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force cleanup (optional method for explicit memory management)
|
||||
*/
|
||||
cleanup(): void {
|
||||
// In JavaScript/TypeScript, this is mainly for consistency
|
||||
// The garbage collector will handle actual memory cleanup
|
||||
if (this.cache.size > this.maxSize * 1.5) {
|
||||
// Emergency cleanup if cache somehow exceeds limit
|
||||
const entries = Array.from(this.cache.entries())
|
||||
this.cache.clear()
|
||||
// Keep only the most recent half
|
||||
const keepEntries = entries.slice(-Math.floor(this.maxSize / 2))
|
||||
keepEntries.forEach(([key, value]) => this.cache.set(key, value))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,17 @@ if (parentPort) {
|
||||
result = { success: true }
|
||||
break
|
||||
case 'setMonitor':
|
||||
core.setMonitor((type, json) => {
|
||||
{
|
||||
const monitorOk = core.setMonitor((type, json) => {
|
||||
parentPort!.postMessage({
|
||||
id: -1,
|
||||
type: 'monitor',
|
||||
payload: { type, json }
|
||||
})
|
||||
})
|
||||
result = { success: true }
|
||||
result = { success: monitorOk }
|
||||
break
|
||||
}
|
||||
case 'testConnection':
|
||||
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||
break
|
||||
@@ -54,6 +56,9 @@ if (parentPort) {
|
||||
case 'getMessageCount':
|
||||
result = await core.getMessageCount(payload.sessionId)
|
||||
break
|
||||
case 'getMessageCounts':
|
||||
result = await core.getMessageCounts(payload.sessionIds)
|
||||
break
|
||||
case 'getDisplayNames':
|
||||
result = await core.getDisplayNames(payload.usernames)
|
||||
break
|
||||
@@ -87,6 +92,9 @@ if (parentPort) {
|
||||
case 'getContact':
|
||||
result = await core.getContact(payload.username)
|
||||
break
|
||||
case 'getContactStatus':
|
||||
result = await core.getContactStatus(payload.usernames)
|
||||
break
|
||||
case 'getAggregateStats':
|
||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
@@ -118,7 +126,7 @@ if (parentPort) {
|
||||
result = await core.closeMessageCursor(payload.cursor)
|
||||
break
|
||||
case 'execQuery':
|
||||
result = await core.execQuery(payload.kind, payload.path, payload.sql)
|
||||
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
|
||||
break
|
||||
case 'getEmoticonCdnUrl':
|
||||
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
||||
@@ -144,6 +152,18 @@ if (parentPort) {
|
||||
case 'getSnsAnnualStats':
|
||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'installSnsBlockDeleteTrigger':
|
||||
result = await core.installSnsBlockDeleteTrigger()
|
||||
break
|
||||
case 'uninstallSnsBlockDeleteTrigger':
|
||||
result = await core.uninstallSnsBlockDeleteTrigger()
|
||||
break
|
||||
case 'checkSnsBlockDeleteTrigger':
|
||||
result = await core.checkSnsBlockDeleteTrigger()
|
||||
break
|
||||
case 'deleteSnsPost':
|
||||
result = await core.deleteSnsPost(payload.postId)
|
||||
break
|
||||
case 'getLogs':
|
||||
result = await core.getLogs()
|
||||
break
|
||||
@@ -156,7 +176,15 @@ if (parentPort) {
|
||||
case 'deleteMessage':
|
||||
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
||||
break
|
||||
|
||||
case 'cloudInit':
|
||||
result = await core.cloudInit(payload.intervalSeconds)
|
||||
break
|
||||
case 'cloudReport':
|
||||
result = await core.cloudReport(payload.statsJson)
|
||||
break
|
||||
case 'cloudStop':
|
||||
result = core.cloudStop()
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: `Unknown method: ${type}` }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,28 @@ import { ConfigService } from '../services/config'
|
||||
let notificationWindow: BrowserWindow | null = null
|
||||
let closeTimer: NodeJS.Timeout | null = null
|
||||
|
||||
export function destroyNotificationWindow() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
lastNotificationData = null
|
||||
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null
|
||||
return
|
||||
}
|
||||
|
||||
const win = notificationWindow
|
||||
notificationWindow = null
|
||||
|
||||
try {
|
||||
win.destroy()
|
||||
} catch (error) {
|
||||
console.warn('[NotificationWindow] Failed to destroy window:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationWindow() {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow
|
||||
|
||||
224
package-lock.json
generated
224
package-lock.json
generated
@@ -9,7 +9,6 @@
|
||||
"version": "2.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"echarts": "^5.5.1",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^10.0.0",
|
||||
@@ -35,7 +34,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -2784,16 +2782,6 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmmirror.com/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cacheable-request": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
||||
@@ -3868,20 +3856,6 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.5.0.tgz",
|
||||
"integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/big-integer": {
|
||||
"version": "1.6.52",
|
||||
"resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz",
|
||||
@@ -3904,15 +3878,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz",
|
||||
@@ -4924,6 +4889,7 @@
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
@@ -4939,6 +4905,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -4947,15 +4914,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/defaults": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz",
|
||||
@@ -5047,6 +5005,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5817,15 +5776,6 @@
|
||||
"node": ">=8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/exponential-backoff": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||
@@ -5964,12 +5914,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz",
|
||||
@@ -6272,12 +6216,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
|
||||
@@ -6744,12 +6682,6 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
@@ -8503,12 +8435,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||
@@ -8534,12 +8460,6 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz",
|
||||
@@ -9003,44 +8923,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/node-abi": {
|
||||
"version": "3.85.0",
|
||||
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.85.0.tgz",
|
||||
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-5.0.0.tgz",
|
||||
@@ -9101,6 +8983,7 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.3.tgz",
|
||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
@@ -9130,21 +9013,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
||||
@@ -9868,51 +9736,6 @@
|
||||
"node": ">=16.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
@@ -10142,15 +9965,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stubborn-fs": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/stubborn-fs/-/stubborn-fs-2.0.0.tgz",
|
||||
@@ -10228,24 +10042,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
@@ -10522,18 +10318,6 @@
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz",
|
||||
|
||||
22
package.json
22
package.json
@@ -13,13 +13,13 @@
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"rebuild": "electron-rebuild",
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "vite --mode electron",
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"echarts": "^5.5.1",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^10.0.0",
|
||||
@@ -45,7 +45,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -71,6 +70,18 @@
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"category": "public.app-category.utilities",
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"icon": "resources/icon.icns"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
@@ -119,6 +130,8 @@
|
||||
"asarUnpack": [
|
||||
"node_modules/silk-wasm/**/*",
|
||||
"node_modules/sherpa-onnx-node/**/*",
|
||||
"node_modules/sherpa-onnx-*/*",
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"extraFiles": [
|
||||
@@ -138,6 +151,7 @@
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
]
|
||||
],
|
||||
"icon": "resources/icon.icns"
|
||||
}
|
||||
}
|
||||
}
|
||||
249
public/splash.html
Normal file
249
public/splash.html
Normal file
@@ -0,0 +1,249 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WeFlow</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.splash {
|
||||
width: 100%; height: 100%;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 品牌区 */
|
||||
.brand {
|
||||
padding: 48px 52px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
animation: fadeIn 0.4s ease both;
|
||||
}
|
||||
.logo {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.app-desc {
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.spacer { flex: 1; }
|
||||
|
||||
/* 底部进度区 */
|
||||
.bottom {
|
||||
padding: 0 48px 40px;
|
||||
animation: fadeIn 0.4s ease 0.1s both;
|
||||
}
|
||||
|
||||
/* 进度条轨道 */
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 进度条填充 */
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 扫光:只在有进度时显示,不循环 */
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
|
||||
animation: sweep 1.2s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 等待阶段:进度条末端呼吸光点 */
|
||||
.progress-fill.waiting::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px; right: -2px;
|
||||
width: 6px; height: 4px;
|
||||
border-radius: 50%;
|
||||
background: inherit;
|
||||
filter: blur(2px);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.progress-text {
|
||||
font-size: 11px;
|
||||
opacity: 0.38;
|
||||
}
|
||||
.version {
|
||||
font-size: 11px;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes sweep {
|
||||
0% { opacity: 0; transform: translateX(-100%); }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateX(100%); }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scaleX(1); }
|
||||
50% { opacity: 1; transform: scaleX(1.8); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="splash" id="splash">
|
||||
<div class="brand">
|
||||
<img class="logo" src="./logo.png" alt="WeFlow" />
|
||||
<div class="brand-text">
|
||||
<div class="app-name" id="appName">WeFlow</div>
|
||||
<div class="app-desc" id="appDesc">微信聊天记录管理工具</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="progress-track" id="progressTrack">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<div class="bottom-row">
|
||||
<div class="progress-text" id="progressText">正在启动...</div>
|
||||
<div class="version" id="versionText"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var themes = {
|
||||
'cloud-dancer': {
|
||||
light: { primary: '#8B7355', bg: '#F0EEE9', bgEnd: '#E5E1DA', text: '#3d3d3d', desc: '#8B7355' },
|
||||
dark: { primary: '#C9A86C', bg: '#1a1816', bgEnd: '#252220', text: '#F0EEE9', desc: '#C9A86C' }
|
||||
},
|
||||
'corundum-blue': {
|
||||
light: { primary: '#4A6670', bg: '#E8EEF0', bgEnd: '#D8E4E8', text: '#3d3d3d', desc: '#4A6670' },
|
||||
dark: { primary: '#6A9AAA', bg: '#141a1c', bgEnd: '#1e2a2e', text: '#E0EEF2', desc: '#6A9AAA' }
|
||||
},
|
||||
'kiwi-green': {
|
||||
light: { primary: '#7A9A5C', bg: '#E8F0E4', bgEnd: '#D8E8D2', text: '#3d3d3d', desc: '#7A9A5C' },
|
||||
dark: { primary: '#9ABA7C', bg: '#161a14', bgEnd: '#222a1e', text: '#E8F0E4', desc: '#9ABA7C' }
|
||||
},
|
||||
'spicy-red': {
|
||||
light: { primary: '#8B4049', bg: '#F0E8E8', bgEnd: '#E8D8D8', text: '#3d3d3d', desc: '#8B4049' },
|
||||
dark: { primary: '#C06068', bg: '#1a1416', bgEnd: '#261e20', text: '#F2E8EA', desc: '#C06068' }
|
||||
},
|
||||
'teal-water': {
|
||||
light: { primary: '#5A8A8A', bg: '#E4F0F0', bgEnd: '#D2E8E8', text: '#3d3d3d', desc: '#5A8A8A' },
|
||||
dark: { primary: '#7ABAAA', bg: '#121a1a', bgEnd: '#1a2626', text: '#E0F2EE', desc: '#7ABAAA' }
|
||||
},
|
||||
'blossom-dream': {
|
||||
light: { primary: '#D4849A', primaryEnd: '#D4849A', bg: '#FCF9FB', bgMid: '#F8F2F8', bgEnd: '#F2F6FB', text: '#2E2633', desc: '#D4849A' },
|
||||
dark: { primary: '#C670C3', primaryEnd: '#8A60C0', bg: '#120B16', bgMid: '#1A1020', bgEnd: '#0E0B18', text: '#F2EAF4', desc: '#C670C3' }
|
||||
}
|
||||
};
|
||||
|
||||
function applyTheme(themeId, mode) {
|
||||
var t = themes[themeId] || themes['cloud-dancer'];
|
||||
var isDark = mode === 'dark';
|
||||
if (mode === 'system') isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var c = isDark ? t.dark : t.light;
|
||||
|
||||
var el = document.getElementById('splash');
|
||||
var fill = document.getElementById('progressFill');
|
||||
|
||||
if (themeId === 'blossom-dream') {
|
||||
if (isDark) {
|
||||
// 深色
|
||||
el.style.background =
|
||||
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '28 0%, transparent 70%), ' +
|
||||
'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||
} else {
|
||||
// 浅色
|
||||
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||
}
|
||||
// 进度条
|
||||
fill.style.background = 'linear-gradient(90deg, ' + c.primary + ' 0%, ' + c.primaryEnd + ' 100%)';
|
||||
} else {
|
||||
if (isDark) {
|
||||
el.style.background =
|
||||
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '22 0%, transparent 70%), ' +
|
||||
'linear-gradient(145deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||
} else {
|
||||
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||
}
|
||||
fill.style.background = c.primary;
|
||||
}
|
||||
|
||||
document.getElementById('appName').style.color = c.text;
|
||||
document.getElementById('appDesc').style.color = c.desc;
|
||||
document.getElementById('progressText').style.color = c.text;
|
||||
document.getElementById('versionText').style.color = c.text;
|
||||
document.getElementById('progressTrack').style.background = c.primary + (isDark ? '25' : '18');
|
||||
}
|
||||
|
||||
// percent: 实际进度值;waiting: 是否处于等待阶段
|
||||
function updateProgress(percent, text, waiting) {
|
||||
var fill = document.getElementById('progressFill');
|
||||
var label = document.getElementById('progressText');
|
||||
|
||||
if (fill) {
|
||||
fill.style.width = percent + '%';
|
||||
if (waiting) {
|
||||
fill.classList.add('waiting');
|
||||
} else {
|
||||
fill.classList.remove('waiting');
|
||||
// 触发扫光:重置动画
|
||||
fill.style.animation = 'none';
|
||||
fill.offsetHeight;
|
||||
fill.style.animation = '';
|
||||
}
|
||||
}
|
||||
if (label && text) label.textContent = text;
|
||||
}
|
||||
|
||||
function setVersion(ver) {
|
||||
var el = document.getElementById('versionText');
|
||||
if (el) el.textContent = 'v' + ver;
|
||||
}
|
||||
|
||||
applyTheme('cloud-dancer', 'light');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
resources/icon.icns
Normal file
BIN
resources/icon.icns
Normal file
Binary file not shown.
10
resources/image_scan_entitlements.plist
Normal file
10
resources/image_scan_entitlements.plist
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.debugger</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
resources/image_scan_helper
Executable file
BIN
resources/image_scan_helper
Executable file
Binary file not shown.
77
resources/image_scan_helper.c
Normal file
77
resources/image_scan_helper.c
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* image_scan_helper - 轻量包装程序
|
||||
* 加载 libwx_key.dylib 并调用 ScanMemoryForImageKey
|
||||
* 用法: image_scan_helper <pid> <ciphertext_hex>
|
||||
* 输出: JSON {"success":true,"aesKey":"..."} 或 {"success":false,"error":"..."}
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <dlfcn.h>
|
||||
#include <libgen.h>
|
||||
#include <mach-o/dyld.h>
|
||||
|
||||
typedef const char* (*ScanMemoryForImageKeyFn)(int pid, const char* ciphertext);
|
||||
typedef void (*FreeStringFn)(const char* str);
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
if (argc != 3) {
|
||||
fprintf(stderr, "Usage: %s <pid> <ciphertext_hex>\n", argv[0]);
|
||||
printf("{\"success\":false,\"error\":\"invalid arguments\"}\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
int pid = atoi(argv[1]);
|
||||
const char* ciphertext_hex = argv[2];
|
||||
|
||||
if (pid <= 0) {
|
||||
printf("{\"success\":false,\"error\":\"invalid pid\"}\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* 定位 dylib: 与自身同目录下的 libwx_key.dylib */
|
||||
char exe_path[4096];
|
||||
uint32_t size = sizeof(exe_path);
|
||||
if (_NSGetExecutablePath(exe_path, &size) != 0) {
|
||||
printf("{\"success\":false,\"error\":\"cannot get executable path\"}\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
char* dir = dirname(exe_path);
|
||||
char dylib_path[4096];
|
||||
snprintf(dylib_path, sizeof(dylib_path), "%s/libwx_key.dylib", dir);
|
||||
|
||||
void* handle = dlopen(dylib_path, RTLD_LAZY);
|
||||
if (!handle) {
|
||||
printf("{\"success\":false,\"error\":\"dlopen failed: %s\"}\n", dlerror());
|
||||
return 1;
|
||||
}
|
||||
|
||||
ScanMemoryForImageKeyFn scan_fn = (ScanMemoryForImageKeyFn)dlsym(handle, "ScanMemoryForImageKey");
|
||||
if (!scan_fn) {
|
||||
printf("{\"success\":false,\"error\":\"symbol not found: ScanMemoryForImageKey\"}\n");
|
||||
dlclose(handle);
|
||||
return 1;
|
||||
}
|
||||
|
||||
FreeStringFn free_fn = (FreeStringFn)dlsym(handle, "FreeString");
|
||||
|
||||
fprintf(stderr, "[image_scan_helper] calling ScanMemoryForImageKey(pid=%d, ciphertext=%s)\n", pid, ciphertext_hex);
|
||||
|
||||
const char* result = scan_fn(pid, ciphertext_hex);
|
||||
|
||||
if (result && strlen(result) > 0) {
|
||||
/* 检查是否是错误 */
|
||||
if (strncmp(result, "ERROR", 5) == 0) {
|
||||
printf("{\"success\":false,\"error\":\"%s\"}\n", result);
|
||||
} else {
|
||||
printf("{\"success\":true,\"aesKey\":\"%s\"}\n", result);
|
||||
}
|
||||
if (free_fn) free_fn(result);
|
||||
} else {
|
||||
printf("{\"success\":false,\"error\":\"no key found\"}\n");
|
||||
}
|
||||
|
||||
dlclose(handle);
|
||||
return 0;
|
||||
}
|
||||
BIN
resources/libwx_key.dylib
Executable file
BIN
resources/libwx_key.dylib
Executable file
Binary file not shown.
BIN
resources/macos/libWCDB.dylib
Executable file
BIN
resources/macos/libWCDB.dylib
Executable file
Binary file not shown.
BIN
resources/macos/libwcdb_api.dylib
Executable file
BIN
resources/macos/libwcdb_api.dylib
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/xkey_helper
Executable file
BIN
resources/xkey_helper
Executable file
Binary file not shown.
55
src/App.scss
55
src/App.scss
@@ -4,6 +4,48 @@
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
animation: appFadeIn 0.35s ease-out;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 繁花如梦:底色层(::before)+ 光晕层(::after)分离,避免 blur 吃掉边缘
|
||||
[data-theme="blossom-dream"] .app-container {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// ::before 纯底色,不模糊
|
||||
[data-theme="blossom-dream"] .app-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -2;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
// ::after 光晕层,模糊叠加在底色上
|
||||
[data-theme="blossom-dream"] .app-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-peach) 0%, transparent 65%),
|
||||
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||
filter: blur(80px);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
// 深色模式光晕更克制
|
||||
[data-theme="blossom-dream"][data-mode="dark"] .app-container::after {
|
||||
background:
|
||||
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-purple) 0%, transparent 65%),
|
||||
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||
filter: blur(100px);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.window-drag-region {
|
||||
@@ -27,6 +69,19 @@
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.export-keepalive-page {
|
||||
height: 100%;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.export-route-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes appFadeIn {
|
||||
|
||||
185
src/App.tsx
185
src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom'
|
||||
import TitleBar from './components/TitleBar'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import RouteGuard from './components/RouteGuard'
|
||||
@@ -8,6 +8,7 @@ import HomePage from './pages/HomePage'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import AnalyticsPage from './pages/AnalyticsPage'
|
||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
|
||||
import AnnualReportPage from './pages/AnnualReportPage'
|
||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||
import DualReportPage from './pages/DualReportPage'
|
||||
@@ -26,6 +27,7 @@ import NotificationWindow from './pages/NotificationWindow'
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||
import * as configService from './services/config'
|
||||
import * as cloudControl from './services/cloudControl'
|
||||
import { Download, X, Shield } from 'lucide-react'
|
||||
import './App.scss'
|
||||
|
||||
@@ -34,10 +36,24 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||
import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||
|
||||
function RouteStateRedirect({ to }: { to: string }) {
|
||||
const location = useLocation()
|
||||
|
||||
return <Navigate to={to} replace state={location.state} />
|
||||
}
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const settingsBackgroundRef = useRef<Location>({
|
||||
pathname: '/home',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'settings-fallback'
|
||||
} as Location)
|
||||
|
||||
const {
|
||||
setDbConnected,
|
||||
@@ -59,8 +75,16 @@ function App() {
|
||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||
const isNotificationWindow = location.pathname === '/notification-window'
|
||||
const isSettingsRoute = location.pathname === '/settings'
|
||||
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||
const routeLocation = isSettingsRoute
|
||||
? settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||
: location
|
||||
const isExportRoute = routeLocation.pathname === '/export'
|
||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
// 锁定状态
|
||||
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||
@@ -74,6 +98,15 @@ function App() {
|
||||
const [agreementChecked, setAgreementChecked] = useState(false)
|
||||
const [agreementLoading, setAgreementLoading] = useState(true)
|
||||
|
||||
// 数据收集同意状态
|
||||
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname !== '/settings') {
|
||||
settingsBackgroundRef.current = location
|
||||
}
|
||||
}, [location])
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const body = document.body
|
||||
@@ -105,10 +138,6 @@ function App() {
|
||||
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
|
||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||
document.documentElement.setAttribute('data-mode', effectiveMode)
|
||||
const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
||||
if (!isOnboardingWindow && !isNotificationWindow) {
|
||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
||||
}
|
||||
}
|
||||
|
||||
applyMode(themeMode)
|
||||
@@ -169,6 +198,14 @@ function App() {
|
||||
const agreed = await configService.getAgreementAccepted()
|
||||
if (!agreed) {
|
||||
setShowAgreement(true)
|
||||
} else {
|
||||
// 协议已同意,检查数据收集同意状态
|
||||
const consent = await configService.getAnalyticsConsent()
|
||||
const denyCount = await configService.getAnalyticsDenyCount()
|
||||
// 如果未设置同意状态且拒绝次数小于2次,显示弹窗
|
||||
if (consent === null && denyCount < 2) {
|
||||
setShowAnalyticsConsent(true)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查协议状态失败:', e)
|
||||
@@ -179,16 +216,45 @@ function App() {
|
||||
checkAgreement()
|
||||
}, [])
|
||||
|
||||
// 初始化数据收集
|
||||
useEffect(() => {
|
||||
cloudControl.initCloudControl()
|
||||
}, [])
|
||||
|
||||
// 记录页面访问
|
||||
useEffect(() => {
|
||||
const path = location.pathname
|
||||
if (path && path !== '/') {
|
||||
cloudControl.recordPage(path)
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
const handleAgree = async () => {
|
||||
if (!agreementChecked) return
|
||||
await configService.setAgreementAccepted(true)
|
||||
setShowAgreement(false)
|
||||
// 协议同意后,检查数据收集同意
|
||||
const consent = await configService.getAnalyticsConsent()
|
||||
if (consent === null) {
|
||||
setShowAnalyticsConsent(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisagree = () => {
|
||||
window.electronAPI.window.close()
|
||||
}
|
||||
|
||||
const handleAnalyticsAllow = async () => {
|
||||
await configService.setAnalyticsConsent(true)
|
||||
setShowAnalyticsConsent(false)
|
||||
}
|
||||
|
||||
const handleAnalyticsDeny = async () => {
|
||||
const denyCount = await configService.getAnalyticsDenyCount()
|
||||
await configService.setAnalyticsDenyCount(denyCount + 1)
|
||||
setShowAnalyticsConsent(false)
|
||||
}
|
||||
|
||||
// 监听启动时的更新通知
|
||||
useEffect(() => {
|
||||
if (isNotificationWindow) return // Skip updates in notification window
|
||||
@@ -312,7 +378,7 @@ function App() {
|
||||
const checkLock = async () => {
|
||||
// 并行获取配置,减少等待
|
||||
const [enabled, useHello] = await Promise.all([
|
||||
configService.getAuthEnabled(),
|
||||
window.electronAPI.auth.verifyEnabled(),
|
||||
configService.getAuthUseHello()
|
||||
])
|
||||
|
||||
@@ -359,12 +425,51 @@ function App() {
|
||||
return <ChatHistoryPage />
|
||||
}
|
||||
|
||||
// 独立会话聊天窗口(仅显示聊天内容区域)
|
||||
if (isStandaloneChatWindow) {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const sessionId = params.get('sessionId') || ''
|
||||
const standaloneSource = params.get('source')
|
||||
const standaloneInitialDisplayName = params.get('initialDisplayName')
|
||||
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
|
||||
const standaloneInitialContactType = params.get('initialContactType')
|
||||
return (
|
||||
<ChatPage
|
||||
standaloneSessionWindow
|
||||
initialSessionId={sessionId}
|
||||
standaloneSource={standaloneSource}
|
||||
standaloneInitialDisplayName={standaloneInitialDisplayName}
|
||||
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
|
||||
standaloneInitialContactType={standaloneInitialContactType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立通知窗口
|
||||
if (isNotificationWindow) {
|
||||
return <NotificationWindow />
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
const handleCloseSettings = () => {
|
||||
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||
if (backgroundLocation.pathname === '/settings') {
|
||||
navigate('/home', { replace: true })
|
||||
return
|
||||
}
|
||||
navigate(
|
||||
{
|
||||
pathname: backgroundLocation.pathname,
|
||||
search: backgroundLocation.search,
|
||||
hash: backgroundLocation.hash
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
state: backgroundLocation.state
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<div className="window-drag-region" aria-hidden="true" />
|
||||
@@ -375,7 +480,10 @@ function App() {
|
||||
useHello={lockUseHello}
|
||||
/>
|
||||
)}
|
||||
<TitleBar />
|
||||
<TitleBar
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)}
|
||||
/>
|
||||
|
||||
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
||||
<UpdateProgressCapsule />
|
||||
@@ -385,6 +493,7 @@ function App() {
|
||||
|
||||
{/* 全局批量转写进度浮窗 */}
|
||||
<BatchTranscribeGlobal />
|
||||
<BatchImageDecryptGlobal />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
@@ -437,6 +546,42 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据收集同意弹窗 */}
|
||||
{showAnalyticsConsent && !agreementLoading && (
|
||||
<div className="agreement-overlay">
|
||||
<div className="agreement-modal">
|
||||
<div className="agreement-header">
|
||||
<Shield size={32} />
|
||||
<h2>使用数据收集说明</h2>
|
||||
</div>
|
||||
<div className="agreement-content">
|
||||
<div className="agreement-text">
|
||||
<p>为了持续改进 WeFlow 并提供更好的用户体验,我们希望收集一些匿名的使用数据。</p>
|
||||
|
||||
<h4>我们会收集什么?</h4>
|
||||
<p>• 功能使用情况(如哪些功能被使用、使用频率)</p>
|
||||
<p>• 应用性能数据(如加载时间、错误日志)</p>
|
||||
<p>• 设备基本信息(如操作系统版本、应用版本)</p>
|
||||
|
||||
<h4>我们不会收集什么?</h4>
|
||||
<p>• 你的聊天记录内容</p>
|
||||
<p>• 个人身份信息</p>
|
||||
<p>• 联系人信息</p>
|
||||
<p>• 任何可以识别你身份的数据</p>
|
||||
<p>• 一切你担心会涉及隐藏的数据</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
<div className="agreement-actions">
|
||||
<button className="btn btn-secondary" onClick={handleAnalyticsDeny}>不允许</button>
|
||||
<button className="btn btn-primary" onClick={handleAnalyticsAllow}>允许</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 更新提示对话框 */}
|
||||
<UpdateDialog
|
||||
open={showUpdateDialog}
|
||||
@@ -449,24 +594,30 @@ function App() {
|
||||
/>
|
||||
|
||||
<div className="main-layout">
|
||||
<Sidebar />
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<main className="content">
|
||||
<RouteGuard>
|
||||
<Routes>
|
||||
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||
<ExportPage />
|
||||
</div>
|
||||
|
||||
<Routes location={routeLocation}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
|
||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
|
||||
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
|
||||
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
<Route path="/dual-report" element={<DualReportPage />} />
|
||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
@@ -474,6 +625,10 @@ function App() {
|
||||
</RouteGuard>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{isSettingsRoute && (
|
||||
<SettingsPage onClose={handleCloseSettings} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
133
src/components/BatchImageDecryptGlobal.tsx
Normal file
133
src/components/BatchImageDecryptGlobal.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Loader2, X, Image as ImageIcon, Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||
import '../styles/batchTranscribe.scss'
|
||||
|
||||
export const BatchImageDecryptGlobal: React.FC = () => {
|
||||
const {
|
||||
isBatchDecrypting,
|
||||
progress,
|
||||
showToast,
|
||||
showResultToast,
|
||||
result,
|
||||
sessionName,
|
||||
startTime,
|
||||
setShowToast,
|
||||
setShowResultToast
|
||||
} = useBatchImageDecryptStore()
|
||||
|
||||
const voiceToastOccupied = useBatchTranscribeStore(
|
||||
state => state.isBatchTranscribing && state.showToast
|
||||
)
|
||||
|
||||
const [eta, setEta] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBatchDecrypting || !startTime || progress.current === 0) {
|
||||
setEta('')
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
if (elapsed <= 0) return
|
||||
const rate = progress.current / elapsed
|
||||
const remain = progress.total - progress.current
|
||||
if (remain <= 0 || rate <= 0) {
|
||||
setEta('')
|
||||
return
|
||||
}
|
||||
const seconds = Math.ceil((remain / rate) / 1000)
|
||||
if (seconds < 60) {
|
||||
setEta(`${seconds}秒`)
|
||||
} else {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
setEta(`${m}分${s}秒`)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [isBatchDecrypting, progress.current, progress.total, startTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showResultToast) return
|
||||
const timer = window.setTimeout(() => setShowResultToast(false), 6000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [showResultToast, setShowResultToast])
|
||||
|
||||
const toastBottom = useMemo(() => (voiceToastOccupied ? 148 : 24), [voiceToastOccupied])
|
||||
|
||||
return (
|
||||
<>
|
||||
{showToast && isBatchDecrypting && createPortal(
|
||||
<div className="batch-progress-toast" style={{ bottom: toastBottom }}>
|
||||
<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
|
||||
)}
|
||||
|
||||
{showResultToast && createPortal(
|
||||
<div className="batch-progress-toast batch-inline-result-toast" style={{ bottom: toastBottom }}>
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<ImageIcon size={14} />
|
||||
<span>图片批量解密完成</span>
|
||||
</div>
|
||||
<button className="batch-progress-toast-close" onClick={() => setShowResultToast(false)} title="关闭">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="batch-progress-toast-body">
|
||||
<div className="batch-inline-result-summary">
|
||||
<div className="batch-inline-result-item success">
|
||||
<CheckCircle size={14} />
|
||||
<span>成功 {result.success}</span>
|
||||
</div>
|
||||
<div className={`batch-inline-result-item ${result.fail > 0 ? 'fail' : 'muted'}`}>
|
||||
<XCircle size={14} />
|
||||
<span>失败 {result.fail}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
136
src/components/ChatAnalysisHeader.scss
Normal file
136
src/components/ChatAnalysisHeader.scss
Normal file
@@ -0,0 +1,136 @@
|
||||
.chat-analysis-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 28px;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-analysis-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-analysis-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.chat-analysis-breadcrumb-separator {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-analysis-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-analysis-current-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
.current {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.open svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-analysis-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
min-width: 120px;
|
||||
padding: 6px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.chat-analysis-menu-item {
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 9px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-analysis-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-analysis-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-analysis-breadcrumb {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.chat-analysis-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
105
src/components/ChatAnalysisHeader.tsx
Normal file
105
src/components/ChatAnalysisHeader.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { ChevronDown, ChevronLeft } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import './ChatAnalysisHeader.scss'
|
||||
|
||||
export type ChatAnalysisMode = 'private' | 'group'
|
||||
|
||||
interface ChatAnalysisHeaderProps {
|
||||
currentMode: ChatAnalysisMode
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
const MODE_CONFIG: Record<ChatAnalysisMode, { label: string; path: string }> = {
|
||||
private: {
|
||||
label: '私聊分析',
|
||||
path: '/analytics/private'
|
||||
},
|
||||
group: {
|
||||
label: '群聊分析',
|
||||
path: '/analytics/group'
|
||||
}
|
||||
}
|
||||
|
||||
function ChatAnalysisHeader({ currentMode, actions }: ChatAnalysisHeaderProps) {
|
||||
const navigate = useNavigate()
|
||||
const currentLabel = MODE_CONFIG[currentMode].label
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null)
|
||||
const alternateMode = useMemo(
|
||||
() => (currentMode === 'private' ? 'group' : 'private'),
|
||||
[currentMode]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!dropdownRef.current?.contains(event.target as Node)) {
|
||||
setMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [menuOpen])
|
||||
|
||||
return (
|
||||
<div className="chat-analysis-header">
|
||||
<div className="chat-analysis-breadcrumb">
|
||||
<button
|
||||
type="button"
|
||||
className="chat-analysis-back"
|
||||
onClick={() => navigate('/analytics')}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
<span>聊天分析</span>
|
||||
</button>
|
||||
<span className="chat-analysis-breadcrumb-separator">/</span>
|
||||
<div className="chat-analysis-dropdown" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-analysis-current-trigger ${menuOpen ? 'open' : ''}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="current">{currentLabel}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="chat-analysis-menu" role="menu" aria-label="切换聊天分析类型">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="chat-analysis-menu-item"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
navigate(MODE_CONFIG[alternateMode].path)
|
||||
}}
|
||||
>
|
||||
{MODE_CONFIG[alternateMode].label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actions ? <div className="chat-analysis-actions">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatAnalysisHeader
|
||||
41
src/components/ErrorBoundary.tsx
Normal file
41
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Component, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: any) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback || (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||
<p>消息渲染出错</p>
|
||||
<p style={{ fontSize: '12px', marginTop: '8px' }}>
|
||||
{this.state.error?.message || '未知错误'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
254
src/components/Export/ExportDateRangeDialog.scss
Normal file
254
src/components/Export/ExportDateRangeDialog.scss
Normal file
@@ -0,0 +1,254 @@
|
||||
.export-date-range-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 2400;
|
||||
}
|
||||
|
||||
.export-date-range-dialog {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow-y: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.export-date-range-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-dialog-close-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.export-date-range-preset-list {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-preset-item {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
min-height: 30px;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-mode-banner {
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.range {
|
||||
border-color: rgba(var(--primary-rgb), 0.4);
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-date-range-calendar-panel {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.export-date-range-calendar-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-date-range-calendar-date-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-date-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
height: 24px;
|
||||
padding: 0 7px;
|
||||
font-size: 11px;
|
||||
|
||||
&.invalid {
|
||||
border-color: #e84d4d;
|
||||
box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-calendar-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
|
||||
button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-calendar-weekdays {
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
|
||||
span {
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-calendar-days {
|
||||
margin-top: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.export-date-range-calendar-day {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
min-height: 20px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&.outside {
|
||||
color: var(--text-quaternary);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.14);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-date-range-dialog-btn {
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&.primary {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.export-date-range-calendar-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
340
src/components/Export/ExportDateRangeDialog.tsx
Normal file
340
src/components/Export/ExportDateRangeDialog.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, X } from 'lucide-react'
|
||||
import {
|
||||
EXPORT_DATE_RANGE_PRESETS,
|
||||
WEEKDAY_SHORT_LABELS,
|
||||
addMonths,
|
||||
buildCalendarCells,
|
||||
cloneExportDateRangeSelection,
|
||||
createDateRangeByPreset,
|
||||
createDefaultDateRange,
|
||||
formatCalendarMonthTitle,
|
||||
formatDateInputValue,
|
||||
isSameDay,
|
||||
parseDateInputValue,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
toMonthStart,
|
||||
type ExportDateRangePreset,
|
||||
type ExportDateRangeSelection
|
||||
} from '../../utils/exportDateRange'
|
||||
import './ExportDateRangeDialog.scss'
|
||||
|
||||
interface ExportDateRangeDialogProps {
|
||||
open: boolean
|
||||
value: ExportDateRangeSelection
|
||||
title?: string
|
||||
onClose: () => void
|
||||
onConfirm: (value: ExportDateRangeSelection) => void
|
||||
}
|
||||
|
||||
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
|
||||
startPanelMonth: Date
|
||||
endPanelMonth: Date
|
||||
}
|
||||
|
||||
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({
|
||||
...cloneExportDateRangeSelection(value),
|
||||
startPanelMonth: toMonthStart(value.dateRange.start),
|
||||
endPanelMonth: toMonthStart(value.dateRange.end)
|
||||
})
|
||||
|
||||
export function ExportDateRangeDialog({
|
||||
open,
|
||||
value,
|
||||
title = '时间范围设置',
|
||||
onClose,
|
||||
onConfirm
|
||||
}: ExportDateRangeDialogProps) {
|
||||
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value))
|
||||
const [dateInput, setDateInput] = useState({
|
||||
start: formatDateInputValue(value.dateRange.start),
|
||||
end: formatDateInputValue(value.dateRange.end)
|
||||
})
|
||||
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const nextDraft = buildDialogDraft(value)
|
||||
setDraft(nextDraft)
|
||||
setDateInput({
|
||||
start: formatDateInputValue(nextDraft.dateRange.start),
|
||||
end: formatDateInputValue(nextDraft.dateRange.end)
|
||||
})
|
||||
setDateInputError({ start: false, end: false })
|
||||
}, [open, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setDateInput({
|
||||
start: formatDateInputValue(draft.dateRange.start),
|
||||
end: formatDateInputValue(draft.dateRange.end)
|
||||
})
|
||||
setDateInputError({ start: false, end: false })
|
||||
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
||||
|
||||
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
|
||||
if (preset === 'all') {
|
||||
const previewRange = createDefaultDateRange()
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset,
|
||||
useAllTime: true,
|
||||
dateRange: previewRange,
|
||||
startPanelMonth: toMonthStart(previewRange.start),
|
||||
endPanelMonth: toMonthStart(previewRange.end)
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const range = createDateRangeByPreset(preset)
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset,
|
||||
useAllTime: false,
|
||||
dateRange: range,
|
||||
startPanelMonth: toMonthStart(range.start),
|
||||
endPanelMonth: toMonthStart(range.end)
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const updateDraftStart = useCallback((targetDate: Date) => {
|
||||
const start = startOfDay(targetDate)
|
||||
setDraft(prev => {
|
||||
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start,
|
||||
end: nextEnd
|
||||
},
|
||||
startPanelMonth: toMonthStart(start),
|
||||
endPanelMonth: toMonthStart(nextEnd)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateDraftEnd = useCallback((targetDate: Date) => {
|
||||
const end = endOfDay(targetDate)
|
||||
setDraft(prev => {
|
||||
const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
||||
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
},
|
||||
startPanelMonth: toMonthStart(nextStart),
|
||||
endPanelMonth: toMonthStart(nextEnd)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const commitStartFromInput = useCallback(() => {
|
||||
const parsed = parseDateInputValue(dateInput.start)
|
||||
if (!parsed) {
|
||||
setDateInputError(prev => ({ ...prev, start: true }))
|
||||
return
|
||||
}
|
||||
setDateInputError(prev => ({ ...prev, start: false }))
|
||||
updateDraftStart(parsed)
|
||||
}, [dateInput.start, updateDraftStart])
|
||||
|
||||
const commitEndFromInput = useCallback(() => {
|
||||
const parsed = parseDateInputValue(dateInput.end)
|
||||
if (!parsed) {
|
||||
setDateInputError(prev => ({ ...prev, end: true }))
|
||||
return
|
||||
}
|
||||
setDateInputError(prev => ({ ...prev, end: false }))
|
||||
updateDraftEnd(parsed)
|
||||
}, [dateInput.end, updateDraftEnd])
|
||||
|
||||
const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
|
||||
setDraft(prev => (
|
||||
panel === 'start'
|
||||
? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) }
|
||||
: { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) }
|
||||
))
|
||||
}, [])
|
||||
|
||||
const isRangeModeActive = !draft.useAllTime
|
||||
const modeText = isRangeModeActive
|
||||
? '当前导出模式:按时间范围导出'
|
||||
: '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)'
|
||||
|
||||
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
|
||||
if (preset === 'all') return draft.useAllTime
|
||||
return !draft.useAllTime && draft.preset === preset
|
||||
}, [draft])
|
||||
|
||||
const startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth])
|
||||
const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="export-date-range-dialog-overlay" onClick={onClose}>
|
||||
<div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="export-date-range-dialog-header">
|
||||
<h4>{title}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="export-date-range-dialog-close-btn"
|
||||
onClick={onClose}
|
||||
aria-label="关闭时间范围设置"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="export-date-range-preset-list">
|
||||
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
|
||||
const active = isPresetActive(preset.value)
|
||||
return (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
className={`export-date-range-preset-item ${active ? 'active' : ''}`}
|
||||
onClick={() => applyPreset(preset.value)}
|
||||
>
|
||||
<span>{preset.label}</span>
|
||||
{active && <Check size={14} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={`export-date-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
|
||||
{modeText}
|
||||
</div>
|
||||
|
||||
<div className="export-date-range-calendar-grid">
|
||||
<section className="export-date-range-calendar-panel">
|
||||
<div className="export-date-range-calendar-panel-header">
|
||||
<div className="export-date-range-calendar-date-label">
|
||||
<span>起始日期</span>
|
||||
<input
|
||||
type="text"
|
||||
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
|
||||
value={dateInput.start}
|
||||
placeholder="YYYY-MM-DD"
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
setDateInput(prev => ({ ...prev, start: nextValue }))
|
||||
if (dateInputError.start) {
|
||||
setDateInputError(prev => ({ ...prev, start: false }))
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
commitStartFromInput()
|
||||
}}
|
||||
onBlur={commitStartFromInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="export-date-range-calendar-nav">
|
||||
<button type="button" onClick={() => shiftPanelMonth('start', -1)} aria-label="上个月">‹</button>
|
||||
<span>{formatCalendarMonthTitle(draft.startPanelMonth)}</span>
|
||||
<button type="button" onClick={() => shiftPanelMonth('start', 1)} aria-label="下个月">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="export-date-range-calendar-weekdays">
|
||||
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||
<span key={`start-weekday-${label}`}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="export-date-range-calendar-days">
|
||||
{startPanelCells.map((cell) => {
|
||||
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start)
|
||||
return (
|
||||
<button
|
||||
key={`start-${cell.date.getTime()}`}
|
||||
type="button"
|
||||
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
|
||||
onClick={() => updateDraftStart(cell.date)}
|
||||
>
|
||||
{cell.date.getDate()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="export-date-range-calendar-panel">
|
||||
<div className="export-date-range-calendar-panel-header">
|
||||
<div className="export-date-range-calendar-date-label">
|
||||
<span>截止日期</span>
|
||||
<input
|
||||
type="text"
|
||||
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
|
||||
value={dateInput.end}
|
||||
placeholder="YYYY-MM-DD"
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
setDateInput(prev => ({ ...prev, end: nextValue }))
|
||||
if (dateInputError.end) {
|
||||
setDateInputError(prev => ({ ...prev, end: false }))
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
commitEndFromInput()
|
||||
}}
|
||||
onBlur={commitEndFromInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="export-date-range-calendar-nav">
|
||||
<button type="button" onClick={() => shiftPanelMonth('end', -1)} aria-label="上个月">‹</button>
|
||||
<span>{formatCalendarMonthTitle(draft.endPanelMonth)}</span>
|
||||
<button type="button" onClick={() => shiftPanelMonth('end', 1)} aria-label="下个月">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="export-date-range-calendar-weekdays">
|
||||
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||
<span key={`end-weekday-${label}`}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="export-date-range-calendar-days">
|
||||
{endPanelCells.map((cell) => {
|
||||
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end)
|
||||
return (
|
||||
<button
|
||||
key={`end-${cell.date.getTime()}`}
|
||||
type="button"
|
||||
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
|
||||
onClick={() => updateDraftEnd(cell.date)}
|
||||
>
|
||||
{cell.date.getDate()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="export-date-range-dialog-actions">
|
||||
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="export-date-range-dialog-btn primary"
|
||||
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
459
src/components/Export/ExportDefaultsSettingsForm.scss
Normal file
459
src/components/Export/ExportDefaultsSettingsForm.scss
Normal file
@@ -0,0 +1,459 @@
|
||||
.export-defaults-settings-form {
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.select-field {
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9999px;
|
||||
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: 120;
|
||||
max-height: 320px;
|
||||
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-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.format-card {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.format-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.format-desc {
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.select-option.active .option-desc {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.settings-time-range-field {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-time-range-trigger {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9999px;
|
||||
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: rgba(var(--primary-rgb), 0.45);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-time-range-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.settings-time-range-arrow {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.log-toggle-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.media-default-grid {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-bottom: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
margin: 0;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.log-status {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.concurrency-inline-options {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.concurrency-option {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
min-height: 38px;
|
||||
padding: 0;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch-input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
|
||||
&:checked + .switch-slider {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&:checked + .switch-slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
&:focus + .switch-slider {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.switch-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&.layout-split {
|
||||
.form-group {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.form-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-bottom: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.select-field,
|
||||
.settings-time-range-field {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.log-toggle-line {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.media-default-grid {
|
||||
max-width: 360px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.concurrency-inline-options {
|
||||
max-width: 360px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.format-setting-group {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.format-setting-group .form-control {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
max-width: none;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.export-defaults-settings-form.layout-split {
|
||||
.media-setting-group {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.media-setting-group .form-control {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.media-default-grid {
|
||||
max-width: none;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.export-defaults-settings-form.layout-split {
|
||||
.form-group {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.select-field,
|
||||
.settings-time-range-field,
|
||||
.log-toggle-line,
|
||||
.media-default-grid,
|
||||
.concurrency-inline-options,
|
||||
.format-grid {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.media-default-grid {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
389
src/components/Export/ExportDefaultsSettingsForm.tsx
Normal file
389
src/components/Export/ExportDefaultsSettingsForm.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import * as configService from '../../services/config'
|
||||
import { ExportDateRangeDialog } from './ExportDateRangeDialog'
|
||||
import {
|
||||
createDefaultExportDateRangeSelection,
|
||||
getExportDateRangeLabel,
|
||||
resolveExportDateRangeConfig,
|
||||
serializeExportDateRangeConfig,
|
||||
type ExportDateRangeSelection
|
||||
} from '../../utils/exportDateRange'
|
||||
import './ExportDefaultsSettingsForm.scss'
|
||||
|
||||
export interface ExportDefaultsSettingsPatch {
|
||||
format?: string
|
||||
avatars?: boolean
|
||||
dateRange?: ExportDateRangeSelection
|
||||
media?: configService.ExportDefaultMediaConfig
|
||||
voiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
concurrency?: number
|
||||
}
|
||||
|
||||
interface ExportDefaultsSettingsFormProps {
|
||||
onNotify?: (text: string, success: boolean) => void
|
||||
onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void
|
||||
layout?: 'stacked' | 'split'
|
||||
}
|
||||
|
||||
const exportFormatOptions = [
|
||||
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
|
||||
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
|
||||
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
|
||||
] as const
|
||||
|
||||
const exportExcelColumnOptions = [
|
||||
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
|
||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||
] as const
|
||||
|
||||
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
|
||||
|
||||
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
export function ExportDefaultsSettingsForm({
|
||||
onNotify,
|
||||
onDefaultsChanged,
|
||||
layout = 'stacked'
|
||||
}: ExportDefaultsSettingsFormProps) {
|
||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
})
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultAvatars(),
|
||||
configService.getExportDefaultDateRange(),
|
||||
configService.getExportDefaultMedia(),
|
||||
configService.getExportDefaultVoiceAsText(),
|
||||
configService.getExportDefaultExcelCompactColumns(),
|
||||
configService.getExportDefaultConcurrency()
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
setExportDefaultFormat(savedFormat || 'excel')
|
||||
setExportDefaultAvatars(savedAvatars ?? true)
|
||||
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
|
||||
setExportDefaultMedia(savedMedia ?? {
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
})
|
||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showExportExcelColumnsSelect])
|
||||
|
||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
||||
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
||||
|
||||
const notify = (text: string, success = true) => {
|
||||
onNotify?.(text, success)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`export-defaults-settings-form ${layout === 'split' ? 'layout-split' : 'layout-stacked'}`}>
|
||||
<div className="form-group">
|
||||
<div className="form-copy">
|
||||
<label>导出并发数</label>
|
||||
<span className="form-hint">导出多个会话时的最大并发(1~6)</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="concurrency-inline-options" role="radiogroup" aria-label="导出并发数">
|
||||
{exportConcurrencyOptions.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={`concurrency-option ${exportDefaultConcurrency === option ? 'active' : ''}`}
|
||||
aria-pressed={exportDefaultConcurrency === option}
|
||||
onClick={async () => {
|
||||
setExportDefaultConcurrency(option)
|
||||
await configService.setExportDefaultConcurrency(option)
|
||||
onDefaultsChanged?.({ concurrency: option })
|
||||
notify(`已将导出并发数设为 ${option}`, true)
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group format-setting-group">
|
||||
<div className="form-copy">
|
||||
<label>聊天消息默认导出格式</label>
|
||||
<span className="form-hint">导出页面默认选中的格式</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="format-grid">
|
||||
{exportFormatOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`format-card ${exportDefaultFormat === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultFormat(option.value)
|
||||
await configService.setExportDefaultFormat(option.value)
|
||||
onDefaultsChanged?.({ format: option.value })
|
||||
notify('已更新导出格式默认值', true)
|
||||
}}
|
||||
>
|
||||
<span className="format-label">{option.label}</span>
|
||||
<span className="format-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="form-copy">
|
||||
<label>聊天消息导出带头像</label>
|
||||
<span className="form-hint">开启后导出的聊天消息对应的文件中会带头像信息。</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{exportDefaultAvatars ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="shared-export-default-avatars">
|
||||
<input
|
||||
id="shared-export-default-avatars"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={exportDefaultAvatars}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setExportDefaultAvatars(enabled)
|
||||
await configService.setExportDefaultAvatars(enabled)
|
||||
onDefaultsChanged?.({ avatars: enabled })
|
||||
notify(enabled ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="form-copy">
|
||||
<label>默认导出时间范围</label>
|
||||
<span className="form-hint">控制导出页面的默认时间选择</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="settings-time-range-field">
|
||||
<button
|
||||
type="button"
|
||||
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
setIsExportDateRangeDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span className="settings-time-range-value">{exportDateRangeLabel}</span>
|
||||
<span className="settings-time-range-arrow">></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExportDateRangeDialog
|
||||
open={isExportDateRangeDialogOpen}
|
||||
value={exportDefaultDateRange}
|
||||
onClose={() => setIsExportDateRangeDialogOpen(false)}
|
||||
onConfirm={async (nextSelection) => {
|
||||
setExportDefaultDateRange(nextSelection)
|
||||
await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection))
|
||||
onDefaultsChanged?.({ dateRange: nextSelection })
|
||||
notify('已更新默认导出时间范围', true)
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="form-copy">
|
||||
<label>Excel 列显示</label>
|
||||
<span className="form-hint">控制 Excel 导出的列字段</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportExcelColumnsLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportExcelColumnsSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportExcelColumnOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
const compact = option.value === 'compact'
|
||||
setExportDefaultExcelCompactColumns(compact)
|
||||
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||
onDefaultsChanged?.({ excelCompactColumns: compact })
|
||||
notify(compact ? '已启用精简列' : '已启用完整列', true)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group media-setting-group">
|
||||
<div className="form-copy">
|
||||
<label>默认导出媒体内容</label>
|
||||
<span className="form-hint">控制图片、视频、语音、表情包的默认导出开关</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="media-default-grid">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportDefaultMedia.images}
|
||||
onChange={async (e) => {
|
||||
const next = { ...exportDefaultMedia, images: e.target.checked }
|
||||
setExportDefaultMedia(next)
|
||||
await configService.setExportDefaultMedia(next)
|
||||
onDefaultsChanged?.({ media: next })
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出图片`, true)
|
||||
}}
|
||||
/>
|
||||
图片
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportDefaultMedia.voices}
|
||||
onChange={async (e) => {
|
||||
const next = { ...exportDefaultMedia, voices: e.target.checked }
|
||||
setExportDefaultMedia(next)
|
||||
await configService.setExportDefaultMedia(next)
|
||||
onDefaultsChanged?.({ media: next })
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出语音`, true)
|
||||
}}
|
||||
/>
|
||||
语音
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportDefaultMedia.videos}
|
||||
onChange={async (e) => {
|
||||
const next = { ...exportDefaultMedia, videos: e.target.checked }
|
||||
setExportDefaultMedia(next)
|
||||
await configService.setExportDefaultMedia(next)
|
||||
onDefaultsChanged?.({ media: next })
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出视频`, true)
|
||||
}}
|
||||
/>
|
||||
视频
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportDefaultMedia.emojis}
|
||||
onChange={async (e) => {
|
||||
const next = { ...exportDefaultMedia, emojis: e.target.checked }
|
||||
setExportDefaultMedia(next)
|
||||
await configService.setExportDefaultMedia(next)
|
||||
onDefaultsChanged?.({ media: next })
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出表情包`, true)
|
||||
}}
|
||||
/>
|
||||
表情包
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="form-copy">
|
||||
<label>默认语音转文字</label>
|
||||
<span className="form-hint">导出时默认将语音转写为文字</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="shared-export-default-voice-as-text">
|
||||
<input
|
||||
id="shared-export-default-voice-as-text"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={exportDefaultVoiceAsText}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setExportDefaultVoiceAsText(enabled)
|
||||
await configService.setExportDefaultVoiceAsText(enabled)
|
||||
onDefaultsChanged?.({ voiceAsText: enabled })
|
||||
notify(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -46,7 +46,6 @@ export function GlobalSessionMonitor() {
|
||||
return () => {
|
||||
removeListener()
|
||||
}
|
||||
} else {
|
||||
}
|
||||
return () => { }
|
||||
}, [])
|
||||
@@ -97,6 +96,10 @@ export function GlobalSessionMonitor() {
|
||||
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
||||
// 这是新消息事件
|
||||
|
||||
// 免打扰、折叠群、折叠入口不弹通知
|
||||
if (newSession.isMuted || newSession.isFolded) continue
|
||||
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||
|
||||
// 1. 群聊过滤自己发送的消息
|
||||
if (newSession.username.includes('@chatroom')) {
|
||||
// 如果是自己发的消息,不弹通知
|
||||
@@ -194,11 +197,12 @@ export function GlobalSessionMonitor() {
|
||||
// 尝试丰富或获取联系人详情
|
||||
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
||||
if (contact) {
|
||||
if (contact.remark || contact.nickname) {
|
||||
title = contact.remark || contact.nickname
|
||||
if (contact.remark || contact.nickName) {
|
||||
title = contact.remark || contact.nickName
|
||||
}
|
||||
if (contact.avatarUrl) {
|
||||
avatarUrl = contact.avatarUrl
|
||||
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||
if (avatarResult?.avatarUrl) {
|
||||
avatarUrl = avatarResult.avatarUrl
|
||||
}
|
||||
} else {
|
||||
// 如果不在缓存/数据库中
|
||||
@@ -218,8 +222,11 @@ export function GlobalSessionMonitor() {
|
||||
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
|
||||
title = retried.remark || retried.nickName || title
|
||||
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||
if (retriedAvatar?.avatarUrl) {
|
||||
avatarUrl = retriedAvatar.avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,7 +260,8 @@ export function GlobalSessionMonitor() {
|
||||
const handleActiveSessionRefresh = async (sessionId: string) => {
|
||||
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
||||
const state = useChatStore.getState()
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
const msgs = state.messages || []
|
||||
const lastMsg = msgs[msgs.length - 1]
|
||||
const minTime = lastMsg?.createTime || 0
|
||||
|
||||
try {
|
||||
|
||||
166
src/components/JumpToDatePopover.scss
Normal file
166
src/components/JumpToDatePopover.scss
Normal file
@@ -0,0 +1,166 @@
|
||||
.jump-date-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: 312px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: none;
|
||||
background-color: var(--bg-secondary-solid, #ffffff) !important;
|
||||
opacity: 1;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
mix-blend-mode: normal;
|
||||
isolation: isolate;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
padding: 12px;
|
||||
z-index: 1600;
|
||||
}
|
||||
|
||||
.jump-date-popover .calendar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.jump-date-popover .current-month {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.jump-date-popover .nav-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
background-color: var(--bg-secondary-solid, #ffffff) !important;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.jump-date-popover .nav-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.jump-date-popover .status-line {
|
||||
min-height: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.jump-date-popover .status-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.jump-date-popover .calendar-grid .weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.jump-date-popover .calendar-grid .weekday {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.jump-date-popover .calendar-grid .days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: repeat(6, 36px);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell {
|
||||
position: relative;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell .day-number {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.empty {
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell:not(.empty):not(.no-message):hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.today {
|
||||
border-color: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.selected {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.no-message {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-count {
|
||||
position: static;
|
||||
margin-top: 1px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: #16a34a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.selected .day-count {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-count-loading {
|
||||
position: static;
|
||||
margin-top: 1px;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.jump-date-popover .spin {
|
||||
animation: jump-date-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes jump-date-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
191
src/components/JumpToDatePopover.tsx
Normal file
191
src/components/JumpToDatePopover.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import './JumpToDatePopover.scss'
|
||||
|
||||
interface JumpToDatePopoverProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSelect: (date: Date) => void
|
||||
onMonthChange?: (date: Date) => void
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
currentDate?: Date
|
||||
messageDates?: Set<string>
|
||||
hasLoadedMessageDates?: boolean
|
||||
messageDateCounts?: Record<string, number>
|
||||
loadingDates?: boolean
|
||||
loadingDateCounts?: boolean
|
||||
}
|
||||
|
||||
const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
onMonthChange,
|
||||
className,
|
||||
style,
|
||||
currentDate = new Date(),
|
||||
messageDates,
|
||||
hasLoadedMessageDates = false,
|
||||
messageDateCounts,
|
||||
loadingDates = false,
|
||||
loadingDateCounts = false
|
||||
}) => {
|
||||
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const normalized = new Date(currentDate)
|
||||
setCalendarDate(normalized)
|
||||
setSelectedDate(normalized)
|
||||
}, [isOpen, currentDate])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const getDaysInMonth = (date: Date): number => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
const getFirstDayOfMonth = (date: Date): number => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
return new Date(year, month, 1).getDay()
|
||||
}
|
||||
|
||||
const toDateKey = (day: number): string => {
|
||||
const year = calendarDate.getFullYear()
|
||||
const month = calendarDate.getMonth() + 1
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const hasMessage = (day: number): boolean => {
|
||||
if (!hasLoadedMessageDates) return true
|
||||
if (!messageDates || messageDates.size === 0) return false
|
||||
return messageDates.has(toDateKey(day))
|
||||
}
|
||||
|
||||
const isToday = (day: number): boolean => {
|
||||
const today = new Date()
|
||||
return day === today.getDate()
|
||||
&& calendarDate.getMonth() === today.getMonth()
|
||||
&& calendarDate.getFullYear() === today.getFullYear()
|
||||
}
|
||||
|
||||
const isSelected = (day: number): boolean => {
|
||||
return day === selectedDate.getDate()
|
||||
&& calendarDate.getMonth() === selectedDate.getMonth()
|
||||
&& calendarDate.getFullYear() === selectedDate.getFullYear()
|
||||
}
|
||||
|
||||
const generateCalendar = (): Array<number | null> => {
|
||||
const daysInMonth = getDaysInMonth(calendarDate)
|
||||
const firstDay = getFirstDayOfMonth(calendarDate)
|
||||
const days: Array<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 handleDateClick = (day: number) => {
|
||||
if (hasLoadedMessageDates && !hasMessage(day)) return
|
||||
const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||
setSelectedDate(targetDate)
|
||||
onSelect(targetDate)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const getDayClassName = (day: number | null): string => {
|
||||
if (day === null) return 'day-cell empty'
|
||||
const classes = ['day-cell']
|
||||
if (isToday(day)) classes.push('today')
|
||||
if (isSelected(day)) classes.push('selected')
|
||||
if (hasLoadedMessageDates && !hasMessage(day)) classes.push('no-message')
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
||||
const updateCalendarDate = (nextDate: Date) => {
|
||||
setCalendarDate(nextDate)
|
||||
onMonthChange?.(nextDate)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
aria-label="上一月"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
aria-label="下一月"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="status-line">
|
||||
{loadingDates && (
|
||||
<span className="status-item">
|
||||
<Loader2 size={12} className="spin" />
|
||||
<span>日期加载中</span>
|
||||
</span>
|
||||
)}
|
||||
{!loadingDates && loadingDateCounts && (
|
||||
<span className="status-item">
|
||||
<Loader2 size={12} className="spin" />
|
||||
<span>条数加载中</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="calendar-grid">
|
||||
<div className="weekdays">
|
||||
{weekdays.map(day => (
|
||||
<div key={day} className="weekday">{day}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="days">
|
||||
{days.map((day, index) => {
|
||||
if (day === null) return <div key={index} className="day-cell empty" />
|
||||
const dateKey = toDateKey(day)
|
||||
const hasMessageOnDay = hasMessage(day)
|
||||
const count = Number(messageDateCounts?.[dateKey] || 0)
|
||||
const showCount = count > 0
|
||||
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={getDayClassName(day)}
|
||||
onClick={() => handleDateClick(day)}
|
||||
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
||||
type="button"
|
||||
>
|
||||
<span className="day-number">{day}</span>
|
||||
{showCount && <span className="day-count">{count}</span>}
|
||||
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default JumpToDatePopover
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import * as configService from '../services/config'
|
||||
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
||||
import './LockScreen.scss'
|
||||
|
||||
@@ -9,14 +8,6 @@ interface LockScreenProps {
|
||||
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('')
|
||||
@@ -49,19 +40,9 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
|
||||
const quickStartHello = async () => {
|
||||
try {
|
||||
// 如果父组件已经告诉我们要用 Hello,直接开始,不等待 IPC
|
||||
let shouldUseHello = useHello
|
||||
|
||||
// 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config
|
||||
if (!shouldUseHello) {
|
||||
shouldUseHello = await configService.getAuthUseHello()
|
||||
}
|
||||
|
||||
if (shouldUseHello) {
|
||||
// 标记为可用,显示按钮
|
||||
if (useHello) {
|
||||
setHelloAvailable(true)
|
||||
setShowHello(true)
|
||||
// 立即执行验证 (0延迟)
|
||||
verifyHello()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -96,25 +77,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
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)
|
||||
// 发送原始密码到主进程,由主进程验证并解密密钥
|
||||
const result = await window.electronAPI.auth.unlock(password)
|
||||
|
||||
if (inputHash === storedHash) {
|
||||
if (result.success) {
|
||||
handleUnlock()
|
||||
} else {
|
||||
setError('密码错误')
|
||||
setError(result.error || '密码错误')
|
||||
setPassword('')
|
||||
setIsVerifying(false)
|
||||
// 如果密码错误,是否重新触发 Hello?
|
||||
// 用户可能想重试密码,暂时不自动触发
|
||||
}
|
||||
} catch (e) {
|
||||
setError('验证失败')
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-light);
|
||||
|
||||
// 浅色模式下使用不透明背景,避免透明窗口中通知过于透明
|
||||
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
|
||||
[data-mode="light"] &,
|
||||
:not([data-mode]) & {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
border-radius: 12px;
|
||||
@@ -46,12 +48,26 @@
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
|
||||
// 确保背景不透明
|
||||
background: var(--bg-secondary, #2c2c2c);
|
||||
color: var(--text-primary, #ffffff);
|
||||
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
|
||||
background: #ffffff;
|
||||
color: #3d3d3d;
|
||||
--text-primary: #3d3d3d;
|
||||
--text-secondary: #666666;
|
||||
--text-tertiary: #999999;
|
||||
--border-light: rgba(0, 0, 0, 0.08);
|
||||
|
||||
// 深色模式覆盖
|
||||
[data-mode="dark"] & {
|
||||
background: var(--bg-secondary-solid, #282420);
|
||||
color: var(--text-primary, #F0EEE9);
|
||||
--text-primary: #F0EEE9;
|
||||
--text-secondary: #b3b0aa;
|
||||
--text-tertiary: #807d78;
|
||||
--border-light: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
box-shadow: none !important; // NO SHADOW
|
||||
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
||||
border: 1px solid var(--border-light);
|
||||
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
|
||||
@@ -10,6 +10,19 @@
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
|
||||
.sidebar-user-card-wrap {
|
||||
margin: 0 8px 8px;
|
||||
}
|
||||
|
||||
.sidebar-user-card {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
|
||||
.user-meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu,
|
||||
.sidebar-footer {
|
||||
padding: 0 8px;
|
||||
@@ -27,6 +40,150 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-user-card-wrap {
|
||||
position: relative;
|
||||
margin: 0 12px 10px;
|
||||
--sidebar-user-menu-width: 172px;
|
||||
}
|
||||
|
||||
.sidebar-user-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: auto;
|
||||
bottom: calc(100% + 8px);
|
||||
width: max(100%, var(--sidebar-user-menu-width));
|
||||
z-index: 12;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.95);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-user-menu-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 9px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #d93025;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-user-card {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 56px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(99, 102, 241, 0.32);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.menu-open {
|
||||
border-color: rgba(99, 102, 241, 0.44);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--on-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-wxid {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-menu-caret {
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
transition: transform 0.2s ease, color 0.2s ease;
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -57,7 +214,7 @@
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
color: var(--on-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +227,44 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-icon-with-badge {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
background: #ff3b30;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.18);
|
||||
}
|
||||
|
||||
.nav-badge.icon-badge {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -10px;
|
||||
margin-left: 0;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
box-shadow: 0 0 0 2px var(--bg-secondary);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
@@ -85,22 +275,286 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
.sidebar-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 4px;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-dialog {
|
||||
width: min(420px, 100%);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||
padding: 18px 18px 16px;
|
||||
animation: slideUp 0.25s ease;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-wxid-list {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-wxid-item {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(99, 102, 241, 0.32);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.current {
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wxid-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--on-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wxid-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wxid-id {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.current-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-dialog-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog {
|
||||
width: min(460px, 100%);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||
padding: 18px 18px 16px;
|
||||
animation: slideUp 0.25s ease;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-options {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: #ef4444;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
|
||||
[data-theme="blossom-dream"] .sidebar {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="blossom-dream"][data-mode="dark"] .sidebar {
|
||||
background: rgba(34, 30, 36, 0.75);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
// 激活项:主品牌色纵向微渐变
|
||||
[data-theme="blossom-dream"] .nav-item.active {
|
||||
background: linear-gradient(180deg, #D4849A 0%, #C4748A 100%);
|
||||
}
|
||||
|
||||
// 深色激活项:用藕粉色,背景深灰底 + 粉色文字/图标(高阶玩法)
|
||||
[data-theme="blossom-dream"][data-mode="dark"] .nav-item.active {
|
||||
background: rgba(209, 158, 187, 0.15);
|
||||
color: #D19EBB;
|
||||
border: 1px solid rgba(209, 158, 187, 0.2);
|
||||
}
|
||||
|
||||
@@ -1,142 +1,575 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import * as configService from '../services/config'
|
||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||
|
||||
import './Sidebar.scss'
|
||||
|
||||
function Sidebar() {
|
||||
interface SidebarUserProfile {
|
||||
wxid: string
|
||||
displayName: string
|
||||
alias?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
||||
|
||||
interface SidebarUserProfileCache extends SidebarUserProfile {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface AccountProfilesCache {
|
||||
[wxid: string]: {
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
alias?: string
|
||||
updatedAt: number
|
||||
}
|
||||
}
|
||||
|
||||
interface WxidOption {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as SidebarUserProfileCache
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
if (!parsed.wxid || !parsed.displayName) return null
|
||||
return {
|
||||
wxid: parsed.wxid,
|
||||
displayName: parsed.displayName,
|
||||
alias: parsed.alias,
|
||||
avatarUrl: parsed.avatarUrl
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
|
||||
if (!profile.wxid || !profile.displayName) return
|
||||
try {
|
||||
const payload: SidebarUserProfileCache = {
|
||||
...profile,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
|
||||
|
||||
// 同时写入账号缓存池
|
||||
const accountsCache = readAccountProfilesCache()
|
||||
accountsCache[profile.wxid] = {
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
alias: profile.alias,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache))
|
||||
} catch {
|
||||
// 忽略本地缓存失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
const readAccountProfilesCache = (): AccountProfilesCache => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw)
|
||||
return typeof parsed === 'object' && parsed ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeAccountId = (value?: string | null): string => {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
function Sidebar({ collapsed }: SidebarProps) {
|
||||
const location = useLocation()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
||||
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
||||
wxid: '',
|
||||
displayName: '未识别用户'
|
||||
})
|
||||
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
||||
const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false)
|
||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||
const [isSwitchingAccount, setIsSwitchingAccount] = useState(false)
|
||||
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
const setLocked = useAppStore(state => state.setLocked)
|
||||
const isDbConnected = useAppStore(state => state.isDbConnected)
|
||||
const resetChatStore = useChatStore(state => state.reset)
|
||||
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
|
||||
|
||||
useEffect(() => {
|
||||
configService.getAuthEnabled().then(setAuthEnabled)
|
||||
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!isAccountMenuOpen) return
|
||||
const target = event.target as Node | null
|
||||
if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) {
|
||||
setIsAccountMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isAccountMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onExportSessionStatus((payload) => {
|
||||
const countFromPayload = typeof payload?.activeTaskCount === 'number'
|
||||
? payload.activeTaskCount
|
||||
: Array.isArray(payload?.inProgressSessionIds)
|
||||
? payload.inProgressSessionIds.length
|
||||
: 0
|
||||
const normalized = Math.max(0, Math.floor(countFromPayload))
|
||||
setActiveExportTaskCount(normalized)
|
||||
})
|
||||
|
||||
requestExportSessionStatus()
|
||||
const timer = window.setTimeout(() => requestExportSessionStatus(), 120)
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadCurrentUser = async () => {
|
||||
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
|
||||
setUserProfile(prev => {
|
||||
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
|
||||
return prev
|
||||
}
|
||||
const next: SidebarUserProfile = {
|
||||
...prev,
|
||||
...patch
|
||||
}
|
||||
if (!next.displayName) {
|
||||
next.displayName = next.wxid || '未识别用户'
|
||||
}
|
||||
writeSidebarUserProfileCache(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const wxid = await configService.getMyWxid()
|
||||
const resolvedWxidRaw = String(wxid || '').trim()
|
||||
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
|
||||
const resolvedWxid = cleanedWxid || resolvedWxidRaw
|
||||
|
||||
if (!resolvedWxidRaw && !resolvedWxid) return
|
||||
|
||||
const wxidCandidates = new Set<string>([
|
||||
resolvedWxidRaw.toLowerCase(),
|
||||
resolvedWxid.trim().toLowerCase(),
|
||||
cleanedWxid.trim().toLowerCase()
|
||||
].filter(Boolean))
|
||||
|
||||
const normalizeName = (value?: string | null): string | undefined => {
|
||||
if (!value) return undefined
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
const lowered = trimmed.toLowerCase()
|
||||
if (lowered === 'self') return undefined
|
||||
if (lowered.startsWith('wxid_')) return undefined
|
||||
if (wxidCandidates.has(lowered)) return undefined
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeName(candidate)
|
||||
if (normalized) return normalized
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 并行获取名称和头像
|
||||
const [contactResult, avatarResult] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))
|
||||
for (const candidate of candidates) {
|
||||
const contact = await window.electronAPI.chat.getContact(candidate)
|
||||
if (contact?.remark || contact?.nickName || contact?.alias) {
|
||||
return contact
|
||||
}
|
||||
}
|
||||
return null
|
||||
})(),
|
||||
window.electronAPI.chat.getMyAvatarUrl()
|
||||
])
|
||||
|
||||
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
|
||||
const displayName = pickFirstValidName(
|
||||
myContact?.remark,
|
||||
myContact?.nickName,
|
||||
myContact?.alias
|
||||
) || resolvedWxid || '未识别用户'
|
||||
|
||||
patchUserProfile({
|
||||
wxid: resolvedWxid,
|
||||
displayName,
|
||||
alias: myContact?.alias,
|
||||
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
|
||||
? avatarResult.value.avatarUrl
|
||||
: undefined
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载侧边栏用户信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const cachedProfile = readSidebarUserProfileCache()
|
||||
if (cachedProfile) {
|
||||
setUserProfile(cachedProfile)
|
||||
}
|
||||
|
||||
void loadCurrentUser()
|
||||
const onWxidChanged = () => { void loadCurrentUser() }
|
||||
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
}, [])
|
||||
|
||||
const getAvatarLetter = (name: string): string => {
|
||||
if (!name) return '?'
|
||||
return [...name][0] || '?'
|
||||
}
|
||||
|
||||
const openSwitchAccountDialog = async () => {
|
||||
setIsAccountMenuOpen(false)
|
||||
if (!isDbConnected) {
|
||||
window.alert('数据库未连接,无法切换账号')
|
||||
return
|
||||
}
|
||||
const dbPath = await configService.getDbPath()
|
||||
if (!dbPath) {
|
||||
window.alert('请先在设置中配置数据库路径')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
const accountsCache = readAccountProfilesCache()
|
||||
console.log('[切换账号] 账号缓存:', accountsCache)
|
||||
|
||||
const enrichedWxids = wxids.map(option => {
|
||||
const normalizedWxid = normalizeAccountId(option.wxid)
|
||||
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
|
||||
|
||||
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
|
||||
return {
|
||||
...option,
|
||||
displayName: userProfile.displayName,
|
||||
avatarUrl: userProfile.avatarUrl
|
||||
}
|
||||
}
|
||||
if (cached) {
|
||||
console.log('[切换账号] 使用缓存:', option.wxid, cached)
|
||||
return {
|
||||
...option,
|
||||
displayName: cached.displayName,
|
||||
avatarUrl: cached.avatarUrl
|
||||
}
|
||||
}
|
||||
return { ...option, displayName: option.wxid }
|
||||
})
|
||||
|
||||
setWxidOptions(enrichedWxids)
|
||||
setShowSwitchAccountDialog(true)
|
||||
} catch (error) {
|
||||
console.error('扫描账号失败:', error)
|
||||
window.alert('扫描账号失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSwitchAccount = async (selectedWxid: string) => {
|
||||
if (!selectedWxid || isSwitchingAccount) return
|
||||
setIsSwitchingAccount(true)
|
||||
try {
|
||||
console.log('[切换账号] 开始切换到:', selectedWxid)
|
||||
const currentWxid = userProfile.wxid
|
||||
if (currentWxid === selectedWxid) {
|
||||
console.log('[切换账号] 已经是当前账号,跳过')
|
||||
setShowSwitchAccountDialog(false)
|
||||
setIsSwitchingAccount(false)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[切换账号] 设置新 wxid')
|
||||
await configService.setMyWxid(selectedWxid)
|
||||
|
||||
console.log('[切换账号] 获取账号配置')
|
||||
const wxidConfig = await configService.getWxidConfig(selectedWxid)
|
||||
console.log('[切换账号] 配置内容:', wxidConfig)
|
||||
if (wxidConfig?.decryptKey) {
|
||||
console.log('[切换账号] 设置 decryptKey')
|
||||
await configService.setDecryptKey(wxidConfig.decryptKey)
|
||||
}
|
||||
if (typeof wxidConfig?.imageXorKey === 'number') {
|
||||
console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey)
|
||||
await configService.setImageXorKey(wxidConfig.imageXorKey)
|
||||
}
|
||||
if (wxidConfig?.imageAesKey) {
|
||||
console.log('[切换账号] 设置 imageAesKey')
|
||||
await configService.setImageAesKey(wxidConfig.imageAesKey)
|
||||
}
|
||||
|
||||
console.log('[切换账号] 检查数据库连接状态')
|
||||
console.log('[切换账号] 数据库连接状态:', isDbConnected)
|
||||
if (isDbConnected) {
|
||||
console.log('[切换账号] 关闭数据库连接')
|
||||
await window.electronAPI.chat.close()
|
||||
}
|
||||
|
||||
console.log('[切换账号] 清除缓存')
|
||||
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
clearAnalyticsStoreCache()
|
||||
resetChatStore()
|
||||
|
||||
console.log('[切换账号] 触发 wxid-changed 事件')
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
|
||||
|
||||
console.log('[切换账号] 切换成功')
|
||||
setShowSwitchAccountDialog(false)
|
||||
} catch (error) {
|
||||
console.error('[切换账号] 失败:', error)
|
||||
window.alert('切换账号失败,请稍后重试')
|
||||
} finally {
|
||||
setIsSwitchingAccount(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openSettingsFromAccountMenu = () => {
|
||||
setIsAccountMenuOpen(false)
|
||||
navigate('/settings', {
|
||||
state: {
|
||||
backgroundLocation: location
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
}
|
||||
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
|
||||
|
||||
return (
|
||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<nav className="nav-menu">
|
||||
{/* 首页 */}
|
||||
<NavLink
|
||||
to="/home"
|
||||
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
|
||||
title={collapsed ? '首页' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Home size={20} /></span>
|
||||
<span className="nav-label">首页</span>
|
||||
</NavLink>
|
||||
<>
|
||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<nav className="nav-menu">
|
||||
{/* 首页 */}
|
||||
<NavLink
|
||||
to="/home"
|
||||
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
|
||||
title={collapsed ? '首页' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Home size={20} /></span>
|
||||
<span className="nav-label">首页</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 聊天 */}
|
||||
<NavLink
|
||||
to="/chat"
|
||||
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
|
||||
title={collapsed ? '聊天' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><MessageSquare size={20} /></span>
|
||||
<span className="nav-label">聊天</span>
|
||||
</NavLink>
|
||||
{/* 聊天 */}
|
||||
<NavLink
|
||||
to="/chat"
|
||||
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
|
||||
title={collapsed ? '聊天' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><MessageSquare size={20} /></span>
|
||||
<span className="nav-label">聊天</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 朋友圈 */}
|
||||
<NavLink
|
||||
to="/sns"
|
||||
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
||||
title={collapsed ? '朋友圈' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Aperture size={20} /></span>
|
||||
<span className="nav-label">朋友圈</span>
|
||||
</NavLink>
|
||||
{/* 朋友圈 */}
|
||||
<NavLink
|
||||
to="/sns"
|
||||
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
||||
title={collapsed ? '朋友圈' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Aperture size={20} /></span>
|
||||
<span className="nav-label">朋友圈</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 通讯录 */}
|
||||
<NavLink
|
||||
to="/contacts"
|
||||
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
|
||||
title={collapsed ? '通讯录' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><UserCircle size={20} /></span>
|
||||
<span className="nav-label">通讯录</span>
|
||||
</NavLink>
|
||||
{/* 通讯录 */}
|
||||
<NavLink
|
||||
to="/contacts"
|
||||
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
|
||||
title={collapsed ? '通讯录' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><UserCircle size={20} /></span>
|
||||
<span className="nav-label">通讯录</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 私聊分析 */}
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
|
||||
title={collapsed ? '私聊分析' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><BarChart3 size={20} /></span>
|
||||
<span className="nav-label">私聊分析</span>
|
||||
</NavLink>
|
||||
{/* 聊天分析 */}
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
|
||||
title={collapsed ? '聊天分析' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><BarChart3 size={20} /></span>
|
||||
<span className="nav-label">聊天分析</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 群聊分析 */}
|
||||
<NavLink
|
||||
to="/group-analytics"
|
||||
className={`nav-item ${isActive('/group-analytics') ? 'active' : ''}`}
|
||||
title={collapsed ? '群聊分析' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Users size={20} /></span>
|
||||
<span className="nav-label">群聊分析</span>
|
||||
</NavLink>
|
||||
{/* 年度报告 */}
|
||||
<NavLink
|
||||
to="/annual-report"
|
||||
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
|
||||
title={collapsed ? '年度报告' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><FileText size={20} /></span>
|
||||
<span className="nav-label">年度报告</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 年度报告 */}
|
||||
<NavLink
|
||||
to="/annual-report"
|
||||
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
|
||||
title={collapsed ? '年度报告' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><FileText size={20} /></span>
|
||||
<span className="nav-label">年度报告</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 导出 */}
|
||||
<NavLink
|
||||
to="/export"
|
||||
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
||||
title={collapsed ? '导出' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Download size={20} /></span>
|
||||
<span className="nav-label">导出</span>
|
||||
</NavLink>
|
||||
{/* 导出 */}
|
||||
<NavLink
|
||||
to="/export"
|
||||
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
||||
title={collapsed ? '导出' : undefined}
|
||||
>
|
||||
<span className="nav-icon nav-icon-with-badge">
|
||||
<Download size={20} />
|
||||
{collapsed && activeExportTaskCount > 0 && (
|
||||
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="nav-label">导出</span>
|
||||
{!collapsed && activeExportTaskCount > 0 && (
|
||||
<span className="nav-badge">{exportTaskBadge}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
|
||||
</nav>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{authEnabled && (
|
||||
<div className="sidebar-footer">
|
||||
<button
|
||||
className="nav-item"
|
||||
onClick={() => setLocked(true)}
|
||||
title={collapsed ? '锁定' : undefined}
|
||||
onClick={() => {
|
||||
if (authEnabled) {
|
||||
setLocked(true)
|
||||
return
|
||||
}
|
||||
navigate('/settings', {
|
||||
state: {
|
||||
initialTab: 'security',
|
||||
backgroundLocation: location
|
||||
}
|
||||
})
|
||||
}}
|
||||
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Lock size={20} /></span>
|
||||
<span className="nav-label">锁定</span>
|
||||
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
|
||||
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
||||
title={collapsed ? '设置' : undefined}
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<Settings size={20} />
|
||||
</span>
|
||||
<span className="nav-label">设置</span>
|
||||
</NavLink>
|
||||
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
|
||||
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
|
||||
<button
|
||||
className="sidebar-user-menu-item"
|
||||
onClick={openSwitchAccountDialog}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<span>切换账号</span>
|
||||
</button>
|
||||
<button
|
||||
className="sidebar-user-menu-item"
|
||||
onClick={openSettingsFromAccountMenu}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span>设置</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
|
||||
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
|
||||
onClick={() => setIsAccountMenuOpen(prev => !prev)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setIsAccountMenuOpen(prev => !prev)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="user-avatar">
|
||||
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
|
||||
</div>
|
||||
<div className="user-meta">
|
||||
<div className="user-name">{userProfile.displayName}</div>
|
||||
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
|
||||
<ChevronUp size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<button
|
||||
className="collapse-btn"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
title={collapsed ? '展开菜单' : '收起菜单'}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{showSwitchAccountDialog && (
|
||||
<div className="sidebar-dialog-overlay" onClick={() => !isSwitchingAccount && setShowSwitchAccountDialog(false)}>
|
||||
<div className="sidebar-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>切换账号</h3>
|
||||
<p>选择要切换的微信账号</p>
|
||||
<div className="sidebar-wxid-list">
|
||||
{wxidOptions.map((option) => (
|
||||
<button
|
||||
key={option.wxid}
|
||||
className={`sidebar-wxid-item ${userProfile.wxid === option.wxid ? 'current' : ''}`}
|
||||
onClick={() => handleSwitchAccount(option.wxid)}
|
||||
disabled={isSwitchingAccount}
|
||||
type="button"
|
||||
>
|
||||
<div className="wxid-avatar">
|
||||
{option.avatarUrl ? <img src={option.avatarUrl} alt="" /> : <span>{getAvatarLetter(option.displayName || option.wxid)}</span>}
|
||||
</div>
|
||||
<div className="wxid-info">
|
||||
<div className="wxid-name">{option.displayName || option.wxid}</div>
|
||||
<div className="wxid-id">{option.wxid}</div>
|
||||
</div>
|
||||
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sidebar-dialog-actions">
|
||||
<button type="button" onClick={() => setShowSwitchAccountDialog(false)} disabled={isSwitchingAccount}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
329
src/components/Sns/ContactSnsTimelineDialog.scss
Normal file
329
src/components/Sns/ContactSnsTimelineDialog.scss
Normal file
@@ -0,0 +1,329 @@
|
||||
.contact-sns-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
background: rgba(15, 23, 42, 0.38);
|
||||
}
|
||||
|
||||
.contact-sns-dialog {
|
||||
width: min(760px, 100%);
|
||||
max-height: min(86vh, 860px);
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary-solid, #ffffff);
|
||||
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.spin {
|
||||
animation: contactSnsDialogSpin 1s linear infinite;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.contact-sns-dialog-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-sns-dialog-meta {
|
||||
min-width: 0;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-sns-dialog-username {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-stats {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.contact-sns-dialog-header-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||
}
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 248px;
|
||||
max-height: calc((28px * 15) + 16px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
border-radius: 10px;
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18);
|
||||
padding: 8px;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 4px 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 0 4px;
|
||||
border-radius: 7px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-index {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-close-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.contact-sns-dialog-tip {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-posts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-posts-list .post-header-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-status {
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.empty {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.contact-sns-dialog-load-more {
|
||||
display: block;
|
||||
margin: 12px auto 0;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 10px;
|
||||
padding: 9px 18px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.contact-sns-dialog-overlay {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog {
|
||||
width: min(100vw - 16px, 760px);
|
||||
max-height: calc(100vh - 24px);
|
||||
|
||||
.contact-sns-dialog-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-header-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-btn {
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-panel {
|
||||
width: min(78vw, 232px);
|
||||
}
|
||||
|
||||
.contact-sns-dialog-tip {
|
||||
padding: 10px 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-body {
|
||||
padding: 10px 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contactSnsDialogSpin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
593
src/components/Sns/ContactSnsTimelineDialog.tsx
Normal file
593
src/components/Sns/ContactSnsTimelineDialog.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Loader2, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { SnsPostItem } from './SnsPostItem'
|
||||
import type { SnsPost } from '../../types/sns'
|
||||
import {
|
||||
type ContactSnsRankItem,
|
||||
type ContactSnsRankMode,
|
||||
type ContactSnsTimelineTarget,
|
||||
getAvatarLetter
|
||||
} from './contactSnsTimeline'
|
||||
import './ContactSnsTimelineDialog.scss'
|
||||
|
||||
const TIMELINE_PAGE_SIZE = 20
|
||||
const SNS_RANK_PAGE_SIZE = 50
|
||||
const SNS_RANK_DISPLAY_LIMIT = 15
|
||||
|
||||
interface ContactSnsRankCacheEntry {
|
||||
likes: ContactSnsRankItem[]
|
||||
comments: ContactSnsRankItem[]
|
||||
totalPosts: number
|
||||
}
|
||||
|
||||
interface ContactSnsTimelineDialogProps {
|
||||
target: ContactSnsTimelineTarget | null
|
||||
onClose: () => void
|
||||
initialTotalPosts?: number | null
|
||||
initialTotalPostsLoading?: boolean
|
||||
isProtected?: boolean
|
||||
onDeletePost?: (postId: string, username: string) => void
|
||||
}
|
||||
|
||||
const normalizeTotalPosts = (value?: number | null): number | null => {
|
||||
if (!Number.isFinite(value)) return null
|
||||
return Math.max(0, Math.floor(Number(value)))
|
||||
}
|
||||
|
||||
const formatYmdDateFromSeconds = (timestamp?: number): string => {
|
||||
if (!timestamp || !Number.isFinite(timestamp)) return '—'
|
||||
const date = new Date(timestamp * 1000)
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[]; comments: ContactSnsRankItem[] } => {
|
||||
const likeMap = new Map<string, ContactSnsRankItem>()
|
||||
const commentMap = new Map<string, ContactSnsRankItem>()
|
||||
|
||||
for (const post of posts) {
|
||||
const createTime = Number(post?.createTime) || 0
|
||||
const likes = Array.isArray(post?.likes) ? post.likes : []
|
||||
const comments = Array.isArray(post?.comments) ? post.comments : []
|
||||
|
||||
for (const likeNameRaw of likes) {
|
||||
const name = String(likeNameRaw || '').trim() || '未知用户'
|
||||
const current = likeMap.get(name)
|
||||
if (current) {
|
||||
current.count += 1
|
||||
if (createTime > current.latestTime) current.latestTime = createTime
|
||||
continue
|
||||
}
|
||||
likeMap.set(name, { name, count: 1, latestTime: createTime })
|
||||
}
|
||||
|
||||
for (const comment of comments) {
|
||||
const name = String(comment?.nickname || '').trim() || '未知用户'
|
||||
const current = commentMap.get(name)
|
||||
if (current) {
|
||||
current.count += 1
|
||||
if (createTime > current.latestTime) current.latestTime = createTime
|
||||
continue
|
||||
}
|
||||
commentMap.set(name, { name, count: 1, latestTime: createTime })
|
||||
}
|
||||
}
|
||||
|
||||
const sorter = (left: ContactSnsRankItem, right: ContactSnsRankItem): number => {
|
||||
if (right.count !== left.count) return right.count - left.count
|
||||
if (right.latestTime !== left.latestTime) return right.latestTime - left.latestTime
|
||||
return left.name.localeCompare(right.name, 'zh-CN')
|
||||
}
|
||||
|
||||
return {
|
||||
likes: [...likeMap.values()].sort(sorter),
|
||||
comments: [...commentMap.values()].sort(sorter)
|
||||
}
|
||||
}
|
||||
|
||||
export function ContactSnsTimelineDialog({
|
||||
target,
|
||||
onClose,
|
||||
initialTotalPosts = null,
|
||||
initialTotalPostsLoading = false,
|
||||
isProtected = false,
|
||||
onDeletePost
|
||||
}: ContactSnsTimelineDialogProps) {
|
||||
const [timelinePosts, setTimelinePosts] = useState<SnsPost[]>([])
|
||||
const [timelineLoading, setTimelineLoading] = useState(false)
|
||||
const [timelineLoadingMore, setTimelineLoadingMore] = useState(false)
|
||||
const [timelineHasMore, setTimelineHasMore] = useState(false)
|
||||
const [timelineTotalPosts, setTimelineTotalPosts] = useState<number | null>(null)
|
||||
const [timelineStatsLoading, setTimelineStatsLoading] = useState(false)
|
||||
const [rankMode, setRankMode] = useState<ContactSnsRankMode | null>(null)
|
||||
const [likeRankings, setLikeRankings] = useState<ContactSnsRankItem[]>([])
|
||||
const [commentRankings, setCommentRankings] = useState<ContactSnsRankItem[]>([])
|
||||
const [rankLoading, setRankLoading] = useState(false)
|
||||
const [rankError, setRankError] = useState<string | null>(null)
|
||||
const [rankLoadedPosts, setRankLoadedPosts] = useState(0)
|
||||
const [rankTotalPosts, setRankTotalPosts] = useState<number | null>(null)
|
||||
|
||||
const timelinePostsRef = useRef<SnsPost[]>([])
|
||||
const timelineLoadingRef = useRef(false)
|
||||
const timelineRequestTokenRef = useRef(0)
|
||||
const totalPostsRequestTokenRef = useRef(0)
|
||||
const rankRequestTokenRef = useRef(0)
|
||||
const rankLoadingRef = useRef(false)
|
||||
const rankCacheRef = useRef<Record<string, ContactSnsRankCacheEntry>>({})
|
||||
|
||||
const targetUsername = String(target?.username || '').trim()
|
||||
const targetDisplayName = target?.displayName || targetUsername
|
||||
const targetAvatarUrl = target?.avatarUrl
|
||||
|
||||
useEffect(() => {
|
||||
timelinePostsRef.current = timelinePosts
|
||||
}, [timelinePosts])
|
||||
|
||||
const loadTimelinePosts = useCallback(async (nextTarget: ContactSnsTimelineTarget, options?: { reset?: boolean }) => {
|
||||
const reset = Boolean(options?.reset)
|
||||
if (timelineLoadingRef.current) return
|
||||
|
||||
timelineLoadingRef.current = true
|
||||
if (reset) {
|
||||
setTimelineLoading(true)
|
||||
setTimelineLoadingMore(false)
|
||||
setTimelineHasMore(false)
|
||||
} else {
|
||||
setTimelineLoadingMore(true)
|
||||
}
|
||||
|
||||
const requestToken = ++timelineRequestTokenRef.current
|
||||
|
||||
try {
|
||||
let endTime: number | undefined
|
||||
if (!reset && timelinePostsRef.current.length > 0) {
|
||||
endTime = timelinePostsRef.current[timelinePostsRef.current.length - 1].createTime - 1
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.sns.getTimeline(
|
||||
TIMELINE_PAGE_SIZE,
|
||||
0,
|
||||
[nextTarget.username],
|
||||
'',
|
||||
undefined,
|
||||
endTime
|
||||
)
|
||||
if (requestToken !== timelineRequestTokenRef.current) return
|
||||
|
||||
if (!result.success || !Array.isArray(result.timeline)) {
|
||||
if (reset) {
|
||||
setTimelinePosts([])
|
||||
setTimelineHasMore(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const timeline = [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
|
||||
if (reset) {
|
||||
setTimelinePosts(timeline)
|
||||
setTimelineHasMore(timeline.length >= TIMELINE_PAGE_SIZE)
|
||||
return
|
||||
}
|
||||
|
||||
const existingIds = new Set(timelinePostsRef.current.map((post) => post.id))
|
||||
const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id))
|
||||
if (uniqueOlder.length > 0) {
|
||||
const merged = [...timelinePostsRef.current, ...uniqueOlder].sort((left, right) => right.createTime - left.createTime)
|
||||
setTimelinePosts(merged)
|
||||
}
|
||||
if (timeline.length < TIMELINE_PAGE_SIZE) {
|
||||
setTimelineHasMore(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载联系人朋友圈失败:', error)
|
||||
if (requestToken === timelineRequestTokenRef.current && reset) {
|
||||
setTimelinePosts([])
|
||||
setTimelineHasMore(false)
|
||||
}
|
||||
} finally {
|
||||
if (requestToken === timelineRequestTokenRef.current) {
|
||||
timelineLoadingRef.current = false
|
||||
setTimelineLoading(false)
|
||||
setTimelineLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadTimelineTotalPosts = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
|
||||
const requestToken = ++totalPostsRequestTokenRef.current
|
||||
setTimelineStatsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||
if (requestToken !== totalPostsRequestTokenRef.current) return
|
||||
|
||||
if (!result.success || !result.counts) {
|
||||
setTimelineTotalPosts(null)
|
||||
setRankTotalPosts(null)
|
||||
return
|
||||
}
|
||||
|
||||
const rawCount = Number(result.counts[nextTarget.username] || 0)
|
||||
const normalized = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
|
||||
setTimelineTotalPosts(normalized)
|
||||
setRankTotalPosts(normalized)
|
||||
} catch (error) {
|
||||
console.error('加载联系人朋友圈条数失败:', error)
|
||||
if (requestToken !== totalPostsRequestTokenRef.current) return
|
||||
setTimelineTotalPosts(null)
|
||||
setRankTotalPosts(null)
|
||||
} finally {
|
||||
if (requestToken === totalPostsRequestTokenRef.current) {
|
||||
setTimelineStatsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadRankings = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
|
||||
const normalizedUsername = String(nextTarget?.username || '').trim()
|
||||
if (!normalizedUsername || rankLoadingRef.current) return
|
||||
|
||||
const normalizedKnownTotal = normalizeTotalPosts(timelineTotalPosts)
|
||||
const cached = rankCacheRef.current[normalizedUsername]
|
||||
|
||||
if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) {
|
||||
setLikeRankings(cached.likes)
|
||||
setCommentRankings(cached.comments)
|
||||
setRankLoadedPosts(cached.totalPosts)
|
||||
setRankTotalPosts(cached.totalPosts)
|
||||
setRankError(null)
|
||||
setRankLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
rankLoadingRef.current = true
|
||||
const requestToken = ++rankRequestTokenRef.current
|
||||
setRankLoading(true)
|
||||
setRankError(null)
|
||||
setRankLoadedPosts(0)
|
||||
setRankTotalPosts(normalizedKnownTotal)
|
||||
|
||||
try {
|
||||
const allPosts: SnsPost[] = []
|
||||
let endTime: number | undefined
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
const result = await window.electronAPI.sns.getTimeline(
|
||||
SNS_RANK_PAGE_SIZE,
|
||||
0,
|
||||
[normalizedUsername],
|
||||
'',
|
||||
undefined,
|
||||
endTime
|
||||
)
|
||||
if (requestToken !== rankRequestTokenRef.current) return
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '加载朋友圈排行失败')
|
||||
}
|
||||
|
||||
const pagePosts = Array.isArray(result.timeline)
|
||||
? [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
|
||||
: []
|
||||
if (pagePosts.length === 0) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
allPosts.push(...pagePosts)
|
||||
setRankLoadedPosts(allPosts.length)
|
||||
if (normalizedKnownTotal === null) {
|
||||
setRankTotalPosts(allPosts.length)
|
||||
}
|
||||
|
||||
endTime = pagePosts[pagePosts.length - 1].createTime - 1
|
||||
hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE
|
||||
}
|
||||
|
||||
if (requestToken !== rankRequestTokenRef.current) return
|
||||
|
||||
const rankings = buildContactSnsRankings(allPosts)
|
||||
const totalPosts = allPosts.length
|
||||
rankCacheRef.current[normalizedUsername] = {
|
||||
likes: rankings.likes,
|
||||
comments: rankings.comments,
|
||||
totalPosts
|
||||
}
|
||||
setLikeRankings(rankings.likes)
|
||||
setCommentRankings(rankings.comments)
|
||||
setRankLoadedPosts(totalPosts)
|
||||
setRankTotalPosts(totalPosts)
|
||||
setRankError(null)
|
||||
} catch (error) {
|
||||
if (requestToken !== rankRequestTokenRef.current) return
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
setLikeRankings([])
|
||||
setCommentRankings([])
|
||||
setRankError(message || '加载朋友圈排行失败')
|
||||
} finally {
|
||||
if (requestToken === rankRequestTokenRef.current) {
|
||||
rankLoadingRef.current = false
|
||||
setRankLoading(false)
|
||||
}
|
||||
}
|
||||
}, [timelineTotalPosts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetUsername) return
|
||||
|
||||
totalPostsRequestTokenRef.current += 1
|
||||
rankRequestTokenRef.current += 1
|
||||
rankLoadingRef.current = false
|
||||
setRankMode(null)
|
||||
setLikeRankings([])
|
||||
setCommentRankings([])
|
||||
setRankLoading(false)
|
||||
setRankError(null)
|
||||
setRankLoadedPosts(0)
|
||||
setRankTotalPosts(null)
|
||||
setTimelinePosts([])
|
||||
setTimelineTotalPosts(null)
|
||||
setTimelineStatsLoading(false)
|
||||
setTimelineHasMore(false)
|
||||
setTimelineLoadingMore(false)
|
||||
setTimelineLoading(false)
|
||||
|
||||
void loadTimelinePosts({
|
||||
username: targetUsername,
|
||||
displayName: targetDisplayName,
|
||||
avatarUrl: targetAvatarUrl
|
||||
}, { reset: true })
|
||||
}, [loadTimelinePosts, targetAvatarUrl, targetDisplayName, targetUsername])
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetUsername) return
|
||||
|
||||
const normalizedTotal = normalizeTotalPosts(initialTotalPosts)
|
||||
if (normalizedTotal !== null) {
|
||||
setTimelineTotalPosts(normalizedTotal)
|
||||
setRankTotalPosts(normalizedTotal)
|
||||
setTimelineStatsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (initialTotalPostsLoading) {
|
||||
setTimelineTotalPosts(null)
|
||||
setRankTotalPosts(null)
|
||||
setTimelineStatsLoading(true)
|
||||
return
|
||||
}
|
||||
|
||||
void loadTimelineTotalPosts({
|
||||
username: targetUsername,
|
||||
displayName: targetDisplayName,
|
||||
avatarUrl: targetAvatarUrl
|
||||
})
|
||||
}, [
|
||||
initialTotalPosts,
|
||||
initialTotalPostsLoading,
|
||||
loadTimelineTotalPosts,
|
||||
targetAvatarUrl,
|
||||
targetDisplayName,
|
||||
targetUsername
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (timelineTotalPosts === null) return
|
||||
if (timelinePosts.length >= timelineTotalPosts) {
|
||||
setTimelineHasMore(false)
|
||||
}
|
||||
}, [timelinePosts.length, timelineTotalPosts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!rankMode || !targetUsername) return
|
||||
void loadRankings({
|
||||
username: targetUsername,
|
||||
displayName: targetDisplayName,
|
||||
avatarUrl: targetAvatarUrl
|
||||
})
|
||||
}, [loadRankings, rankMode, targetAvatarUrl, targetDisplayName, targetUsername])
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetUsername) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose, targetUsername])
|
||||
|
||||
const timelineStatsText = useMemo(() => {
|
||||
const loadedCount = timelinePosts.length
|
||||
const loadPart = timelineStatsLoading
|
||||
? `已加载 ${loadedCount} / 总数统计中...`
|
||||
: timelineTotalPosts === null
|
||||
? `已加载 ${loadedCount} 条`
|
||||
: `已加载 ${loadedCount} / 共 ${timelineTotalPosts} 条`
|
||||
|
||||
if (timelineLoading && loadedCount === 0) return `${loadPart} | 加载中...`
|
||||
if (loadedCount === 0) return loadPart
|
||||
|
||||
const latest = timelinePosts[0]?.createTime
|
||||
const earliest = timelinePosts[timelinePosts.length - 1]?.createTime
|
||||
return `${loadPart} | ${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}`
|
||||
}, [timelineLoading, timelinePosts, timelineStatsLoading, timelineTotalPosts])
|
||||
|
||||
const activeRankings = useMemo(() => {
|
||||
if (rankMode === 'likes') return likeRankings
|
||||
if (rankMode === 'comments') return commentRankings
|
||||
return []
|
||||
}, [commentRankings, likeRankings, rankMode])
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!targetUsername || timelineLoading || timelineLoadingMore || !timelineHasMore) return
|
||||
void loadTimelinePosts({
|
||||
username: targetUsername,
|
||||
displayName: targetDisplayName,
|
||||
avatarUrl: targetAvatarUrl
|
||||
}, { reset: false })
|
||||
}, [
|
||||
loadTimelinePosts,
|
||||
targetAvatarUrl,
|
||||
targetDisplayName,
|
||||
targetUsername,
|
||||
timelineHasMore,
|
||||
timelineLoading,
|
||||
timelineLoadingMore
|
||||
])
|
||||
|
||||
const handleBodyScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const element = event.currentTarget
|
||||
const remaining = element.scrollHeight - element.scrollTop - element.clientHeight
|
||||
if (remaining <= 160) {
|
||||
loadMore()
|
||||
}
|
||||
}, [loadMore])
|
||||
|
||||
const toggleRankMode = useCallback((mode: ContactSnsRankMode) => {
|
||||
setRankMode((previous) => (previous === mode ? null : mode))
|
||||
}, [])
|
||||
|
||||
if (!target) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="contact-sns-dialog-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="contact-sns-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="联系人朋友圈"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="contact-sns-dialog-header">
|
||||
<div className="contact-sns-dialog-header-main">
|
||||
<div className="contact-sns-dialog-avatar">
|
||||
{targetAvatarUrl ? (
|
||||
<img src={targetAvatarUrl} alt="" />
|
||||
) : (
|
||||
<span>{getAvatarLetter(targetDisplayName)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="contact-sns-dialog-meta">
|
||||
<h4>{targetDisplayName}</h4>
|
||||
<div className="contact-sns-dialog-username">@{targetUsername}</div>
|
||||
<div className="contact-sns-dialog-stats">{timelineStatsText}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="contact-sns-dialog-header-actions">
|
||||
<div className="contact-sns-dialog-rank-switch">
|
||||
<button
|
||||
type="button"
|
||||
className={`contact-sns-dialog-rank-btn ${rankMode === 'likes' ? 'active' : ''}`}
|
||||
onClick={() => toggleRankMode('likes')}
|
||||
>
|
||||
点赞排行
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`contact-sns-dialog-rank-btn ${rankMode === 'comments' ? 'active' : ''}`}
|
||||
onClick={() => toggleRankMode('comments')}
|
||||
>
|
||||
评论排行
|
||||
</button>
|
||||
{rankMode && (
|
||||
<div
|
||||
className="contact-sns-dialog-rank-panel"
|
||||
role="region"
|
||||
aria-label={rankMode === 'likes' ? '点赞排行' : '评论排行'}
|
||||
>
|
||||
{rankLoading && (
|
||||
<div className="contact-sns-dialog-rank-loading">
|
||||
<Loader2 size={12} className="spin" />
|
||||
<span>
|
||||
{rankTotalPosts !== null && rankTotalPosts > 0
|
||||
? `统计中,已加载 ${rankLoadedPosts} / ${rankTotalPosts} 条`
|
||||
: `统计中,已加载 ${rankLoadedPosts} 条`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!rankLoading && rankError ? (
|
||||
<div className="contact-sns-dialog-rank-empty">{rankError}</div>
|
||||
) : !rankLoading && activeRankings.length === 0 ? (
|
||||
<div className="contact-sns-dialog-rank-empty">
|
||||
{rankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'}
|
||||
</div>
|
||||
) : (
|
||||
activeRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => (
|
||||
<div className="contact-sns-dialog-rank-row" key={`${rankMode}-${item.name}`}>
|
||||
<span className="contact-sns-dialog-rank-index">{index + 1}</span>
|
||||
<span className="contact-sns-dialog-rank-name" title={item.name}>{item.name}</span>
|
||||
<span className="contact-sns-dialog-rank-count">
|
||||
{item.count.toLocaleString('zh-CN')}
|
||||
{rankMode === 'likes' ? '次' : '条'}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="contact-sns-dialog-close-btn" type="button" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="contact-sns-dialog-tip">
|
||||
在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="contact-sns-dialog-body"
|
||||
onScroll={handleBodyScroll}
|
||||
>
|
||||
{timelinePosts.length > 0 && (
|
||||
<div className="contact-sns-dialog-posts-list">
|
||||
{timelinePosts.map((post) => (
|
||||
<SnsPostItem
|
||||
key={post.id}
|
||||
post={{ ...post, isProtected }}
|
||||
onPreview={(src, isVideo, liveVideoPath) => {
|
||||
if (isVideo) {
|
||||
void window.electronAPI.window.openVideoPlayerWindow(src)
|
||||
} else {
|
||||
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
|
||||
}
|
||||
}}
|
||||
onDebug={() => {}}
|
||||
onDelete={onDeletePost}
|
||||
hideAuthorMeta
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timelineLoading && (
|
||||
<div className="contact-sns-dialog-status">正在加载该联系人的朋友圈...</div>
|
||||
)}
|
||||
|
||||
{!timelineLoading && timelinePosts.length === 0 && (
|
||||
<div className="contact-sns-dialog-status empty">该联系人暂无朋友圈</div>
|
||||
)}
|
||||
|
||||
{!timelineLoading && timelineHasMore && (
|
||||
<button
|
||||
className="contact-sns-dialog-load-more"
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={timelineLoadingMore}
|
||||
>
|
||||
{timelineLoadingMore ? '正在加载...' : '加载更多'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,67 +1,83 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { Search, User, X, Loader2, CheckSquare, Square, Download } 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
|
||||
postCount?: number
|
||||
postCountStatus?: 'idle' | 'loading' | 'ready'
|
||||
}
|
||||
|
||||
interface ContactsCountProgress {
|
||||
resolved: number
|
||||
total: number
|
||||
running: boolean
|
||||
}
|
||||
|
||||
interface SnsFilterPanelProps {
|
||||
searchKeyword: string
|
||||
setSearchKeyword: (val: string) => void
|
||||
jumpTargetDate?: Date
|
||||
setJumpTargetDate: (date?: Date) => void
|
||||
onOpenJumpDialog: () => void
|
||||
selectedUsernames: string[]
|
||||
setSelectedUsernames: (val: string[]) => void
|
||||
totalFriendsLabel?: string
|
||||
contacts: Contact[]
|
||||
contactSearch: string
|
||||
setContactSearch: (val: string) => void
|
||||
loading?: boolean
|
||||
contactsCountProgress?: ContactsCountProgress
|
||||
selectedContactUsernames: string[]
|
||||
activeContactUsername?: string
|
||||
onOpenContactTimeline: (contact: Contact) => void
|
||||
onToggleContactSelected: (contact: Contact) => void
|
||||
onClearSelectedContacts: () => void
|
||||
onExportSelectedContacts: () => void
|
||||
}
|
||||
|
||||
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
searchKeyword,
|
||||
setSearchKeyword,
|
||||
jumpTargetDate,
|
||||
setJumpTargetDate,
|
||||
onOpenJumpDialog,
|
||||
selectedUsernames,
|
||||
setSelectedUsernames,
|
||||
totalFriendsLabel,
|
||||
contacts,
|
||||
contactSearch,
|
||||
setContactSearch,
|
||||
loading
|
||||
loading,
|
||||
contactsCountProgress,
|
||||
selectedContactUsernames,
|
||||
activeContactUsername,
|
||||
onOpenContactTimeline,
|
||||
onToggleContactSelected,
|
||||
onClearSelectedContacts,
|
||||
onExportSelectedContacts
|
||||
}) => {
|
||||
|
||||
const filteredContacts = contacts.filter(c =>
|
||||
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||
(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 selectedContactLookup = React.useMemo(
|
||||
() => new Set(selectedContactUsernames),
|
||||
[selectedContactUsernames]
|
||||
)
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchKeyword('')
|
||||
setSelectedUsernames([])
|
||||
setJumpTargetDate(undefined)
|
||||
setContactSearch('')
|
||||
}
|
||||
|
||||
const getEmptyStateText = () => {
|
||||
if (loading && contacts.length === 0) {
|
||||
return '正在加载联系人...'
|
||||
}
|
||||
if (contacts.length === 0) {
|
||||
return '暂无好友或曾经的好友'
|
||||
}
|
||||
return '没有找到联系人'
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sns-filter-panel">
|
||||
<div className="filter-header">
|
||||
<h3>筛选条件</h3>
|
||||
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && (
|
||||
{(searchKeyword || contactSearch) && (
|
||||
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
@@ -89,43 +105,13 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
)}
|
||||
</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>
|
||||
{totalFriendsLabel && (
|
||||
<span className="widget-header-summary">{totalFriendsLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -142,21 +128,77 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contactsCountProgress && contactsCountProgress.total > 0 && (
|
||||
<div className="contact-count-progress">
|
||||
{contactsCountProgress.running
|
||||
? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}`
|
||||
: `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="contact-interaction-hint">
|
||||
点左侧可多选下载,点右侧可查看单人详情
|
||||
</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.map(contact => {
|
||||
const isPostCountReady = contact.postCountStatus === 'ready'
|
||||
const isSelected = selectedContactLookup.has(contact.username)
|
||||
const isActive = activeContactUsername === contact.username
|
||||
return (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-row${isSelected ? ' is-selected' : ''}${isActive ? ' is-active' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`contact-select-btn${isSelected ? ' checked' : ''}`}
|
||||
onClick={() => onToggleContactSelected(contact)}
|
||||
title={isSelected ? `取消选择 ${contact.displayName}` : `选择 ${contact.displayName}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{isSelected ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="contact-main-btn"
|
||||
onClick={() => onOpenContactTimeline(contact)}
|
||||
title={`查看 ${contact.displayName} 的朋友圈`}
|
||||
>
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
||||
<div className="contact-meta">
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
</div>
|
||||
<div className="contact-post-count-wrap">
|
||||
{isPostCountReady ? (
|
||||
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}条</span>
|
||||
) : (
|
||||
<span className="contact-post-count-loading" title="统计中">
|
||||
<Loader2 size={13} className="spinning" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filteredContacts.length === 0 && (
|
||||
<div className="empty-state">没有找到联系人</div>
|
||||
<div className="empty-state">{getEmptyStateText()}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedContactUsernames.length > 0 && (
|
||||
<div className="contact-batch-bar">
|
||||
<span className="contact-batch-summary">已选 {selectedContactUsernames.length} 人</span>
|
||||
<button type="button" className="contact-batch-btn" onClick={onClearSelectedContacts}>
|
||||
清空
|
||||
</button>
|
||||
<button type="button" className="contact-batch-btn primary" onClick={onExportSelectedContacts}>
|
||||
<Download size={14} />
|
||||
<span>下载所选</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -21,6 +21,7 @@ interface SnsMedia {
|
||||
|
||||
interface SnsMediaGridProps {
|
||||
mediaList: SnsMedia[]
|
||||
postType?: number
|
||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||
onMediaDeleted?: () => void
|
||||
}
|
||||
@@ -80,7 +81,7 @@ const extractVideoFrame = async (videoPath: string): Promise<string> => {
|
||||
})
|
||||
}
|
||||
|
||||
const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
|
||||
const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsMedia; postType?: number; 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)
|
||||
@@ -96,6 +97,8 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
|
||||
const isVideo = isSnsVideoUrl(media.url)
|
||||
const isLive = !!media.livePhoto
|
||||
const targetUrl = media.thumb || media.url
|
||||
// type 7 的朋友圈媒体不需要解密,直接使用原始 URL
|
||||
const skipDecrypt = postType === 7
|
||||
|
||||
// 视频重试:失败时重试最多2次,耗尽才标记删除
|
||||
const videoRetryOrDelete = () => {
|
||||
@@ -119,7 +122,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
|
||||
// For images, we proxy to get the local path/base64
|
||||
const result = await window.electronAPI.sns.proxyImage({
|
||||
url: targetUrl,
|
||||
key: media.key
|
||||
key: skipDecrypt ? undefined : media.key
|
||||
})
|
||||
if (cancelled) return
|
||||
|
||||
@@ -134,7 +137,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
|
||||
if (isLive && media.livePhoto?.url) {
|
||||
window.electronAPI.sns.proxyImage({
|
||||
url: media.livePhoto.url,
|
||||
key: media.livePhoto.key || media.key
|
||||
key: skipDecrypt ? undefined : (media.livePhoto.key || media.key)
|
||||
}).then((res: any) => {
|
||||
if (!cancelled && res.success && res.videoPath) {
|
||||
setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`)
|
||||
@@ -150,7 +153,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
|
||||
// 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
|
||||
key: skipDecrypt ? undefined : media.key
|
||||
})
|
||||
|
||||
if (cancelled) return
|
||||
@@ -201,7 +204,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
|
||||
try {
|
||||
const res = await window.electronAPI.sns.proxyImage({
|
||||
url: media.url,
|
||||
key: media.key
|
||||
key: skipDecrypt ? undefined : media.key
|
||||
})
|
||||
if (res.success && res.videoPath) {
|
||||
const local = `file://${res.videoPath.replace(/\\/g, '/')}`
|
||||
@@ -229,7 +232,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
|
||||
try {
|
||||
const result = await window.electronAPI.sns.proxyImage({
|
||||
url: media.url,
|
||||
key: media.key
|
||||
key: skipDecrypt ? undefined : media.key
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
@@ -334,7 +337,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr
|
||||
)
|
||||
}
|
||||
|
||||
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview, onMediaDeleted }) => {
|
||||
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, postType, onPreview, onMediaDeleted }) => {
|
||||
if (!mediaList || mediaList.length === 0) return null
|
||||
|
||||
const count = mediaList.length
|
||||
@@ -350,7 +353,7 @@ export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview
|
||||
return (
|
||||
<div className={`sns-media-grid ${gridClass}`}>
|
||||
{mediaList.map((media, idx) => (
|
||||
<MediaItem key={idx} media={media} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
|
||||
<MediaItem key={idx} media={media} postType={postType} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal, Trash2 } from 'lucide-react'
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react'
|
||||
import { SnsPost, SnsLinkCardData } from '../../types/sns'
|
||||
import { Avatar } from '../Avatar'
|
||||
import { SnsMediaGrid } from './SnsMediaGrid'
|
||||
@@ -178,14 +179,80 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 表情包内存缓存
|
||||
const emojiLocalCache = new Map<string, string>()
|
||||
|
||||
// 评论表情包组件
|
||||
const CommentEmoji: React.FC<{
|
||||
emoji: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }
|
||||
onPreview?: (src: string) => void
|
||||
}> = ({ emoji, onPreview }) => {
|
||||
const cacheKey = emoji.encryptUrl || emoji.url
|
||||
const [localSrc, setLocalSrc] = useState<string>(() => emojiLocalCache.get(cacheKey) || '')
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheKey) return
|
||||
if (emojiLocalCache.has(cacheKey)) {
|
||||
setLocalSrc(emojiLocalCache.get(cacheKey)!)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await window.electronAPI.sns.downloadEmoji({
|
||||
url: emoji.url,
|
||||
encryptUrl: emoji.encryptUrl,
|
||||
aesKey: emoji.aesKey
|
||||
})
|
||||
if (cancelled) return
|
||||
if (res.success && res.localPath) {
|
||||
const fileUrl = res.localPath.startsWith('file:')
|
||||
? res.localPath
|
||||
: `file://${res.localPath.replace(/\\/g, '/')}`
|
||||
emojiLocalCache.set(cacheKey, fileUrl)
|
||||
setLocalSrc(fileUrl)
|
||||
}
|
||||
} catch { /* 静默失败 */ }
|
||||
}
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [cacheKey])
|
||||
|
||||
if (!localSrc) return null
|
||||
|
||||
return (
|
||||
<img
|
||||
src={localSrc}
|
||||
alt="emoji"
|
||||
className="comment-custom-emoji"
|
||||
draggable={false}
|
||||
onClick={(e) => { e.stopPropagation(); onPreview?.(localSrc) }}
|
||||
style={{
|
||||
width: Math.min(emoji.width || 24, 30),
|
||||
height: Math.min(emoji.height || 24, 30),
|
||||
verticalAlign: 'middle',
|
||||
marginLeft: 2,
|
||||
borderRadius: 4,
|
||||
cursor: onPreview ? 'pointer' : 'default'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface SnsPostItemProps {
|
||||
post: SnsPost
|
||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||
onDebug: (post: SnsPost) => void
|
||||
onDelete?: (postId: string, username: string) => void
|
||||
onOpenAuthorPosts?: (post: SnsPost) => void
|
||||
hideAuthorMeta?: boolean
|
||||
}
|
||||
|
||||
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
|
||||
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => {
|
||||
const [mediaDeleted, setMediaDeleted] = useState(false)
|
||||
const [dbDeleted, setDbDeleted] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = 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
|
||||
@@ -221,30 +288,84 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (deleting || dbDeleted) return
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
setDeleting(true)
|
||||
try {
|
||||
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
|
||||
if (r.success) {
|
||||
setDbDeleted(true)
|
||||
onDelete?.(post.id, post.username)
|
||||
}
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenAuthorPosts = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onOpenAuthorPosts?.(post)
|
||||
}
|
||||
|
||||
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={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
|
||||
{!hideAuthorMeta && (
|
||||
<div className="post-avatar-col">
|
||||
<button
|
||||
type="button"
|
||||
className="author-trigger-btn avatar-trigger"
|
||||
onClick={handleOpenAuthorPosts}
|
||||
title="查看该发布者的全部朋友圈"
|
||||
>
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
name={post.nickname}
|
||||
size={48}
|
||||
shape="rounded"
|
||||
/>
|
||||
</button>
|
||||
</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>
|
||||
{hideAuthorMeta ? (
|
||||
<span className="post-time post-time-standalone">{formatTime(post.createTime)}</span>
|
||||
) : (
|
||||
<div className="post-author-info">
|
||||
<button
|
||||
type="button"
|
||||
className="author-trigger-btn author-name-trigger"
|
||||
onClick={handleOpenAuthorPosts}
|
||||
title="查看该发布者的全部朋友圈"
|
||||
>
|
||||
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
||||
</button>
|
||||
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="post-header-actions">
|
||||
{mediaDeleted && (
|
||||
{(mediaDeleted || dbDeleted) && (
|
||||
<span className="post-deleted-badge">
|
||||
<Trash2 size={12} />
|
||||
<span>已删除</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="icon-btn-ghost debug-btn delete-btn"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={deleting || dbDeleted}
|
||||
title="从数据库删除此条记录"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDebug(post);
|
||||
@@ -264,7 +385,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
|
||||
{showMediaGrid && (
|
||||
<div className="post-media-container">
|
||||
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
|
||||
<SnsMediaGrid mediaList={post.media} postType={post.type} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -289,7 +410,16 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
</>
|
||||
)}
|
||||
<span className="comment-colon">:</span>
|
||||
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
|
||||
{c.content && (
|
||||
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
|
||||
)}
|
||||
{c.emojis && c.emojis.map((emoji, ei) => (
|
||||
<CommentEmoji
|
||||
key={ei}
|
||||
emoji={emoji}
|
||||
onPreview={(src) => onPreview(src)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -298,5 +428,24 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 删除确认弹窗 - 用 Portal 挂到 body,避免父级 transform 影响 fixed 定位 */}
|
||||
{showDeleteConfirm && createPortal(
|
||||
<div className="sns-confirm-overlay" onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div className="sns-confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="sns-confirm-icon">
|
||||
<Trash2 size={22} />
|
||||
</div>
|
||||
<div className="sns-confirm-title">删除这条记录?</div>
|
||||
<div className="sns-confirm-desc">将从本地数据库中永久删除,无法恢复。</div>
|
||||
<div className="sns-confirm-actions">
|
||||
<button className="sns-confirm-cancel" onClick={() => setShowDeleteConfirm(false)}>取消</button>
|
||||
<button className="sns-confirm-ok" onClick={handleDeleteConfirm}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
26
src/components/Sns/contactSnsTimeline.ts
Normal file
26
src/components/Sns/contactSnsTimeline.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface ContactSnsTimelineTarget {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export interface ContactSnsRankItem {
|
||||
name: string
|
||||
count: number
|
||||
latestTime: number
|
||||
}
|
||||
|
||||
export type ContactSnsRankMode = 'likes' | 'comments'
|
||||
|
||||
export const isSingleContactSession = (sessionId: string): boolean => {
|
||||
const normalized = String(sessionId || '').trim()
|
||||
if (!normalized) return false
|
||||
if (normalized.includes('@chatroom')) return false
|
||||
if (normalized.startsWith('gh_')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export const getAvatarLetter = (name: string): string => {
|
||||
if (!name) return '?'
|
||||
return [...name][0] || '?'
|
||||
}
|
||||
@@ -3,11 +3,27 @@
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
-webkit-app-region: drag;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
z-index: 2101;
|
||||
}
|
||||
|
||||
// 繁花如梦:标题栏毛玻璃
|
||||
[data-theme="blossom-dream"] .title-bar {
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.title-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-logo {
|
||||
@@ -20,4 +36,111 @@
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.title-sidebar-toggle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.title-window-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.title-window-control-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.is-close:hover {
|
||||
background: #e5484d;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.image-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: auto;
|
||||
padding-left: 16px;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.live-play-btn.active {
|
||||
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
|
||||
color: var(--primary, #4c84ff);
|
||||
}
|
||||
}
|
||||
|
||||
.scale-text {
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,87 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Copy, Minus, PanelLeftClose, PanelLeftOpen, Square, X } from 'lucide-react'
|
||||
import './TitleBar.scss'
|
||||
|
||||
interface TitleBarProps {
|
||||
title?: string
|
||||
sidebarCollapsed?: boolean
|
||||
onToggleSidebar?: () => void
|
||||
showWindowControls?: boolean
|
||||
customControls?: React.ReactNode
|
||||
showLogo?: boolean
|
||||
}
|
||||
|
||||
function TitleBar({ title }: TitleBarProps = {}) {
|
||||
function TitleBar({
|
||||
title,
|
||||
sidebarCollapsed = false,
|
||||
onToggleSidebar,
|
||||
showWindowControls = true,
|
||||
customControls,
|
||||
showLogo = true
|
||||
}: TitleBarProps = {}) {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showWindowControls) return
|
||||
|
||||
void window.electronAPI.window.isMaximized().then(setIsMaximized).catch(() => {
|
||||
setIsMaximized(false)
|
||||
})
|
||||
|
||||
return window.electronAPI.window.onMaximizeStateChanged((maximized) => {
|
||||
setIsMaximized(maximized)
|
||||
})
|
||||
}, [showWindowControls])
|
||||
|
||||
return (
|
||||
<div className="title-bar">
|
||||
<img src="./logo.png" alt="WeFlow" className="title-logo" />
|
||||
<span className="titles">{title || 'WeFlow'}</span>
|
||||
<div className="title-brand">
|
||||
{showLogo && <img src="./logo.png" alt="WeFlow" className="title-logo" />}
|
||||
<span className="titles">{title || 'WeFlow'}</span>
|
||||
{onToggleSidebar ? (
|
||||
<button
|
||||
type="button"
|
||||
className="title-sidebar-toggle"
|
||||
onClick={onToggleSidebar}
|
||||
title={sidebarCollapsed ? '展开菜单' : '收起菜单'}
|
||||
aria-label={sidebarCollapsed ? '展开菜单' : '收起菜单'}
|
||||
>
|
||||
{sidebarCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{customControls}
|
||||
{showWindowControls ? (
|
||||
<div className="title-window-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="title-window-control-btn"
|
||||
aria-label="最小化"
|
||||
title="最小化"
|
||||
onClick={() => window.electronAPI.window.minimize()}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="title-window-control-btn"
|
||||
aria-label={isMaximized ? '还原' : '最大化'}
|
||||
title={isMaximized ? '还原' : '最大化'}
|
||||
onClick={() => window.electronAPI.window.maximize()}
|
||||
>
|
||||
{isMaximized ? <Copy size={12} /> : <Square size={12} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="title-window-control-btn is-close"
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
onClick={() => window.electronAPI.window.close()}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
.update-dialog {
|
||||
width: 680px;
|
||||
background: #f5f5f5;
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
/* Top Section (White/Gradient) */
|
||||
.dialog-header {
|
||||
background: #ffffff;
|
||||
background: var(--bg-primary, #ffffff);
|
||||
padding: 40px 20px 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -41,14 +41,14 @@
|
||||
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;
|
||||
background: radial-gradient(circle, rgba(255, 235, 220, 0.15) 0%, rgba(255, 255, 255, 0) 70%);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
background: #f0eee9;
|
||||
color: #8c7b6e;
|
||||
background: var(--bg-tertiary, #f0eee9);
|
||||
color: var(--text-tertiary, #8c7b6e);
|
||||
padding: 4px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
@@ -60,21 +60,21 @@
|
||||
h2 {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #333333;
|
||||
color: var(--text-primary, #333333);
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 15px;
|
||||
color: #999999;
|
||||
color: var(--text-secondary, #999999);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
/* Content Section (Light Gray) */
|
||||
.dialog-content {
|
||||
background: #f2f2f2;
|
||||
background: var(--bg-tertiary, #f2f2f2);
|
||||
padding: 24px 40px 40px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -87,7 +87,7 @@
|
||||
margin-bottom: 30px;
|
||||
|
||||
.icon-box {
|
||||
background: #fbfbfb; // Beige-ish white
|
||||
background: var(--bg-primary, #fbfbfb);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
@@ -96,7 +96,7 @@
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
color: #8c7b6e;
|
||||
color: var(--text-tertiary, #8c7b6e);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
|
||||
|
||||
svg {
|
||||
@@ -107,27 +107,38 @@
|
||||
.text-box {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--text-primary, #333333);
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
margin: 0 0 8px;
|
||||
margin: 16px 0 8px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
color: var(--text-secondary, #666666);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 8px 0 0 18px;
|
||||
margin: 4px 0 0 18px;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
color: var(--text-secondary, #666666);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
@@ -142,19 +153,19 @@
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
color: var(--text-secondary, #888);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
height: 6px;
|
||||
background: #e0e0e0;
|
||||
background: var(--border-color, #e0e0e0);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: #000000;
|
||||
background: var(--text-primary, #000000);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
@@ -164,7 +175,7 @@
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,8 +186,8 @@
|
||||
|
||||
.btn-ignore {
|
||||
background: transparent;
|
||||
color: #666666;
|
||||
border: 1px solid #d0d0d0;
|
||||
color: var(--text-secondary, #666666);
|
||||
border: 1px solid var(--border-color, #d0d0d0);
|
||||
padding: 16px 32px;
|
||||
border-radius: 20px;
|
||||
font-size: 16px;
|
||||
@@ -185,9 +196,9 @@
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #999999;
|
||||
color: #333333;
|
||||
background: var(--bg-hover, #f5f5f5);
|
||||
border-color: var(--text-secondary, #999999);
|
||||
color: var(--text-primary, #333333);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -196,11 +207,11 @@
|
||||
}
|
||||
|
||||
.btn-update {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
background: var(--text-primary, #000000);
|
||||
color: var(--bg-primary, #ffffff);
|
||||
border: none;
|
||||
padding: 16px 48px;
|
||||
border-radius: 20px; // Pill shape
|
||||
border-radius: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -231,7 +242,7 @@
|
||||
right: 16px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: none;
|
||||
color: #999;
|
||||
color: var(--text-secondary, #999);
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -244,7 +255,7 @@
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: #333;
|
||||
color: var(--text-primary, #333);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
||||
<Quote size={20} />
|
||||
</div>
|
||||
<div className="text-box">
|
||||
<h3>优化</h3>
|
||||
{updateInfo.releaseNotes ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
|
||||
) : (
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
.analytics-page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 100%;
|
||||
|
||||
.loading-container,
|
||||
.error-container {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载和错误状态
|
||||
.loading-container,
|
||||
.error-container {
|
||||
@@ -53,24 +65,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import {
|
||||
finishBackgroundTask,
|
||||
isBackgroundTaskCancelRequested,
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import './AnalyticsPage.scss'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
|
||||
|
||||
interface ExcludeCandidate {
|
||||
username: string
|
||||
@@ -48,6 +55,13 @@ function AnalyticsPage() {
|
||||
|
||||
const loadData = useCallback(async (forceRefresh = false) => {
|
||||
if (isLoaded && !forceRefresh) return
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage: 'analytics',
|
||||
title: forceRefresh ? '刷新分析看板' : '加载分析看板',
|
||||
detail: '准备读取整体统计数据',
|
||||
progressText: '整体统计',
|
||||
cancelable: true
|
||||
})
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setProgress(0)
|
||||
@@ -60,27 +74,70 @@ function AnalyticsPage() {
|
||||
|
||||
try {
|
||||
setLoadingStatus('正在统计消息数据...')
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: '正在统计消息数据',
|
||||
progressText: '整体统计'
|
||||
})
|
||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '已停止后续加载,当前页面分析流程已结束'
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatistics(statsResult.data)
|
||||
} else {
|
||||
setError(statsResult.error || '加载统计数据失败')
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
detail: statsResult.error || '加载统计数据失败'
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
setLoadingStatus('正在分析联系人排名...')
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: '正在分析联系人排名',
|
||||
progressText: '联系人排名'
|
||||
})
|
||||
const rankingsResult = await window.electronAPI.analytics.getContactRankings(20)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '已停止后续加载,联系人排名后续步骤未继续'
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
if (rankingsResult.success && rankingsResult.data) {
|
||||
setRankings(rankingsResult.data)
|
||||
}
|
||||
setLoadingStatus('正在计算时间分布...')
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: '正在计算时间分布',
|
||||
progressText: '时间分布'
|
||||
})
|
||||
const timeResult = await window.electronAPI.analytics.getTimeDistribution()
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '已停止后续加载,时间分布结果未继续写入'
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
if (timeResult.success && timeResult.data) {
|
||||
setTimeDistribution(timeResult.data)
|
||||
}
|
||||
markLoaded()
|
||||
finishBackgroundTask(taskId, 'completed', {
|
||||
detail: '分析看板数据加载完成',
|
||||
progressText: '已完成'
|
||||
})
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
detail: String(e)
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (removeListener) removeListener()
|
||||
@@ -360,8 +417,28 @@ function AnalyticsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const renderPageShell = (content: ReactNode) => (
|
||||
<div className="analytics-page-shell">
|
||||
<ChatAnalysisHeader currentMode="private" />
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
||||
const analyticsHeaderActions = (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||
{isLoading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={openExcludeDialog}>
|
||||
<UserMinus size={16} />
|
||||
排除好友{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isLoading && !isLoaded) {
|
||||
return (
|
||||
return renderPageShell(
|
||||
<div className="loading-container">
|
||||
<Loader2 size={48} className="spin" />
|
||||
<p className="loading-status">{loadingStatus}</p>
|
||||
@@ -374,7 +451,7 @@ function AnalyticsPage() {
|
||||
}
|
||||
|
||||
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
|
||||
return (
|
||||
return renderPageShell(
|
||||
<div className="error-container">
|
||||
<p>{error}</p>
|
||||
<div className="error-actions">
|
||||
@@ -390,25 +467,18 @@ function AnalyticsPage() {
|
||||
}
|
||||
|
||||
if (error && !isLoaded) {
|
||||
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
||||
return renderPageShell(
|
||||
<div className="error-container">
|
||||
<p>{error}</p>
|
||||
<button className="btn btn-primary" onClick={() => loadData(true)}>重试</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>私聊分析</h1>
|
||||
<div className="header-actions">
|
||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||
{isLoading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={openExcludeDialog}>
|
||||
<UserMinus size={16} />
|
||||
排除好友{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="analytics-page-shell">
|
||||
<ChatAnalysisHeader currentMode="private" actions={analyticsHeaderActions} />
|
||||
<div className="page-scroll">
|
||||
<section className="page-section">
|
||||
<div className="stats-overview">
|
||||
@@ -556,7 +626,7 @@ function AnalyticsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
.analytics-entry-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.analytics-welcome-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 40px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
overflow-y: auto;
|
||||
|
||||
&.analytics-welcome-container--mode {
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(7, 193, 96, 0.06), transparent 48%),
|
||||
var(--bg-primary);
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
@@ -106,6 +123,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.analytics-welcome-container {
|
||||
padding: 28px 18px;
|
||||
|
||||
.welcome-content {
|
||||
.action-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -116,4 +145,4 @@
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { BarChart2, History, RefreshCcw } from 'lucide-react'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
|
||||
import './AnalyticsWelcomePage.scss'
|
||||
|
||||
function AnalyticsWelcomePage() {
|
||||
@@ -14,11 +15,11 @@ function AnalyticsWelcomePage() {
|
||||
const { lastLoadTime } = useAnalyticsStore()
|
||||
|
||||
const handleLoadCache = () => {
|
||||
navigate('/analytics/view')
|
||||
navigate('/analytics/private/view')
|
||||
}
|
||||
|
||||
const handleNewAnalysis = () => {
|
||||
navigate('/analytics/view', { state: { forceRefresh: true } })
|
||||
navigate('/analytics/private/view', { state: { forceRefresh: true } })
|
||||
}
|
||||
|
||||
const formatLastTime = (ts: number | null) => {
|
||||
@@ -27,33 +28,37 @@ function AnalyticsWelcomePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="analytics-welcome-container">
|
||||
<div className="welcome-content">
|
||||
<div className="icon-wrapper">
|
||||
<BarChart2 size={40} />
|
||||
</div>
|
||||
<h1>私聊数据分析</h1>
|
||||
<p>
|
||||
WeFlow 可以分析你的聊天记录,生成详细的统计报表。<br />
|
||||
你可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
|
||||
</p>
|
||||
<div className="analytics-entry-page">
|
||||
<ChatAnalysisHeader currentMode="private" />
|
||||
|
||||
<div className="action-cards">
|
||||
<button onClick={handleLoadCache}>
|
||||
<div className="card-icon">
|
||||
<History size={24} />
|
||||
</div>
|
||||
<h3>加载缓存</h3>
|
||||
<span>查看上次分析结果<br />(上次更新: {formatLastTime(lastLoadTime)})</span>
|
||||
</button>
|
||||
<div className="analytics-welcome-container analytics-welcome-container--mode">
|
||||
<div className="welcome-content">
|
||||
<div className="icon-wrapper">
|
||||
<BarChart2 size={40} />
|
||||
</div>
|
||||
<h1>私聊数据分析</h1>
|
||||
<p>
|
||||
WeFlow 可以分析你的好友聊天记录,生成详细的统计报表。<br />
|
||||
你可以选择加载上次的分析结果,或者重新开始一次新的私聊分析。
|
||||
</p>
|
||||
|
||||
<button onClick={handleNewAnalysis}>
|
||||
<div className="card-icon">
|
||||
<RefreshCcw size={24} />
|
||||
</div>
|
||||
<h3>新的分析</h3>
|
||||
<span>重新扫描并计算数据<br />(可能需要几分钟)</span>
|
||||
</button>
|
||||
<div className="action-cards">
|
||||
<button onClick={handleLoadCache}>
|
||||
<div className="card-icon">
|
||||
<History size={24} />
|
||||
</div>
|
||||
<h3>加载缓存</h3>
|
||||
<span>查看上次分析结果<br />(上次更新: {formatLastTime(lastLoadTime)})</span>
|
||||
</button>
|
||||
|
||||
<button onClick={handleNewAnalysis}>
|
||||
<div className="card-icon">
|
||||
<RefreshCcw size={24} />
|
||||
</div>
|
||||
<h3>新的分析</h3>
|
||||
<span>重新扫描并计算数据<br />(可能需要几分钟)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,48 @@
|
||||
margin: 0 0 48px;
|
||||
}
|
||||
|
||||
.page-desc.load-summary {
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
.page-desc.load-summary.complete {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.load-telemetry {
|
||||
width: min(760px, 100%);
|
||||
padding: 12px 14px;
|
||||
margin: 0 0 28px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
|
||||
p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.load-telemetry.loading {
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
}
|
||||
|
||||
.load-telemetry.complete {
|
||||
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
|
||||
}
|
||||
|
||||
.load-telemetry.compact {
|
||||
margin: 12px 0 0;
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.report-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -83,6 +125,14 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.year-grid-with-status {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.year-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -95,7 +145,39 @@
|
||||
.report-section .year-grid {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.year-grid-with-status .year-grid {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.year-load-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.year-load-status.complete {
|
||||
color: color-mix(in srgb, var(--primary) 80%, var(--text-secondary));
|
||||
}
|
||||
|
||||
.dot-ellipsis {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
vertical-align: bottom;
|
||||
animation: dot-ellipsis 1.2s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
.year-load-status.complete .dot-ellipsis,
|
||||
.page-desc.load-summary.complete .dot-ellipsis {
|
||||
animation: none;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.year-card {
|
||||
@@ -185,3 +267,7 @@
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes dot-ellipsis {
|
||||
to { width: 1.4em; }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||
import {
|
||||
finishBackgroundTask,
|
||||
isBackgroundTaskCancelRequested,
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import './AnnualReportPage.scss'
|
||||
|
||||
type YearOption = number | 'all'
|
||||
type YearsLoadPayload = {
|
||||
years?: number[]
|
||||
done: boolean
|
||||
error?: string
|
||||
canceled?: boolean
|
||||
strategy?: 'cache' | 'native' | 'hybrid'
|
||||
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||
statusText?: string
|
||||
nativeElapsedMs?: number
|
||||
scanElapsedMs?: number
|
||||
totalElapsedMs?: number
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}
|
||||
|
||||
const formatLoadElapsed = (ms: number) => {
|
||||
const totalSeconds = Math.max(0, ms) / 1000
|
||||
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
return `${minutes}m ${String(seconds).padStart(2, '0')}s`
|
||||
}
|
||||
|
||||
function AnnualReportPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -11,32 +39,152 @@ function AnnualReportPage() {
|
||||
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
|
||||
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false)
|
||||
const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false)
|
||||
const [loadStrategy, setLoadStrategy] = useState<'cache' | 'native' | 'hybrid'>('native')
|
||||
const [loadPhase, setLoadPhase] = useState<'cache' | 'native' | 'scan' | 'done'>('native')
|
||||
const [loadStatusText, setLoadStatusText] = useState('准备加载年份数据...')
|
||||
const [nativeElapsedMs, setNativeElapsedMs] = useState(0)
|
||||
const [scanElapsedMs, setScanElapsedMs] = useState(0)
|
||||
const [totalElapsedMs, setTotalElapsedMs] = useState(0)
|
||||
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
|
||||
const [nativeTimedOut, setNativeTimedOut] = useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadAvailableYears()
|
||||
}, [])
|
||||
let disposed = false
|
||||
let taskId = ''
|
||||
let uiTaskId = ''
|
||||
|
||||
const loadAvailableYears = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const result = await window.electronAPI.annualReport.getAvailableYears()
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
setAvailableYears(result.data)
|
||||
setSelectedYear((prev) => prev ?? result.data[0])
|
||||
setSelectedPairYear((prev) => prev ?? result.data[0])
|
||||
} else if (!result.success) {
|
||||
setLoadError(result.error || '加载年度数据失败')
|
||||
const applyLoadPayload = (payload: YearsLoadPayload) => {
|
||||
if (uiTaskId) {
|
||||
updateBackgroundTask(uiTaskId, {
|
||||
detail: payload.statusText || '正在加载可用年份',
|
||||
progressText: payload.done
|
||||
? '已完成'
|
||||
: `${Array.isArray(payload.years) ? payload.years.length : 0} 个年份`
|
||||
})
|
||||
}
|
||||
if (payload.strategy) setLoadStrategy(payload.strategy)
|
||||
if (payload.phase) setLoadPhase(payload.phase)
|
||||
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
|
||||
if (typeof payload.nativeElapsedMs === 'number' && Number.isFinite(payload.nativeElapsedMs)) {
|
||||
setNativeElapsedMs(Math.max(0, payload.nativeElapsedMs))
|
||||
}
|
||||
if (typeof payload.scanElapsedMs === 'number' && Number.isFinite(payload.scanElapsedMs)) {
|
||||
setScanElapsedMs(Math.max(0, payload.scanElapsedMs))
|
||||
}
|
||||
if (typeof payload.totalElapsedMs === 'number' && Number.isFinite(payload.totalElapsedMs)) {
|
||||
setTotalElapsedMs(Math.max(0, payload.totalElapsedMs))
|
||||
}
|
||||
if (typeof payload.switched === 'boolean') setHasSwitchedStrategy(payload.switched)
|
||||
if (typeof payload.nativeTimedOut === 'boolean') setNativeTimedOut(payload.nativeTimedOut)
|
||||
|
||||
const years = Array.isArray(payload.years) ? payload.years : []
|
||||
if (years.length > 0) {
|
||||
setAvailableYears(years)
|
||||
setSelectedYear((prev) => {
|
||||
if (prev === 'all') return prev
|
||||
if (typeof prev === 'number' && years.includes(prev)) return prev
|
||||
return years[0]
|
||||
})
|
||||
setSelectedPairYear((prev) => {
|
||||
if (prev === 'all') return prev
|
||||
if (typeof prev === 'number' && years.includes(prev)) return prev
|
||||
return years[0]
|
||||
})
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
if (payload.error && !payload.canceled) {
|
||||
setLoadError(payload.error || '加载年度数据失败')
|
||||
}
|
||||
|
||||
if (payload.done) {
|
||||
setIsLoading(false)
|
||||
setIsLoadingMoreYears(false)
|
||||
setHasYearsLoadFinished(true)
|
||||
setLoadPhase('done')
|
||||
if (uiTaskId) {
|
||||
finishBackgroundTask(uiTaskId, payload.canceled ? 'canceled' : 'completed', {
|
||||
detail: payload.canceled
|
||||
? '年度报告年份加载已停止'
|
||||
: `年度报告年份加载完成,共 ${years.length} 个年份`,
|
||||
progressText: payload.canceled ? '已停止' : `${years.length} 个年份`
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setIsLoadingMoreYears(true)
|
||||
setHasYearsLoadFinished(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setLoadError(String(e))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => {
|
||||
if (disposed) return
|
||||
if (taskId && payload.taskId !== taskId) return
|
||||
if (!taskId) taskId = payload.taskId
|
||||
applyLoadPayload(payload)
|
||||
})
|
||||
|
||||
const startLoad = async () => {
|
||||
uiTaskId = registerBackgroundTask({
|
||||
sourcePage: 'annualReport',
|
||||
title: '年度报告年份加载',
|
||||
detail: '准备使用原生快速模式加载年份',
|
||||
progressText: '初始化',
|
||||
cancelable: true,
|
||||
onCancel: async () => {
|
||||
if (taskId) {
|
||||
await window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId)
|
||||
}
|
||||
}
|
||||
})
|
||||
setIsLoading(true)
|
||||
setIsLoadingMoreYears(true)
|
||||
setHasYearsLoadFinished(false)
|
||||
setLoadStrategy('native')
|
||||
setLoadPhase('native')
|
||||
setLoadStatusText('准备使用原生快速模式加载年份...')
|
||||
setNativeElapsedMs(0)
|
||||
setScanElapsedMs(0)
|
||||
setTotalElapsedMs(0)
|
||||
setHasSwitchedStrategy(false)
|
||||
setNativeTimedOut(false)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
|
||||
if (!startResult.success || !startResult.taskId) {
|
||||
finishBackgroundTask(uiTaskId, 'failed', {
|
||||
detail: startResult.error || '加载年度数据失败'
|
||||
})
|
||||
setLoadError(startResult.error || '加载年度数据失败')
|
||||
setIsLoading(false)
|
||||
setIsLoadingMoreYears(false)
|
||||
return
|
||||
}
|
||||
taskId = startResult.taskId
|
||||
if (startResult.snapshot) {
|
||||
applyLoadPayload(startResult.snapshot)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
finishBackgroundTask(uiTaskId, 'failed', {
|
||||
detail: String(e)
|
||||
})
|
||||
setLoadError(String(e))
|
||||
setIsLoading(false)
|
||||
setIsLoadingMoreYears(false)
|
||||
}
|
||||
}
|
||||
|
||||
void startLoad()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
stopListen()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (selectedYear === null) return
|
||||
@@ -57,16 +205,16 @@ function AnnualReportPage() {
|
||||
navigate(`/dual-report?year=${yearParam}`)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading && availableYears.length === 0) {
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在加载年份数据...</p>
|
||||
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在准备年度报告...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (availableYears.length === 0) {
|
||||
if (availableYears.length === 0 && !isLoadingMoreYears) {
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
|
||||
@@ -87,6 +235,21 @@ function AnnualReportPage() {
|
||||
return value === 'all' ? '全部时间' : `${value} 年`
|
||||
}
|
||||
|
||||
const loadedYearCount = availableYears.length
|
||||
const isYearStatusComplete = hasYearsLoadFinished
|
||||
const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })
|
||||
const renderYearLoadStatus = () => (
|
||||
<div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||
{isYearStatusComplete ? (
|
||||
<>全部年份已加载完毕</>
|
||||
) : (
|
||||
<>
|
||||
更多年份加载中<span className="dot-ellipsis" aria-hidden="true">...</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Sparkles size={32} className="header-icon" />
|
||||
@@ -102,17 +265,19 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="year-grid">
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={option}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(option)}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="year-grid-with-status">
|
||||
<div className="year-grid">
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={option}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(option)}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -146,17 +311,19 @@ function AnnualReportPage() {
|
||||
</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 className="year-grid-with-status">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -174,4 +341,23 @@ function AnnualReportPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function getStrategyLabel(params: {
|
||||
loadStrategy: 'cache' | 'native' | 'hybrid'
|
||||
loadPhase: 'cache' | 'native' | 'scan' | 'done'
|
||||
hasYearsLoadFinished: boolean
|
||||
hasSwitchedStrategy: boolean
|
||||
nativeTimedOut: boolean
|
||||
}): string {
|
||||
const { loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut } = params
|
||||
if (loadStrategy === 'cache') return '缓存模式(快速)'
|
||||
if (hasYearsLoadFinished) {
|
||||
if (loadStrategy === 'native') return '原生快速模式'
|
||||
if (hasSwitchedStrategy || nativeTimedOut) return '混合策略(原生→扫表)'
|
||||
return '扫表兼容模式'
|
||||
}
|
||||
if (loadPhase === 'native') return '原生快速模式(优先)'
|
||||
if (loadPhase === 'scan') return '扫表兼容模式(回退)'
|
||||
return '混合策略'
|
||||
}
|
||||
|
||||
export default AnnualReportPage
|
||||
|
||||
@@ -2,6 +2,12 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import {
|
||||
finishBackgroundTask,
|
||||
isBackgroundTaskCancelRequested,
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import './AnnualReportWindow.scss'
|
||||
|
||||
// SVG 背景图案 (用于导出)
|
||||
@@ -127,12 +133,6 @@ function AnnualReportWindow() {
|
||||
|
||||
const { currentTheme, themeMode } = useThemeStore()
|
||||
|
||||
// 应用主题到独立窗口
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||
document.documentElement.setAttribute('data-mode', themeMode)
|
||||
}, [currentTheme, themeMode])
|
||||
|
||||
// Section refs
|
||||
const sectionRefs = {
|
||||
cover: useRef<HTMLElement>(null),
|
||||
@@ -164,6 +164,13 @@ function AnnualReportWindow() {
|
||||
}, [])
|
||||
|
||||
const generateReport = async (year: number) => {
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage: 'annualReport',
|
||||
title: '年度报告生成',
|
||||
detail: `正在生成 ${formatYearLabel(year)} 年度报告`,
|
||||
progressText: '初始化',
|
||||
cancelable: true
|
||||
})
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setLoadingProgress(0)
|
||||
@@ -171,25 +178,46 @@ function AnnualReportWindow() {
|
||||
const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => {
|
||||
setLoadingProgress(payload.progress)
|
||||
setLoadingStage(payload.status)
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: payload.status || '正在生成年度报告',
|
||||
progressText: `${Math.max(0, Math.round(payload.progress || 0))}%`
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.annualReport.generateReport(year)
|
||||
removeProgressListener?.()
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '已停止后续加载,当前报告结果未继续写入页面'
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
setLoadingProgress(100)
|
||||
setLoadingStage('完成')
|
||||
|
||||
if (result.success && result.data) {
|
||||
finishBackgroundTask(taskId, 'completed', {
|
||||
detail: '年度报告生成完成',
|
||||
progressText: '100%'
|
||||
})
|
||||
setTimeout(() => {
|
||||
setReportData(result.data!)
|
||||
setIsLoading(false)
|
||||
}, 300)
|
||||
} else {
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
detail: result.error || '生成年度报告失败'
|
||||
})
|
||||
setError(result.error || '生成报告失败')
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (e) {
|
||||
removeProgressListener?.()
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
detail: String(e)
|
||||
})
|
||||
setError(String(e))
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
123
src/pages/ChatAnalyticsHubPage.scss
Normal file
123
src/pages/ChatAnalyticsHubPage.scss
Normal file
@@ -0,0 +1,123 @@
|
||||
.chat-analytics-hub-page {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.chat-analytics-hub-content {
|
||||
width: min(860px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-analytics-hub-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-analytics-hub-content h1 {
|
||||
margin: 20px 0 12px;
|
||||
font-size: 32px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chat-analytics-hub-desc {
|
||||
max-width: 620px;
|
||||
margin: 0 0 32px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.chat-analytics-hub-grid {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.chat-analytics-entry-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
gap: 14px;
|
||||
min-height: 260px;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(7, 193, 96, 0.08) 0%, rgba(7, 193, 96, 0.02) 100%),
|
||||
var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(7, 193, 96, 0.35);
|
||||
box-shadow: 0 20px 36px rgba(7, 193, 96, 0.12);
|
||||
}
|
||||
|
||||
.entry-card-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(7, 193, 96, 0.12);
|
||||
color: #07c160;
|
||||
|
||||
&.group {
|
||||
background: rgba(24, 119, 242, 0.12);
|
||||
color: #1877f2;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-card-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.entry-card-cta {
|
||||
margin-top: auto;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chat-analytics-hub-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
59
src/pages/ChatAnalyticsHubPage.tsx
Normal file
59
src/pages/ChatAnalyticsHubPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ArrowRight, BarChart3, MessageSquare, Users } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import './ChatAnalyticsHubPage.scss'
|
||||
|
||||
function ChatAnalyticsHubPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="chat-analytics-hub-page">
|
||||
<div className="chat-analytics-hub-content">
|
||||
<div className="chat-analytics-hub-badge">
|
||||
<BarChart3 size={16} />
|
||||
<span>聊天分析</span>
|
||||
</div>
|
||||
|
||||
<h1>选择你要进入的分析视角</h1>
|
||||
<p className="chat-analytics-hub-desc">
|
||||
私聊分析更适合看好友聊天统计和趋势,群聊分析则用于查看群成员、发言排行和活跃时段。
|
||||
</p>
|
||||
|
||||
<div className="chat-analytics-hub-grid">
|
||||
<button
|
||||
type="button"
|
||||
className="chat-analytics-entry-card"
|
||||
onClick={() => navigate('/analytics/private')}
|
||||
>
|
||||
<div className="entry-card-icon">
|
||||
<MessageSquare size={24} />
|
||||
</div>
|
||||
<div className="entry-card-header">
|
||||
<h2>私聊分析</h2>
|
||||
<ArrowRight size={18} />
|
||||
</div>
|
||||
<p>查看好友聊天统计、消息趋势、活跃时段与联系人排名。</p>
|
||||
<span className="entry-card-cta">进入私聊分析</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="chat-analytics-entry-card"
|
||||
onClick={() => navigate('/analytics/group')}
|
||||
>
|
||||
<div className="entry-card-icon group">
|
||||
<Users size={24} />
|
||||
</div>
|
||||
<div className="entry-card-header">
|
||||
<h2>群聊分析</h2>
|
||||
<ArrowRight size={18} />
|
||||
</div>
|
||||
<p>查看群成员信息、发言排行、活跃时段和媒体内容统计。</p>
|
||||
<span className="entry-card-cta">进入群聊分析</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatAnalyticsHubPage
|
||||
@@ -33,6 +33,16 @@
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
|
||||
&.error-item {
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useParams, useLocation } from 'react-router-dom'
|
||||
import { ChatRecordItem } from '../types/models'
|
||||
import TitleBar from '../components/TitleBar'
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary'
|
||||
import './ChatHistoryPage.scss'
|
||||
|
||||
export default function ChatHistoryPage() {
|
||||
@@ -166,7 +167,9 @@ export default function ChatHistoryPage() {
|
||||
<div className="status-msg empty">暂无可显示的聊天记录</div>
|
||||
) : (
|
||||
recordList.map((item, i) => (
|
||||
<HistoryItem key={i} item={item} />
|
||||
<ErrorBoundary key={i} fallback={<div className="history-item error-item">消息解析失败</div>}>
|
||||
<HistoryItem item={item} />
|
||||
</ErrorBoundary>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -175,6 +178,8 @@ export default function ChatHistoryPage() {
|
||||
}
|
||||
|
||||
function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
// sourcetime 在合并转发里有两种格式:
|
||||
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
||||
let time = ''
|
||||
@@ -197,19 +202,16 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
{imageError ? (
|
||||
<div className="media-tip">图片无法加载</div>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt="图片"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -148,6 +148,17 @@
|
||||
svg {
|
||||
opacity: 0.7;
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -177,6 +188,22 @@
|
||||
padding: 0 20px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.contacts-cache-meta {
|
||||
margin-left: 10px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
|
||||
&.syncing {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-enrich-progress {
|
||||
margin-left: 10px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-toolbar {
|
||||
@@ -213,10 +240,103 @@
|
||||
}
|
||||
}
|
||||
|
||||
.load-issue-state {
|
||||
flex: 1;
|
||||
padding: 14px 14px 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.issue-card {
|
||||
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg));
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
color: var(--text-primary);
|
||||
|
||||
.issue-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary));
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.issue-message {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.issue-reason {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.issue-hints {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.issue-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.issue-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-tertiary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.issue-diagnostics {
|
||||
margin-top: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px dashed var(--border-color);
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 12px 12px;
|
||||
position: relative;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -229,15 +349,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-list-virtual {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.contact-row {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 76px;
|
||||
padding-bottom: 4px;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
height: 72px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
@@ -399,6 +535,28 @@
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.detail-entry-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goto-chat-btn {
|
||||
|
||||
@@ -1,24 +1,51 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } 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 { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList, Aperture } from 'lucide-react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||
import * as configService from '../services/config'
|
||||
import type { ContactInfo } from '../types/models'
|
||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
||||
import './ContactsPage.scss'
|
||||
|
||||
interface ContactInfo {
|
||||
username: string
|
||||
displayName: string
|
||||
remark?: string
|
||||
nickname?: string
|
||||
interface ContactEnrichInfo {
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
|
||||
const AVATAR_ENRICH_BATCH_SIZE = 80
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
const VIRTUAL_ROW_HEIGHT = 76
|
||||
const VIRTUAL_OVERSCAN = 10
|
||||
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
|
||||
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
interface ContactsLoadSession {
|
||||
requestId: string
|
||||
startedAt: number
|
||||
attempt: number
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
interface ContactsLoadIssue {
|
||||
kind: 'timeout' | 'error'
|
||||
title: string
|
||||
message: string
|
||||
reason: string
|
||||
errorDetail?: string
|
||||
occurredAt: number
|
||||
elapsedMs: number
|
||||
}
|
||||
|
||||
type ContactsDataSource = 'cache' | 'network' | null
|
||||
|
||||
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 [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('')
|
||||
const [contactTypes, setContactTypes] = useState({
|
||||
friends: true,
|
||||
groups: false,
|
||||
@@ -29,6 +56,9 @@ function ContactsPage() {
|
||||
// 导出模式与查看详情
|
||||
const [exportMode, setExportMode] = useState(false)
|
||||
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
|
||||
const [snsUserPostCounts, setSnsUserPostCounts] = useState<Record<string, number>>({})
|
||||
const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
const [snsTimelineTarget, setSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const { setCurrentSession } = useChatStore()
|
||||
|
||||
@@ -39,79 +69,530 @@ function ContactsPage() {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [showFormatSelect, setShowFormatSelect] = useState(false)
|
||||
const formatDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const loadVersionRef = useRef(0)
|
||||
const [avatarEnrichProgress, setAvatarEnrichProgress] = useState({
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
running: false
|
||||
})
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const [listViewportHeight, setListViewportHeight] = useState(480)
|
||||
const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts)
|
||||
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
|
||||
const loadAttemptRef = useRef(0)
|
||||
const loadTimeoutTimerRef = useRef<number | null>(null)
|
||||
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
|
||||
const [loadSession, setLoadSession] = useState<ContactsLoadSession | null>(null)
|
||||
const [loadIssue, setLoadIssue] = useState<ContactsLoadIssue | null>(null)
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false)
|
||||
const [diagnosticTick, setDiagnosticTick] = useState(Date.now())
|
||||
const [contactsDataSource, setContactsDataSource] = useState<ContactsDataSource>(null)
|
||||
const [contactsUpdatedAt, setContactsUpdatedAt] = useState<number | null>(null)
|
||||
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
|
||||
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
|
||||
const contactsCacheScopeRef = useRef('default')
|
||||
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
|
||||
|
||||
// 加载通讯录
|
||||
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) {
|
||||
|
||||
|
||||
const ensureContactsCacheScope = useCallback(async () => {
|
||||
if (contactsCacheScopeRef.current !== 'default') {
|
||||
return contactsCacheScopeRef.current
|
||||
}
|
||||
const [dbPath, myWxid] = await Promise.all([
|
||||
configService.getDbPath(),
|
||||
configService.getMyWxid()
|
||||
])
|
||||
const scopeKey = dbPath || myWxid
|
||||
? `${dbPath || ''}::${myWxid || ''}`
|
||||
: 'default'
|
||||
contactsCacheScopeRef.current = scopeKey
|
||||
return scopeKey
|
||||
}, [])
|
||||
|
||||
// 获取头像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
|
||||
}
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await configService.getContactsLoadTimeoutMs()
|
||||
if (!cancelled) {
|
||||
setContactsLoadTimeoutMs(value)
|
||||
}
|
||||
|
||||
setContacts(contactsResult.contacts)
|
||||
setFilteredContacts(contactsResult.contacts)
|
||||
setSelectedUsernames(new Set())
|
||||
} catch (error) {
|
||||
console.error('读取通讯录超时配置失败:', error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载通讯录失败:', e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadContacts()
|
||||
}, [loadContacts])
|
||||
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
|
||||
}, [contactsLoadTimeoutMs])
|
||||
|
||||
const mergeAvatarCacheIntoContacts = useCallback((sourceContacts: ContactInfo[]): ContactInfo[] => {
|
||||
const avatarCache = contactsAvatarCacheRef.current
|
||||
if (!sourceContacts.length || Object.keys(avatarCache).length === 0) {
|
||||
return sourceContacts
|
||||
}
|
||||
let changed = false
|
||||
const merged = sourceContacts.map((contact) => {
|
||||
const cachedAvatar = avatarCache[contact.username]?.avatarUrl
|
||||
if (!cachedAvatar || contact.avatarUrl) {
|
||||
return contact
|
||||
}
|
||||
changed = true
|
||||
return {
|
||||
...contact,
|
||||
avatarUrl: cachedAvatar
|
||||
}
|
||||
})
|
||||
return changed ? merged : sourceContacts
|
||||
}, [])
|
||||
|
||||
const upsertAvatarCacheFromContacts = useCallback((
|
||||
scopeKey: string,
|
||||
sourceContacts: ContactInfo[],
|
||||
options?: { prune?: boolean; markCheckedUsernames?: string[] }
|
||||
) => {
|
||||
if (!scopeKey) return
|
||||
const nextCache = { ...contactsAvatarCacheRef.current }
|
||||
const now = Date.now()
|
||||
const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean))
|
||||
const usernamesInSource = new Set<string>()
|
||||
let changed = false
|
||||
|
||||
for (const contact of sourceContacts) {
|
||||
const username = String(contact.username || '').trim()
|
||||
if (!username) continue
|
||||
usernamesInSource.add(username)
|
||||
const prev = nextCache[username]
|
||||
const avatarUrl = String(contact.avatarUrl || '').trim()
|
||||
if (!avatarUrl) continue
|
||||
const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt
|
||||
const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now)
|
||||
if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) {
|
||||
nextCache[username] = {
|
||||
avatarUrl,
|
||||
updatedAt,
|
||||
checkedAt
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
for (const username of markCheckedSet) {
|
||||
const prev = nextCache[username]
|
||||
if (!prev) continue
|
||||
if (prev.checkedAt !== now) {
|
||||
nextCache[username] = {
|
||||
...prev,
|
||||
checkedAt: now
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.prune) {
|
||||
for (const username of Object.keys(nextCache)) {
|
||||
if (usernamesInSource.has(username)) continue
|
||||
delete nextCache[username]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return
|
||||
contactsAvatarCacheRef.current = nextCache
|
||||
setAvatarCacheUpdatedAt(now)
|
||||
void configService.setContactsAvatarCache(scopeKey, nextCache).catch((error) => {
|
||||
console.error('写入通讯录头像缓存失败:', error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const applyEnrichedContacts = useCallback((enrichedMap: Record<string, ContactEnrichInfo>) => {
|
||||
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
|
||||
|
||||
setContacts(prev => {
|
||||
let changed = false
|
||||
const next = prev.map(contact => {
|
||||
const enriched = enrichedMap[contact.username]
|
||||
if (!enriched) return contact
|
||||
const displayName = enriched.displayName || contact.displayName
|
||||
const avatarUrl = enriched.avatarUrl || contact.avatarUrl
|
||||
if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) {
|
||||
return contact
|
||||
}
|
||||
changed = true
|
||||
return {
|
||||
...contact,
|
||||
displayName,
|
||||
avatarUrl
|
||||
}
|
||||
})
|
||||
return changed ? next : prev
|
||||
})
|
||||
|
||||
setSelectedContact(prev => {
|
||||
if (!prev) return prev
|
||||
const enriched = enrichedMap[prev.username]
|
||||
if (!enriched) return prev
|
||||
const displayName = enriched.displayName || prev.displayName
|
||||
const avatarUrl = enriched.avatarUrl || prev.avatarUrl
|
||||
if (displayName === prev.displayName && avatarUrl === prev.avatarUrl) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
displayName,
|
||||
avatarUrl
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const enrichContactsInBackground = useCallback(async (
|
||||
sourceContacts: ContactInfo[],
|
||||
loadVersion: number,
|
||||
scopeKey: string
|
||||
) => {
|
||||
const sourceByUsername = new Map<string, ContactInfo>()
|
||||
for (const contact of sourceContacts) {
|
||||
if (!contact.username) continue
|
||||
sourceByUsername.set(contact.username, contact)
|
||||
}
|
||||
const now = Date.now()
|
||||
const usernames = sourceContacts
|
||||
.map(contact => contact.username)
|
||||
.filter(Boolean)
|
||||
.filter((username) => {
|
||||
const currentContact = sourceByUsername.get(username)
|
||||
if (!currentContact) return false
|
||||
const cacheEntry = contactsAvatarCacheRef.current[username]
|
||||
if (!cacheEntry || !cacheEntry.avatarUrl) {
|
||||
return !currentContact.avatarUrl
|
||||
}
|
||||
if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) {
|
||||
return true
|
||||
}
|
||||
const checkedAt = cacheEntry.checkedAt || 0
|
||||
return now - checkedAt >= AVATAR_RECHECK_INTERVAL_MS
|
||||
})
|
||||
|
||||
const total = usernames.length
|
||||
setAvatarEnrichProgress({
|
||||
loaded: 0,
|
||||
total,
|
||||
running: total > 0
|
||||
})
|
||||
if (total === 0) return
|
||||
|
||||
for (let i = 0; i < total; i += AVATAR_ENRICH_BATCH_SIZE) {
|
||||
if (loadVersionRef.current !== loadVersion) return
|
||||
const batch = usernames.slice(i, i + AVATAR_ENRICH_BATCH_SIZE)
|
||||
if (batch.length === 0) continue
|
||||
|
||||
try {
|
||||
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch)
|
||||
if (loadVersionRef.current !== loadVersion) return
|
||||
if (avatarResult.success && avatarResult.contacts) {
|
||||
applyEnrichedContacts(avatarResult.contacts)
|
||||
for (const [username, enriched] of Object.entries(avatarResult.contacts)) {
|
||||
const prev = sourceByUsername.get(username)
|
||||
if (!prev) continue
|
||||
sourceByUsername.set(username, {
|
||||
...prev,
|
||||
displayName: enriched.displayName || prev.displayName,
|
||||
avatarUrl: enriched.avatarUrl || prev.avatarUrl
|
||||
})
|
||||
}
|
||||
}
|
||||
const batchContacts = batch
|
||||
.map(username => sourceByUsername.get(username))
|
||||
.filter((contact): contact is ContactInfo => Boolean(contact))
|
||||
upsertAvatarCacheFromContacts(scopeKey, batchContacts, {
|
||||
markCheckedUsernames: batch
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('分批补全头像失败:', e)
|
||||
}
|
||||
|
||||
const loaded = Math.min(i + batch.length, total)
|
||||
setAvatarEnrichProgress({
|
||||
loaded,
|
||||
total,
|
||||
running: loaded < total
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
}, [applyEnrichedContacts, upsertAvatarCacheFromContacts])
|
||||
|
||||
// 加载通讯录
|
||||
const loadContacts = useCallback(async (options?: { scopeKey?: string }) => {
|
||||
const scopeKey = options?.scopeKey || await ensureContactsCacheScope()
|
||||
const loadVersion = loadVersionRef.current + 1
|
||||
loadVersionRef.current = loadVersion
|
||||
loadAttemptRef.current += 1
|
||||
const startedAt = Date.now()
|
||||
const timeoutMs = contactsLoadTimeoutMsRef.current
|
||||
const requestId = `contacts-${startedAt}-${loadAttemptRef.current}`
|
||||
setLoadSession({
|
||||
requestId,
|
||||
startedAt,
|
||||
attempt: loadAttemptRef.current,
|
||||
timeoutMs
|
||||
})
|
||||
setLoadIssue(null)
|
||||
setShowDiagnostics(false)
|
||||
if (loadTimeoutTimerRef.current) {
|
||||
window.clearTimeout(loadTimeoutTimerRef.current)
|
||||
loadTimeoutTimerRef.current = null
|
||||
}
|
||||
const timeoutTimerId = window.setTimeout(() => {
|
||||
if (loadVersionRef.current !== loadVersion) return
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
setLoadIssue({
|
||||
kind: 'timeout',
|
||||
title: '通讯录加载超时',
|
||||
message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`,
|
||||
reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。',
|
||||
occurredAt: Date.now(),
|
||||
elapsedMs
|
||||
})
|
||||
}, timeoutMs)
|
||||
loadTimeoutTimerRef.current = timeoutTimerId
|
||||
|
||||
setIsLoading(true)
|
||||
setAvatarEnrichProgress({
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
running: false
|
||||
})
|
||||
try {
|
||||
const contactsResult = await window.electronAPI.chat.getContacts()
|
||||
|
||||
if (loadVersionRef.current !== loadVersion) return
|
||||
if (contactsResult.success && contactsResult.contacts) {
|
||||
if (loadTimeoutTimerRef.current === timeoutTimerId) {
|
||||
window.clearTimeout(loadTimeoutTimerRef.current)
|
||||
loadTimeoutTimerRef.current = null
|
||||
}
|
||||
const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(contactsResult.contacts)
|
||||
setContacts(contactsWithAvatarCache)
|
||||
syncContactTypeCounts(contactsWithAvatarCache)
|
||||
setSelectedUsernames(new Set())
|
||||
setSelectedContact(prev => {
|
||||
if (!prev) return prev
|
||||
return contactsWithAvatarCache.find(contact => contact.username === prev.username) || null
|
||||
})
|
||||
const now = Date.now()
|
||||
setContactsDataSource('network')
|
||||
setContactsUpdatedAt(now)
|
||||
setLoadIssue(null)
|
||||
setIsLoading(false)
|
||||
upsertAvatarCacheFromContacts(scopeKey, contactsWithAvatarCache, { prune: true })
|
||||
void configService.setContactsListCache(
|
||||
scopeKey,
|
||||
contactsWithAvatarCache.map(contact => ({
|
||||
username: contact.username,
|
||||
displayName: contact.displayName,
|
||||
remark: contact.remark,
|
||||
nickname: contact.nickname,
|
||||
type: contact.type
|
||||
}))
|
||||
).catch((error) => {
|
||||
console.error('写入通讯录缓存失败:', error)
|
||||
})
|
||||
void enrichContactsInBackground(contactsWithAvatarCache, loadVersion, scopeKey)
|
||||
return
|
||||
}
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
setLoadIssue({
|
||||
kind: 'error',
|
||||
title: '通讯录加载失败',
|
||||
message: '联系人接口返回失败,未拿到联系人列表。',
|
||||
reason: 'chat.getContacts 返回 success=false。',
|
||||
errorDetail: contactsResult.error || '未知错误',
|
||||
occurredAt: Date.now(),
|
||||
elapsedMs
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('加载通讯录失败:', e)
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
setLoadIssue({
|
||||
kind: 'error',
|
||||
title: '通讯录加载失败',
|
||||
message: '联系人请求执行异常。',
|
||||
reason: '调用 chat.getContacts 发生异常。',
|
||||
errorDetail: String(e),
|
||||
occurredAt: Date.now(),
|
||||
elapsedMs
|
||||
})
|
||||
} finally {
|
||||
if (loadTimeoutTimerRef.current === timeoutTimerId) {
|
||||
window.clearTimeout(loadTimeoutTimerRef.current)
|
||||
loadTimeoutTimerRef.current = null
|
||||
}
|
||||
if (loadVersionRef.current === loadVersion) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
ensureContactsCacheScope,
|
||||
enrichContactsInBackground,
|
||||
mergeAvatarCacheIntoContacts,
|
||||
syncContactTypeCounts,
|
||||
upsertAvatarCacheFromContacts
|
||||
])
|
||||
|
||||
// 搜索和类型过滤
|
||||
useEffect(() => {
|
||||
let filtered = contacts
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const scopeKey = await ensureContactsCacheScope()
|
||||
if (cancelled) return
|
||||
try {
|
||||
const [cacheItem, avatarCacheItem] = await Promise.all([
|
||||
configService.getContactsListCache(scopeKey),
|
||||
configService.getContactsAvatarCache(scopeKey)
|
||||
])
|
||||
const avatarCacheMap = avatarCacheItem?.avatars || {}
|
||||
contactsAvatarCacheRef.current = avatarCacheMap
|
||||
setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null)
|
||||
if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) {
|
||||
const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({
|
||||
...contact,
|
||||
avatarUrl: avatarCacheMap[contact.username]?.avatarUrl
|
||||
}))
|
||||
setContacts(cachedContacts)
|
||||
syncContactTypeCounts(cachedContacts)
|
||||
setContactsDataSource('cache')
|
||||
setContactsUpdatedAt(cacheItem.updatedAt || null)
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('读取通讯录缓存失败:', error)
|
||||
}
|
||||
if (!cancelled) {
|
||||
void loadContacts({ scopeKey })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [ensureContactsCacheScope, loadContacts, syncContactTypeCounts])
|
||||
|
||||
// 类型过滤
|
||||
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
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (loadTimeoutTimerRef.current) {
|
||||
window.clearTimeout(loadTimeoutTimerRef.current)
|
||||
loadTimeoutTimerRef.current = null
|
||||
}
|
||||
loadVersionRef.current += 1
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadIssue || contacts.length > 0) return
|
||||
if (!(isLoading && loadIssue.kind === 'timeout')) return
|
||||
const timer = window.setInterval(() => {
|
||||
setDiagnosticTick(Date.now())
|
||||
}, 500)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [contacts.length, isLoading, loadIssue])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase())
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [searchKeyword])
|
||||
|
||||
const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => {
|
||||
if (!options?.force && (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'ready')) {
|
||||
return
|
||||
}
|
||||
|
||||
setSnsUserPostCountsStatus('loading')
|
||||
try {
|
||||
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||
if (!result.success || !result.counts) {
|
||||
setSnsUserPostCountsStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedCounts: Record<string, number> = {}
|
||||
for (const [rawUsername, rawCount] of Object.entries(result.counts)) {
|
||||
const username = String(rawUsername || '').trim()
|
||||
if (!username) continue
|
||||
const value = Number(rawCount)
|
||||
normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
|
||||
}
|
||||
|
||||
setSnsUserPostCounts(normalizedCounts)
|
||||
setSnsUserPostCountsStatus('ready')
|
||||
} catch (error) {
|
||||
console.error('加载通讯录联系人朋友圈条数失败:', error)
|
||||
setSnsUserPostCountsStatus('error')
|
||||
}
|
||||
}, [snsUserPostCountsStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedContact || !isSingleContactSession(selectedContact.username)) return
|
||||
if (snsUserPostCountsStatus !== 'idle') return
|
||||
void loadSnsUserPostCounts()
|
||||
}, [loadSnsUserPostCounts, selectedContact, snsUserPostCountsStatus])
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
let filtered = contacts.filter(contact => {
|
||||
if (contact.type === 'friend' && !contactTypes.friends) return false
|
||||
if (contact.type === 'group' && !contactTypes.groups) return false
|
||||
if (contact.type === 'official' && !contactTypes.officials) return false
|
||||
if (contact.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)
|
||||
if (debouncedSearchKeyword) {
|
||||
filtered = filtered.filter(contact =>
|
||||
contact.displayName?.toLowerCase().includes(debouncedSearchKeyword) ||
|
||||
contact.remark?.toLowerCase().includes(debouncedSearchKeyword) ||
|
||||
contact.username.toLowerCase().includes(debouncedSearchKeyword)
|
||||
)
|
||||
}
|
||||
|
||||
setFilteredContacts(filtered)
|
||||
}, [searchKeyword, contacts, contactTypes])
|
||||
return filtered
|
||||
}, [contacts, contactTypes, debouncedSearchKeyword])
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
const contactTypeCounts = useMemo(() => toContactTypeCardCounts(sharedTabCounts), [sharedTabCounts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
listRef.current.scrollTop = 0
|
||||
setScrollTop(0)
|
||||
}, [debouncedSearchKeyword, contactTypes])
|
||||
|
||||
useEffect(() => {
|
||||
const node = listRef.current
|
||||
if (!node) return
|
||||
|
||||
const updateViewportHeight = () => {
|
||||
setListViewportHeight(Math.max(node.clientHeight, VIRTUAL_ROW_HEIGHT))
|
||||
}
|
||||
updateViewportHeight()
|
||||
|
||||
const observer = new ResizeObserver(() => updateViewportHeight())
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [filteredContacts.length, isLoading])
|
||||
|
||||
useEffect(() => {
|
||||
const maxScroll = Math.max(0, filteredContacts.length * VIRTUAL_ROW_HEIGHT - listViewportHeight)
|
||||
if (scrollTop <= maxScroll) return
|
||||
setScrollTop(maxScroll)
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = maxScroll
|
||||
}
|
||||
}, [filteredContacts.length, listViewportHeight, scrollTop])
|
||||
|
||||
// 搜索和类型过滤
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
@@ -123,11 +604,117 @@ function ContactsPage() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showFormatSelect])
|
||||
|
||||
const selectedInFilteredCount = filteredContacts.reduce((count, contact) => {
|
||||
return selectedUsernames.has(contact.username) ? count + 1 : count
|
||||
}, 0)
|
||||
const selectedInFilteredCount = useMemo(() => {
|
||||
return filteredContacts.reduce((count, contact) => {
|
||||
return selectedUsernames.has(contact.username) ? count + 1 : count
|
||||
}, 0)
|
||||
}, [filteredContacts, selectedUsernames])
|
||||
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
|
||||
|
||||
const selectedContactSupportsSns = useMemo(() => {
|
||||
return Boolean(selectedContact && isSingleContactSession(selectedContact.username))
|
||||
}, [selectedContact])
|
||||
|
||||
const selectedContactSnsCount = useMemo(() => {
|
||||
if (!selectedContactSupportsSns || !selectedContact) return null
|
||||
if (snsUserPostCountsStatus !== 'ready') return null
|
||||
const rawCount = Number(snsUserPostCounts[selectedContact.username] || 0)
|
||||
return Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
|
||||
}, [selectedContact, selectedContactSupportsSns, snsUserPostCounts, snsUserPostCountsStatus])
|
||||
|
||||
const selectedContactSnsEntryLabel = useMemo(() => {
|
||||
if (!selectedContactSupportsSns) return ''
|
||||
if (selectedContactSnsCount !== null) {
|
||||
return `朋友圈:${selectedContactSnsCount.toLocaleString('zh-CN')}条`
|
||||
}
|
||||
if (snsUserPostCountsStatus === 'error') return '朋友圈:查看'
|
||||
return '朋友圈:统计中...'
|
||||
}, [selectedContactSupportsSns, selectedContactSnsCount, snsUserPostCountsStatus])
|
||||
|
||||
const openSelectedContactSnsTimeline = useCallback(() => {
|
||||
if (!selectedContact || !selectedContactSupportsSns) return
|
||||
if (snsUserPostCountsStatus === 'idle') {
|
||||
void loadSnsUserPostCounts()
|
||||
}
|
||||
setSnsTimelineTarget({
|
||||
username: selectedContact.username,
|
||||
displayName: selectedContact.displayName || selectedContact.remark || selectedContact.nickname || selectedContact.username,
|
||||
avatarUrl: selectedContact.avatarUrl
|
||||
})
|
||||
}, [loadSnsUserPostCounts, selectedContact, selectedContactSupportsSns, snsUserPostCountsStatus])
|
||||
|
||||
const { startIndex, endIndex } = useMemo(() => {
|
||||
if (filteredContacts.length === 0) {
|
||||
return { startIndex: 0, endIndex: 0 }
|
||||
}
|
||||
const baseStart = Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT)
|
||||
const visibleCount = Math.ceil(listViewportHeight / VIRTUAL_ROW_HEIGHT)
|
||||
const nextStart = Math.max(0, baseStart - VIRTUAL_OVERSCAN)
|
||||
const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + VIRTUAL_OVERSCAN * 2)
|
||||
return {
|
||||
startIndex: nextStart,
|
||||
endIndex: nextEnd
|
||||
}
|
||||
}, [filteredContacts.length, listViewportHeight, scrollTop])
|
||||
|
||||
const visibleContacts = useMemo(() => {
|
||||
return filteredContacts.slice(startIndex, endIndex)
|
||||
}, [filteredContacts, startIndex, endIndex])
|
||||
|
||||
const onContactsListScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||
setScrollTop(event.currentTarget.scrollTop)
|
||||
}, [])
|
||||
|
||||
const issueElapsedMs = useMemo(() => {
|
||||
if (!loadIssue) return 0
|
||||
if (isLoading && loadSession) {
|
||||
return Math.max(loadIssue.elapsedMs, diagnosticTick - loadSession.startedAt)
|
||||
}
|
||||
return loadIssue.elapsedMs
|
||||
}, [diagnosticTick, isLoading, loadIssue, loadSession])
|
||||
|
||||
const diagnosticsText = useMemo(() => {
|
||||
if (!loadIssue || !loadSession) return ''
|
||||
return [
|
||||
`请求ID: ${loadSession.requestId}`,
|
||||
`请求序号: 第 ${loadSession.attempt} 次`,
|
||||
`阈值配置: ${loadSession.timeoutMs}ms`,
|
||||
`当前状态: ${loadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`,
|
||||
`累计耗时: ${(issueElapsedMs / 1000).toFixed(1)}s`,
|
||||
`发生时间: ${new Date(loadIssue.occurredAt).toLocaleString()}`,
|
||||
`阶段: chat.getContacts`,
|
||||
`原因: ${loadIssue.reason}`,
|
||||
`错误详情: ${loadIssue.errorDetail || '无'}`
|
||||
].join('\n')
|
||||
}, [issueElapsedMs, loadIssue, loadSession])
|
||||
|
||||
const copyDiagnostics = useCallback(async () => {
|
||||
if (!diagnosticsText) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(diagnosticsText)
|
||||
alert('诊断信息已复制')
|
||||
} catch (error) {
|
||||
console.error('复制诊断信息失败:', error)
|
||||
alert('复制失败,请手动复制诊断信息')
|
||||
}
|
||||
}, [diagnosticsText])
|
||||
|
||||
const contactsUpdatedAtLabel = useMemo(() => {
|
||||
if (!contactsUpdatedAt) return ''
|
||||
return new Date(contactsUpdatedAt).toLocaleString()
|
||||
}, [contactsUpdatedAt])
|
||||
|
||||
const avatarCachedCount = useMemo(() => {
|
||||
return contacts.reduce((count, contact) => (
|
||||
contact.avatarUrl ? count + 1 : count
|
||||
), 0)
|
||||
}, [contacts])
|
||||
|
||||
const avatarCacheUpdatedAtLabel = useMemo(() => {
|
||||
if (!avatarCacheUpdatedAt) return ''
|
||||
return new Date(avatarCacheUpdatedAt).toLocaleString()
|
||||
}, [avatarCacheUpdatedAt])
|
||||
|
||||
const toggleContactSelected = (username: string, checked: boolean) => {
|
||||
setSelectedUsernames(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -256,7 +843,7 @@ function ContactsPage() {
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
|
||||
<button className="icon-btn" onClick={() => void loadContacts()} disabled={isLoading}>
|
||||
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -280,25 +867,30 @@ function ContactsPage() {
|
||||
<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>
|
||||
<User size={16} />
|
||||
<span className="chip-label">好友</span>
|
||||
<span className="chip-count">{contactTypeCounts.friends}</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>
|
||||
<Users size={16} />
|
||||
<span className="chip-label">群聊</span>
|
||||
<span className="chip-count">{contactTypeCounts.groups}</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>
|
||||
<MessageSquare size={16} />
|
||||
<span className="chip-label">公众号</span>
|
||||
<span className="chip-count">{contactTypeCounts.officials}</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>
|
||||
<UserX size={16} />
|
||||
<span className="chip-label">曾经的好友</span>
|
||||
<span className="chip-count">{contactTypeCounts.deletedFriends}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="contacts-count">
|
||||
共 {filteredContacts.length} 个联系人
|
||||
</div>
|
||||
|
||||
{exportMode && (
|
||||
<div className="selection-toolbar">
|
||||
@@ -315,61 +907,105 @@ function ContactsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
{contacts.length === 0 && loadIssue ? (
|
||||
<div className="load-issue-state">
|
||||
<div className="issue-card">
|
||||
<div className="issue-title">
|
||||
<AlertTriangle size={18} />
|
||||
<span>{loadIssue.title}</span>
|
||||
</div>
|
||||
<p className="issue-message">{loadIssue.message}</p>
|
||||
<p className="issue-reason">{loadIssue.reason}</p>
|
||||
<ul className="issue-hints">
|
||||
<li>可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。</li>
|
||||
<li>可能原因2:contact.db 数据量较大,首次查询时间过长。</li>
|
||||
<li>可能原因3:数据库连接状态异常或 IPC 调用卡住。</li>
|
||||
</ul>
|
||||
<div className="issue-actions">
|
||||
<button className="issue-btn primary" onClick={() => void loadContacts()}>
|
||||
<RefreshCw size={14} />
|
||||
<span>重试加载</span>
|
||||
</button>
|
||||
<button className="issue-btn" onClick={() => setShowDiagnostics(prev => !prev)}>
|
||||
<ClipboardList size={14} />
|
||||
<span>{showDiagnostics ? '收起诊断详情' : '查看诊断详情'}</span>
|
||||
</button>
|
||||
<button className="issue-btn" onClick={copyDiagnostics}>
|
||||
<span>复制诊断信息</span>
|
||||
</button>
|
||||
</div>
|
||||
{showDiagnostics && (
|
||||
<pre className="issue-diagnostics">{diagnosticsText}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading && contacts.length === 0 ? (
|
||||
<div className="loading-state">
|
||||
<Loader2 size={32} className="spin" />
|
||||
<span>加载中...</span>
|
||||
<span>联系人加载中...</span>
|
||||
</div>
|
||||
) : filteredContacts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<span>暂无联系人</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="contacts-list">
|
||||
{filteredContacts.map(contact => {
|
||||
<div className="contacts-list" ref={listRef} onScroll={onContactsListScroll}>
|
||||
<div
|
||||
className="contacts-list-virtual"
|
||||
style={{ height: filteredContacts.length * VIRTUAL_ROW_HEIGHT }}
|
||||
>
|
||||
{visibleContacts.map((contact, idx) => {
|
||||
const absoluteIndex = startIndex + idx
|
||||
const top = absoluteIndex * VIRTUAL_ROW_HEIGHT
|
||||
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)
|
||||
}
|
||||
}}
|
||||
className="contact-row"
|
||||
style={{ transform: `translateY(${top}px)` }}
|
||||
>
|
||||
{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
|
||||
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>
|
||||
<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 className="contact-avatar">
|
||||
{contact.avatarUrl ? (
|
||||
<img src={contact.avatarUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -475,6 +1111,19 @@ function ContactsPage() {
|
||||
<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>
|
||||
{selectedContactSupportsSns && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">朋友圈</span>
|
||||
<button
|
||||
type="button"
|
||||
className="detail-entry-btn"
|
||||
onClick={openSelectedContactSnsTimeline}
|
||||
>
|
||||
<Aperture size={14} />
|
||||
<span>{selectedContactSnsEntryLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -497,6 +1146,14 @@ function ContactsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ContactSnsTimelineDialog
|
||||
target={snsTimelineTarget}
|
||||
onClose={() => setSnsTimelineTarget(null)}
|
||||
initialTotalPosts={selectedContact && snsTimelineTarget?.username === selectedContact.username ? selectedContactSnsCount : null}
|
||||
initialTotalPostsLoading={selectedContact && snsTimelineTarget?.username === selectedContact.username
|
||||
? snsUserPostCountsStatus === 'idle' || snsUserPostCountsStatus === 'loading'
|
||||
: false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,7 +107,16 @@ function DualReportWindow() {
|
||||
setLoadingStage('完成')
|
||||
|
||||
if (result.success && result.data) {
|
||||
setReportData(result.data)
|
||||
const normalizedResponse = result.data.response
|
||||
? {
|
||||
...result.data.response,
|
||||
slowest: result.data.response.slowest ?? result.data.response.avg
|
||||
}
|
||||
: undefined
|
||||
setReportData({
|
||||
...result.data,
|
||||
response: normalizedResponse
|
||||
})
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
setError(result.error || '生成报告失败')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,14 @@
|
||||
.group-analytics-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.group-analytics-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 16px;
|
||||
|
||||
&.standalone {
|
||||
|
||||
@@ -4,7 +4,14 @@ import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, C
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import DateRangePicker from '../components/DateRangePicker'
|
||||
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
|
||||
import * as configService from '../services/config'
|
||||
import {
|
||||
finishBackgroundTask,
|
||||
isBackgroundTaskCancelRequested,
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import './GroupAnalyticsPage.scss'
|
||||
|
||||
interface GroupChatInfo {
|
||||
@@ -30,7 +37,7 @@ interface GroupMessageRank {
|
||||
}
|
||||
|
||||
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||
|
||||
interface MemberMessageExportOptions {
|
||||
format: MemberExportFormat
|
||||
@@ -119,6 +126,7 @@ function GroupAnalyticsPage() {
|
||||
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
|
||||
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||
@@ -175,15 +183,39 @@ function GroupAnalyticsPage() {
|
||||
}, [])
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage: 'groupAnalytics',
|
||||
title: '群列表加载',
|
||||
detail: '正在读取群聊列表',
|
||||
progressText: '群聊列表',
|
||||
cancelable: true
|
||||
})
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '已停止后续加载,群聊列表结果未继续写入'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.success && result.data) {
|
||||
setGroups(result.data)
|
||||
setFilteredGroups(result.data)
|
||||
finishBackgroundTask(taskId, 'completed', {
|
||||
detail: `群聊列表加载完成,共 ${result.data.length} 个群`,
|
||||
progressText: `${result.data.length} 个群`
|
||||
})
|
||||
} else {
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
detail: result.error || '加载群聊列表失败'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
detail: String(e)
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -313,6 +345,13 @@ function GroupAnalyticsPage() {
|
||||
|
||||
const loadFunctionData = async (func: AnalysisFunction) => {
|
||||
if (!selectedGroup) return
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage: 'groupAnalytics',
|
||||
title: `群分析:${func}`,
|
||||
detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`,
|
||||
progressText: func,
|
||||
cancelable: true
|
||||
})
|
||||
setFunctionLoading(true)
|
||||
|
||||
// 计算时间戳
|
||||
@@ -322,33 +361,96 @@ function GroupAnalyticsPage() {
|
||||
try {
|
||||
switch (func) {
|
||||
case 'members': {
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: '正在读取群成员列表',
|
||||
progressText: '成员列表'
|
||||
})
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群成员列表未继续写入' })
|
||||
return
|
||||
}
|
||||
if (result.success && result.data) setMembers(result.data)
|
||||
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
|
||||
detail: result.success ? `群成员列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取群成员列表失败'),
|
||||
progressText: result.success ? `${result.data?.length || 0} 人` : '失败'
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'memberExport': {
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: '正在读取导出成员列表',
|
||||
progressText: '成员导出'
|
||||
})
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员导出列表未继续写入' })
|
||||
return
|
||||
}
|
||||
if (result.success && result.data) setMembers(result.data)
|
||||
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
|
||||
detail: result.success ? `成员导出列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取成员导出列表失败'),
|
||||
progressText: result.success ? `${result.data?.length || 0} 人` : '失败'
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'ranking': {
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: '正在计算群消息排行',
|
||||
progressText: '消息排行'
|
||||
})
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息排行未继续写入' })
|
||||
return
|
||||
}
|
||||
if (result.success && result.data) setRankings(result.data)
|
||||
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
|
||||
detail: result.success ? `群消息排行加载完成,共 ${result.data?.length || 0} 条` : (result.error || '读取群消息排行失败'),
|
||||
progressText: result.success ? `${result.data?.length || 0} 条` : '失败'
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'activeHours': {
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: '正在计算群活跃时段',
|
||||
progressText: '活跃时段'
|
||||
})
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群活跃时段未继续写入' })
|
||||
return
|
||||
}
|
||||
if (result.success && result.data) setActiveHours(result.data.hourlyDistribution)
|
||||
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
|
||||
detail: result.success ? '群活跃时段加载完成' : (result.error || '读取群活跃时段失败'),
|
||||
progressText: result.success ? '24 小时分布' : '失败'
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'mediaStats': {
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: '正在统计群消息类型',
|
||||
progressText: '消息类型'
|
||||
})
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime)
|
||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息类型统计未继续写入' })
|
||||
return
|
||||
}
|
||||
if (result.success && result.data) setMediaStats(result.data)
|
||||
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
|
||||
detail: result.success ? `群消息类型统计完成,共 ${result.data?.total || 0} 条` : (result.error || '读取群消息类型统计失败'),
|
||||
progressText: result.success ? `${result.data?.total || 0} 条` : '失败'
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
detail: String(e)
|
||||
})
|
||||
} finally {
|
||||
setFunctionLoading(false)
|
||||
}
|
||||
@@ -1084,11 +1186,14 @@ function GroupAnalyticsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`group-analytics-page ${isResizing ? 'resizing' : ''}`} ref={containerRef}>
|
||||
{renderGroupList()}
|
||||
<div className="resize-handle" onMouseDown={() => setIsResizing(true)} />
|
||||
<div className="detail-area">
|
||||
{renderDetailPanel()}
|
||||
<div className="group-analytics-shell">
|
||||
<ChatAnalysisHeader currentMode="group" />
|
||||
<div className={`group-analytics-page ${isResizing ? 'resizing' : ''}`} ref={containerRef}>
|
||||
{renderGroupList()}
|
||||
<div className="resize-handle" onMouseDown={() => setIsResizing(true)} />
|
||||
<div className="detail-area">
|
||||
{renderDetailPanel()}
|
||||
</div>
|
||||
</div>
|
||||
{renderMemberModal()}
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
.blob-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: rgba(139, 115, 85, 0.25);
|
||||
background: rgba(var(--primary-rgb), 0.25);
|
||||
top: -100px;
|
||||
left: -50px;
|
||||
animation-duration: 25s;
|
||||
@@ -38,7 +38,7 @@
|
||||
.blob-2 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: rgba(139, 115, 85, 0.15);
|
||||
background: rgba(var(--primary-rgb), 0.15);
|
||||
bottom: -50px;
|
||||
right: -50px;
|
||||
animation-duration: 30s;
|
||||
@@ -74,7 +74,7 @@
|
||||
margin: 0 0 16px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -2px;
|
||||
background: linear-gradient(135deg, var(--text-primary) 0%, rgba(139, 115, 85, 0.8) 100%);
|
||||
background: linear-gradient(135deg, var(--primary) 0%, rgba(var(--primary-rgb), 0.6) 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
|
||||
@@ -7,64 +7,6 @@
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
.title-bar {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-right: 140px; // 为原生窗口控件留出空间
|
||||
|
||||
.window-drag-area {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.title-bar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
margin-right: 16px;
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.scale-text {
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--border-color);
|
||||
margin: 0 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-viewport {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -78,14 +20,40 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
img {
|
||||
.media-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
display: block;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
object-fit: contain;
|
||||
will-change: transform;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.live-video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.live-video.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
||||
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||
import TitleBar from '../components/TitleBar'
|
||||
import './ImageWindow.scss'
|
||||
|
||||
export default function ImageWindow() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const imagePath = searchParams.get('imagePath')
|
||||
const liveVideoPath = searchParams.get('liveVideoPath')
|
||||
const hasLiveVideo = !!liveVideoPath
|
||||
|
||||
const [isPlayingLive, setIsPlayingLive] = useState(false)
|
||||
const [isVideoVisible, setIsVideoVisible] = useState(false)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const liveCleanupTimerRef = useRef<number | null>(null)
|
||||
|
||||
const [scale, setScale] = useState(1)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [initialScale, setInitialScale] = useState(1)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
|
||||
// 使用 ref 存储拖动状态,避免闭包问题
|
||||
const dragStateRef = useRef({
|
||||
isDragging: false,
|
||||
@@ -22,11 +31,49 @@ export default function ImageWindow() {
|
||||
startPosY: 0
|
||||
})
|
||||
|
||||
const clearLiveCleanupTimer = useCallback(() => {
|
||||
if (liveCleanupTimerRef.current !== null) {
|
||||
window.clearTimeout(liveCleanupTimerRef.current)
|
||||
liveCleanupTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopLivePlayback = useCallback((immediate = false) => {
|
||||
clearLiveCleanupTimer()
|
||||
setIsVideoVisible(false)
|
||||
|
||||
if (immediate) {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause()
|
||||
videoRef.current.currentTime = 0
|
||||
}
|
||||
setIsPlayingLive(false)
|
||||
return
|
||||
}
|
||||
|
||||
liveCleanupTimerRef.current = window.setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause()
|
||||
videoRef.current.currentTime = 0
|
||||
}
|
||||
setIsPlayingLive(false)
|
||||
liveCleanupTimerRef.current = null
|
||||
}, 300)
|
||||
}, [clearLiveCleanupTimer])
|
||||
|
||||
const handlePlayLiveVideo = useCallback(() => {
|
||||
if (!liveVideoPath || isPlayingLive) return
|
||||
|
||||
clearLiveCleanupTimer()
|
||||
setIsPlayingLive(true)
|
||||
setIsVideoVisible(false)
|
||||
}, [clearLiveCleanupTimer, liveVideoPath, isPlayingLive])
|
||||
|
||||
const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10))
|
||||
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
|
||||
const handleRotate = () => setRotation(prev => (prev + 90) % 360)
|
||||
const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360)
|
||||
|
||||
|
||||
// 重置视图
|
||||
const handleReset = useCallback(() => {
|
||||
setScale(1)
|
||||
@@ -39,7 +86,7 @@ export default function ImageWindow() {
|
||||
const img = e.currentTarget
|
||||
const naturalWidth = img.naturalWidth
|
||||
const naturalHeight = img.naturalHeight
|
||||
|
||||
|
||||
if (viewportRef.current) {
|
||||
const viewportWidth = viewportRef.current.clientWidth * 0.9
|
||||
const viewportHeight = viewportRef.current.clientHeight * 0.9
|
||||
@@ -51,14 +98,37 @@ export default function ImageWindow() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 视频挂载后再播放,避免点击瞬间 ref 尚未就绪导致丢播
|
||||
useEffect(() => {
|
||||
if (!isPlayingLive || !videoRef.current) return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || !isPlayingLive || !video.paused) return
|
||||
|
||||
video.currentTime = 0
|
||||
void video.play().catch(() => {
|
||||
stopLivePlayback(true)
|
||||
})
|
||||
}, 0)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [isPlayingLive, stopLivePlayback])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearLiveCleanupTimer()
|
||||
}
|
||||
}, [clearLiveCleanupTimer])
|
||||
|
||||
// 使用原生事件监听器处理拖动
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragStateRef.current.isDragging) return
|
||||
|
||||
|
||||
const dx = e.clientX - dragStateRef.current.startX
|
||||
const dy = e.clientY - dragStateRef.current.startY
|
||||
|
||||
|
||||
setPosition({
|
||||
x: dragStateRef.current.startPosX + dx,
|
||||
y: dragStateRef.current.startPosY + dy
|
||||
@@ -82,7 +152,7 @@ export default function ImageWindow() {
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
dragStateRef.current = {
|
||||
isDragging: true,
|
||||
startX: e.clientX,
|
||||
@@ -106,15 +176,25 @@ export default function ImageWindow() {
|
||||
// 快捷键支持
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') window.electronAPI.window.close()
|
||||
if (e.key === 'Escape') {
|
||||
if (isPlayingLive) {
|
||||
stopLivePlayback(true)
|
||||
return
|
||||
}
|
||||
window.electronAPI.window.close()
|
||||
}
|
||||
if (e.key === '=' || e.key === '+') handleZoomIn()
|
||||
if (e.key === '-') handleZoomOut()
|
||||
if (e.key === 'r' || e.key === 'R') handleRotate()
|
||||
if (e.key === '0') handleReset()
|
||||
if (e.key === ' ' && hasLiveVideo) {
|
||||
e.preventDefault()
|
||||
handlePlayLiveVideo()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleReset])
|
||||
}, [handleReset, hasLiveVideo, handlePlayLiveVideo, isPlayingLive, stopLivePlayback])
|
||||
|
||||
if (!imagePath) {
|
||||
return (
|
||||
@@ -128,34 +208,68 @@ export default function ImageWindow() {
|
||||
|
||||
return (
|
||||
<div className="image-window-container">
|
||||
<div className="title-bar">
|
||||
<div className="window-drag-area"></div>
|
||||
<div className="title-bar-controls">
|
||||
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
||||
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
||||
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
|
||||
<div className="divider"></div>
|
||||
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
|
||||
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<TitleBar
|
||||
title="图片查看"
|
||||
showWindowControls={true}
|
||||
showLogo={false}
|
||||
customControls={
|
||||
<div className="image-controls">
|
||||
{hasLiveVideo && (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePlayLiveVideo}
|
||||
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
|
||||
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
|
||||
disabled={isPlayingLive}
|
||||
>
|
||||
<LivePhotoIcon size={16} />
|
||||
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
||||
</button>
|
||||
<div className="divider"></div>
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
||||
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
||||
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
|
||||
<div className="divider"></div>
|
||||
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
|
||||
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="image-viewport"
|
||||
<div
|
||||
className="image-viewport"
|
||||
ref={viewportRef}
|
||||
onWheel={handleWheel}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<img
|
||||
src={imagePath}
|
||||
alt="Preview"
|
||||
<div
|
||||
className="media-wrapper"
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
|
||||
}}
|
||||
onLoad={handleImageLoad}
|
||||
draggable={false}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={imagePath}
|
||||
alt="Preview"
|
||||
onLoad={handleImageLoad}
|
||||
draggable={false}
|
||||
/>
|
||||
{hasLiveVideo && isPlayingLive && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={liveVideoPath || ''}
|
||||
className={`live-video ${isVideoVisible ? 'visible' : ''}`}
|
||||
autoPlay
|
||||
playsInline
|
||||
preload="auto"
|
||||
onPlaying={() => setIsVideoVisible(true)}
|
||||
onEnded={() => stopLivePlayback(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import '../components/NotificationToast.scss'
|
||||
import './NotificationWindow.scss'
|
||||
|
||||
export default function NotificationWindow() {
|
||||
const { currentTheme, themeMode } = useThemeStore()
|
||||
const [notification, setNotification] = useState<NotificationData | null>(null)
|
||||
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
||||
|
||||
@@ -19,12 +17,6 @@ export default function NotificationWindow() {
|
||||
|
||||
const notificationRef = useRef<NotificationData | null>(null)
|
||||
|
||||
// 应用主题到通知窗口
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||
document.documentElement.setAttribute('data-mode', themeMode)
|
||||
}, [currentTheme, themeMode])
|
||||
|
||||
useEffect(() => {
|
||||
notificationRef.current = notification
|
||||
}, [notification])
|
||||
|
||||
@@ -1,17 +1,92 @@
|
||||
.settings-modal-overlay {
|
||||
position: fixed;
|
||||
top: 41px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28px 32px;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
backdrop-filter: blur(10px);
|
||||
animation: settingsFadeIn 0.2s ease;
|
||||
|
||||
&.closing {
|
||||
animation: settingsFadeOut 0.2s ease forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settingsFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settingsFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin: -24px;
|
||||
width: min(1160px, calc(100vw - 96px));
|
||||
height: min(820px, calc(100vh - 120px));
|
||||
max-height: 100%;
|
||||
padding: 24px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22);
|
||||
overflow: hidden;
|
||||
animation: settingsSlideUp 0.3s ease;
|
||||
|
||||
&.closing {
|
||||
animation: settingsSlideDown 0.2s ease forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settingsSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settingsSlideDown {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 20px;
|
||||
margin-bottom: 14px;
|
||||
flex-shrink: 0;
|
||||
|
||||
h1 {
|
||||
@@ -22,51 +97,91 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings-title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-close-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(139, 115, 85, 0.28);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 12px;
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 11px 14px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.active {
|
||||
background: var(--card-bg);
|
||||
color: var(--primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--card-bg);
|
||||
color: var(--primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
@@ -85,8 +200,10 @@
|
||||
|
||||
.tab-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
min-height: 100%;
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
@@ -348,6 +465,51 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-time-range-field {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-time-range-trigger {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9999px;
|
||||
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: rgba(var(--primary-rgb), 0.45);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-time-range-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.settings-time-range-arrow {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
@@ -887,7 +1049,7 @@
|
||||
padding: 10px 24px;
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
z-index: 100;
|
||||
z-index: 2200;
|
||||
animation: slideDown 0.3s ease;
|
||||
|
||||
&.success {
|
||||
@@ -901,6 +1063,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.settings-modal-overlay {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
width: min(100%, calc(100vw - 40px));
|
||||
height: min(100%, calc(100vh - 82px));
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -1739,54 +1922,106 @@
|
||||
|
||||
.model-status-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
.model-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.model-path {
|
||||
.model-size {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.model-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: fit-content;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
|
||||
&.success {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.model-path-block {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.path-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.path-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.model-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
.btn-download {
|
||||
display: inline-flex;
|
||||
@@ -1821,16 +2056,18 @@
|
||||
.download-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 280px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
|
||||
.status-header,
|
||||
.progress-info {
|
||||
// specific layout class
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center; // Align vertically
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.percent {
|
||||
@@ -1844,6 +2081,7 @@
|
||||
.details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
@@ -1918,10 +2156,12 @@
|
||||
.path-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
input {
|
||||
margin-bottom: 0 !important;
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -2172,4 +2412,71 @@
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.brute-force-progress {
|
||||
margin-top: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
animation: slideUp 0.3s ease;
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
// 增加文字呼吸灯效果,表明正在运行
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-size: 14px;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 60%, white) 100%);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
|
||||
// 流光扫过的高亮特效
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.3),
|
||||
transparent
|
||||
);
|
||||
animation: progress-shimmer 1.5s infinite linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user