mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 07:35:50 +00:00
Compare commits
385 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a27653039 | ||
|
|
d5b1f5fb1c | ||
|
|
8dfd39810d | ||
|
|
b2ee143e1c | ||
|
|
94b0a9f89b | ||
|
|
a0a50ff7d1 | ||
|
|
7ccdae23fa | ||
|
|
0bf57502e6 | ||
|
|
2888c369d7 | ||
|
|
bedb872034 | ||
|
|
cd42e76659 | ||
|
|
1b49aa2d39 | ||
|
|
4423c895c7 | ||
|
|
f9c574ddd9 | ||
|
|
60dc911228 | ||
|
|
ed25a0e395 | ||
|
|
7590623d26 | ||
|
|
043e518cce | ||
|
|
de7f7bc8de | ||
|
|
b8079f11d0 | ||
|
|
7c5b3f2241 | ||
|
|
48e5ce807d | ||
|
|
35e9ea13de | ||
|
|
958677c5b1 | ||
|
|
21a9904b81 | ||
|
|
bc979767d6 | ||
|
|
e933209ea7 | ||
|
|
ae6cd88d9e | ||
|
|
7ffc0c3484 | ||
|
|
0f450154cf | ||
|
|
e32b4c7406 | ||
|
|
d45179a4b0 | ||
|
|
0816fafc02 | ||
|
|
db4cf015c2 | ||
|
|
48c4197b16 | ||
|
|
a0fb109839 | ||
|
|
4c32bf5934 | ||
|
|
19beb846bf | ||
|
|
661b6e46cc | ||
|
|
19d7330d3a | ||
|
|
75f70c2ae0 | ||
|
|
fb00b12d13 | ||
|
|
0f8f202fbb | ||
|
|
f4ad6bf263 | ||
|
|
be7d173746 | ||
|
|
e0b2f152b0 | ||
|
|
d0457a2782 | ||
|
|
ee684021db | ||
|
|
61eef27740 | ||
|
|
774ac7f2fa | ||
|
|
6dcc597b0c | ||
|
|
5bd332369f | ||
|
|
f2c0799854 | ||
|
|
dea77cc268 | ||
|
|
1f5b1e2bb9 | ||
|
|
da68b0fdae | ||
|
|
1680acb22c | ||
|
|
56a8859eaf | ||
|
|
fee8c3f0ee | ||
|
|
faa22966e4 | ||
|
|
3c72f3b1c5 | ||
|
|
7497b48531 | ||
|
|
70fddac2d5 | ||
|
|
8f65124830 | ||
|
|
bb9b7bcf9f | ||
|
|
4bd2c90554 | ||
|
|
bd6b23f413 | ||
|
|
85b5943b9e | ||
|
|
0f5ed083df | ||
|
|
486ca220a2 | ||
|
|
a19bf5fac2 | ||
|
|
5cf8ce4385 | ||
|
|
8026d19d8f | ||
|
|
d64abe4ee3 | ||
|
|
89acfafbd2 | ||
|
|
072c49a037 | ||
|
|
7fad75fad0 | ||
|
|
79e40f6a53 | ||
|
|
f2b1b07f58 | ||
|
|
999ddaeb9a | ||
|
|
d730ae5bef | ||
|
|
bf48e865ac | ||
|
|
7e05909404 | ||
|
|
7a1c944fe6 | ||
|
|
66a2b3224f | ||
|
|
7bcdecaceb | ||
|
|
6beefb9fc0 | ||
|
|
579b63b036 | ||
|
|
1f676254a9 | ||
|
|
eac81ac82b | ||
|
|
8c1b043769 | ||
|
|
eb870d94c2 | ||
|
|
c18b62ffb9 | ||
|
|
02f724bfc3 | ||
|
|
e12ea371c0 | ||
|
|
9a1726c249 | ||
|
|
50f2eaee3b | ||
|
|
6b1229fcf2 | ||
|
|
ef97202867 | ||
|
|
5494490ff8 | ||
|
|
bd4c4878f1 | ||
|
|
6a7851a1cc | ||
|
|
0eac4e2a44 | ||
|
|
053e2cdc64 | ||
|
|
7024b86d00 | ||
|
|
ae75820b77 | ||
|
|
a800c71cba | ||
|
|
55cce56230 | ||
|
|
128f1ca043 | ||
|
|
2f25fd1239 | ||
|
|
c0ad450960 | ||
|
|
0845ee6775 | ||
|
|
ffcdb10802 | ||
|
|
fe5b63eed8 | ||
|
|
f3ca6c3fa7 | ||
|
|
904bc45652 | ||
|
|
845d6b2e2c | ||
|
|
5deacf45cb | ||
|
|
e9bc303e0e | ||
|
|
caaf1e8d0d | ||
|
|
b96e757379 | ||
|
|
53a52d8561 | ||
|
|
32424e46b8 | ||
|
|
1e3829899a | ||
|
|
b6df41e05b | ||
|
|
f4fd5bb797 | ||
|
|
ecc538a932 | ||
|
|
6741a94c1b | ||
|
|
7be2c69256 | ||
|
|
2b97b6ac9d | ||
|
|
512b47a386 | ||
|
|
d6b95036b5 | ||
|
|
e4c188da75 | ||
|
|
edfe28b9ef | ||
|
|
c111ed4f91 | ||
|
|
318c296ee9 | ||
|
|
998b2ce3d7 | ||
|
|
ba5f8928f7 | ||
|
|
641abc57b9 | ||
|
|
0a23ed6ef4 | ||
|
|
8e69e1ec58 | ||
|
|
d50bffad3e | ||
|
|
db71bc3f19 | ||
|
|
f2a9d7097f | ||
|
|
a4b0a25dab | ||
|
|
3af530a15e | ||
|
|
11c7277878 | ||
|
|
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 | ||
|
|
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 |
114
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
114
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
name: "报告 Bug"
|
||||
description: "代码出现了非预期的问题、崩溃或报错"
|
||||
title: "[Bug]: "
|
||||
labels: ["type: bug", "status: needs info"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请提供尽可能详细的信息,帮助我们快速定位和修复问题。
|
||||
- type: checkboxes
|
||||
id: pre-check
|
||||
attributes:
|
||||
label: 提交前确认
|
||||
description: 请务必确认以下事项
|
||||
options:
|
||||
- label: 我已搜索过现有的 Issues,确认这不是重复问题
|
||||
required: true
|
||||
- label: 我使用的是最新版本
|
||||
required: true
|
||||
- label: 我已阅读过相关文档
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: 使用平台
|
||||
description: 选择出现问题的平台
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
label: 问题严重程度
|
||||
description: 这个问题对你的使用造成了多大影响?
|
||||
options:
|
||||
- 严重崩溃或数据丢失(无法使用)
|
||||
- 核心功能受影响(在下一个常规发布中必须修复)
|
||||
- 边缘场景或轻微问题(等待空闲时修复)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 清晰描述你遇到的问题,包括实际发生了什么
|
||||
placeholder: 例如:当我点击发送按钮时,应用程序崩溃并显示白屏
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 提供详细的操作步骤,让我们能够重现这个问题
|
||||
placeholder: |
|
||||
1. 打开应用并登录账号
|
||||
2. 进入聊天页面
|
||||
3. 点击发送按钮
|
||||
4. 观察到应用崩溃
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 描述你期望的正确行为应该是什么样的
|
||||
placeholder: 例如:点击发送按钮后,消息应该正常发送并显示在聊天窗口中
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: 实际行为
|
||||
description: 描述实际发生的错误行为
|
||||
placeholder: 例如:点击后应用直接崩溃,显示白屏
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 错误日志或截图
|
||||
description: 粘贴控制台错误信息、崩溃日志,或拖入截图
|
||||
placeholder: 请粘贴完整的错误堆栈信息
|
||||
render: shell
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: 操作系统版本
|
||||
description: 例如:Windows 11 24H2、macOS 15.0、Ubuntu 24.04
|
||||
placeholder: Windows 11 24H2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: 应用版本
|
||||
description: 在关于页面或设置中查看版本号
|
||||
placeholder: v1.2.3
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: architecture
|
||||
attributes:
|
||||
label: 系统架构
|
||||
description: 例如:x64、arm64
|
||||
placeholder: x64
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 其他可能有助于定位问题的信息
|
||||
placeholder: 例如:这个问题是在某次更新后开始出现的,或者只在特定网络环境下出现
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name:🤔 找不到合适的模板?
|
||||
url: https://t.me/weflow_cc
|
||||
about: 如果你的问题不属于上述任何分类,请前往我们的 Telegram 频道与我们交流。
|
||||
67
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: "文档反馈"
|
||||
description: "文档存在错别字、描述不清晰或缺少必要的示例"
|
||||
title: "[Docs]: "
|
||||
labels: ["type: docs"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
优秀的文档和代码一样重要。感谢你帮助我们完善文档!
|
||||
- type: dropdown
|
||||
id: doc-type
|
||||
attributes:
|
||||
label: 文档类型
|
||||
description: 问题出现在哪类文档中?
|
||||
options:
|
||||
- README 或项目说明
|
||||
- 安装部署文档
|
||||
- 使用教程
|
||||
- API 文档
|
||||
- 开发者文档
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: doc-link
|
||||
attributes:
|
||||
label: 文档位置
|
||||
description: 提供文档的 URL 或文件路径
|
||||
placeholder: 例如:docs/installation.md 或 https://github.com/xxx/xxx/wiki/xxx
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: issue-type
|
||||
attributes:
|
||||
label: 问题类型
|
||||
description: 文档存在什么问题?
|
||||
options:
|
||||
- 错别字或语法错误
|
||||
- 内容过时或不准确
|
||||
- 描述不清晰或有歧义
|
||||
- 缺少必要的示例代码
|
||||
- 缺少重要的说明或警告
|
||||
- 链接失效或错误
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: issue-desc
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 详细说明文档中存在的问题
|
||||
placeholder: 例如:第 3 步中的命令拼写错误,应该是 "npm install" 而不是 "npm instal"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: suggestion
|
||||
attributes:
|
||||
label: 修改建议
|
||||
description: 你认为应该如何修改?
|
||||
placeholder: 例如:建议将"安装依赖"部分补充完整的命令示例,并说明不同操作系统的差异
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 补充说明
|
||||
description: 其他需要补充的信息
|
||||
78
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: "功能与体验优化"
|
||||
description: "对现有的功能逻辑进行优化,或改进用户体验"
|
||||
title: "[Enhancement]: "
|
||||
labels: ["type: enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。
|
||||
- type: checkboxes
|
||||
id: pre-check
|
||||
attributes:
|
||||
label: 提交前确认
|
||||
options:
|
||||
- label: 我已搜索过现有的 Issues,确认这个优化建议尚未被提出
|
||||
required: true
|
||||
- label: 这是对现有功能的改进,而不是全新功能
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: 优化类别
|
||||
description: 这个优化主要属于哪个方面?
|
||||
options:
|
||||
- 性能优化(速度、内存、资源占用)
|
||||
- 交互体验(操作流程、界面布局)
|
||||
- 视觉设计(样式、动画、美观度)
|
||||
- 易用性(降低使用门槛、减少操作步骤)
|
||||
- 稳定性(减少崩溃、提高可靠性)
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: target
|
||||
attributes:
|
||||
label: 目标功能或模块
|
||||
description: 你希望优化的具体功能或页面是哪个?
|
||||
placeholder: 例如:聊天页面的消息加载、设置页面的布局、文件上传功能
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current-behavior
|
||||
attributes:
|
||||
label: 当前表现
|
||||
description: 描述当前功能的不足之处或存在的问题
|
||||
placeholder: 例如:消息列表滚动时会出现明显卡顿,加载 100 条消息需要 3 秒
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: improvement
|
||||
attributes:
|
||||
label: 优化建议
|
||||
description: 详细说明你的优化方案和预期效果
|
||||
placeholder: 例如:建议使用虚拟滚动技术,只渲染可见区域的消息,预计可将加载时间缩短到 0.5 秒以内
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: benefits
|
||||
attributes:
|
||||
label: 优化收益
|
||||
description: 这个优化会带来什么具体好处?
|
||||
placeholder: 例如:提升 80% 的加载速度、减少 50% 的内存占用、降低用户操作步骤从 5 步到 2 步
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: 影响范围
|
||||
description: 这个优化会影响哪些用户或场景?
|
||||
placeholder: 例如:所有用户在查看历史消息时都会受益,尤其是群聊消息较多的场景
|
||||
- type: checkboxes
|
||||
id: contribution
|
||||
attributes:
|
||||
label: 参与贡献
|
||||
options:
|
||||
- label: 我愿意提交 Pull Request 来实现这个优化
|
||||
validations:
|
||||
required: true
|
||||
71
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
71
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: "全新功能请求"
|
||||
description: "提议一个目前项目中完全没有的新特性"
|
||||
title: "[Feature]: "
|
||||
labels: ["type: feature"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢你为项目提供新想法!详细的需求描述能极大提高该功能被采纳的几率。
|
||||
- type: checkboxes
|
||||
id: pre-check
|
||||
attributes:
|
||||
label: 提交前确认
|
||||
options:
|
||||
- label: 我已搜索过现有的 Issues 和 Pull Requests,确认这个功能尚未被提出或实现
|
||||
required: true
|
||||
- label: 这是一个全新的功能,而不是对现有功能的改进
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 功能优先级
|
||||
description: 你认为这个功能有多重要?
|
||||
options:
|
||||
- 高优先级(核心功能缺失,严重影响使用体验)
|
||||
- 中优先级(有助于提升使用体验)
|
||||
- 低优先级(锦上添花的功能)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 问题或痛点
|
||||
description: 【为什么需要】你现在做某件事遇到了什么困难?缺少什么能力?
|
||||
placeholder: 例如:目前无法批量导出聊天记录,每次只能手动复制单条消息,处理 100 条消息需要半小时
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 期望的解决方案
|
||||
description: 【怎么实现】详细描述功能的操作流程、界面位置、可选参数等
|
||||
placeholder: 例如:在聊天窗口右键菜单添加"导出记录",点击后弹窗可选时间范围、导出格式(TXT/JSON)、筛选用户,最后保存到本地
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: 使用场景
|
||||
description: 【什么时候用】你会在哪些具体情况下使用这个功能?
|
||||
placeholder: 例如:每周五整理工作讨论记录;保存客户沟通记录作为合同依据;备份重要群聊内容
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 替代方案
|
||||
description: 你目前使用什么临时方案?或者有没有考虑过其他实现方式?
|
||||
placeholder: 例如:目前只能手动截图或逐条复制粘贴
|
||||
- type: textarea
|
||||
id: reference
|
||||
attributes:
|
||||
label: 参考示例
|
||||
description: 其他应用中是否有类似功能可以参考?
|
||||
placeholder: 例如:微信的聊天记录导出功能、Telegram 的导出数据功能
|
||||
- type: checkboxes
|
||||
id: contribution
|
||||
attributes:
|
||||
label: 参与贡献
|
||||
options:
|
||||
- label: 我愿意提交 Pull Request 来实现这个功能
|
||||
71
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
71
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: "使用答疑"
|
||||
description: "关于如何配置、如何使用项目的求助"
|
||||
title: "[Question]: "
|
||||
labels: ["type: question"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
在提问之前,请确保你已经仔细阅读过我们的官方文档。
|
||||
- type: checkboxes
|
||||
id: pre-check
|
||||
attributes:
|
||||
label: 提交前确认
|
||||
options:
|
||||
- label: 我已阅读过相关文档
|
||||
required: true
|
||||
- label: 我已搜索过现有的 Issues,没有找到类似问题
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: question-type
|
||||
attributes:
|
||||
label: 问题类型
|
||||
description: 你的问题属于哪个方面?
|
||||
options:
|
||||
- 安装部署问题
|
||||
- 配置相关问题
|
||||
- 功能使用问题
|
||||
- API 调用问题
|
||||
- 错误排查问题
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 清晰描述你遇到的问题或疑问
|
||||
placeholder: 例如:我在 Windows 系统上安装后无法启动应用,双击图标没有任何反应
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: attempts
|
||||
attributes:
|
||||
label: 已尝试的方法
|
||||
description: 你已经尝试过哪些解决方法?
|
||||
placeholder: 例如:我尝试过重新安装、以管理员身份运行、关闭防火墙,但问题依然存在
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 运行环境
|
||||
description: 提供你的系统环境信息
|
||||
placeholder: |
|
||||
操作系统:Windows 11
|
||||
应用版本:v1.2.3
|
||||
系统架构:x64
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: code-snippet
|
||||
attributes:
|
||||
label: 相关配置或代码
|
||||
description: 如果涉及配置或代码问题,请粘贴相关内容
|
||||
placeholder: 粘贴你的配置文件或代码片段
|
||||
render: javascript
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 截图或日志
|
||||
description: 如有必要,请提供截图或错误日志
|
||||
84
.github/workflows/issue-auto-assign.yml
vendored
Normal file
84
.github/workflows/issue-auto-assign.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
name: Issue Auto Assign
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, reopened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
assign-by-platform:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Assign issue by selected platform
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
ASSIGNEE_WINDOWS: ${{ vars.ISSUE_ASSIGNEE_WINDOWS }}
|
||||
ASSIGNEE_MACOS: ${{ vars.ISSUE_ASSIGNEE_MACOS }}
|
||||
ASSIGNEE_LINUX: ${{ vars.ISSUE_ASSIGNEE_LINUX || 'H3CoF6' }}
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
if (!issue) {
|
||||
core.info("No issue payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = (issue.labels || []).map((l) => l.name);
|
||||
if (!labels.includes("type: bug")) {
|
||||
core.info("Skip non-bug issue.");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = issue.body || "";
|
||||
const match = body.match(/###\s*(?:使用平台|平台|Platform)\s*\r?\n+([^\r\n]+)/i);
|
||||
if (!match) {
|
||||
core.info("No platform field found in issue body.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPlatform = match[1].trim().toLowerCase();
|
||||
let platformKey = null;
|
||||
if (rawPlatform.includes("windows")) platformKey = "windows";
|
||||
if (rawPlatform.includes("macos")) platformKey = "macos";
|
||||
if (rawPlatform.includes("linux")) platformKey = "linux";
|
||||
|
||||
if (!platformKey) {
|
||||
core.info(`Unrecognized platform value: ${rawPlatform}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const parseAssignees = (value) =>
|
||||
(value || "")
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const assigneeMap = {
|
||||
windows: parseAssignees(process.env.ASSIGNEE_WINDOWS),
|
||||
macos: parseAssignees(process.env.ASSIGNEE_MACOS),
|
||||
linux: parseAssignees(process.env.ASSIGNEE_LINUX),
|
||||
};
|
||||
|
||||
const candidates = assigneeMap[platformKey] || [];
|
||||
if (candidates.length === 0) {
|
||||
core.info(`No assignee configured for platform: ${platformKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = new Set((issue.assignees || []).map((a) => a.login));
|
||||
const toAdd = candidates.filter((u) => !existing.has(u));
|
||||
if (toAdd.length === 0) {
|
||||
core.info("All configured assignees already assigned.");
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: toAdd,
|
||||
});
|
||||
|
||||
core.info(`Assigned issue #${issue.number} to: ${toAdd.join(", ")}`);
|
||||
136
.github/workflows/release.yml
vendored
136
.github/workflows/release.yml
vendored
@@ -8,24 +8,100 @@ 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 install
|
||||
|
||||
- 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
|
||||
|
||||
release-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
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 install
|
||||
|
||||
- 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 Linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --linux --publish always
|
||||
|
||||
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
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
@@ -45,17 +121,63 @@ jobs:
|
||||
run: |
|
||||
npx electron-builder --publish always
|
||||
|
||||
- name: Update Release Notes
|
||||
update-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- release-mac-arm64
|
||||
- release-linux
|
||||
- release
|
||||
|
||||
steps:
|
||||
- name: Generate release notes with platform download links
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<EOF > release_notes.md
|
||||
set -euo pipefail
|
||||
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||
|
||||
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||
|
||||
pick_asset() {
|
||||
local pattern="$1"
|
||||
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||
}
|
||||
|
||||
WINDOWS_ASSET="$(pick_asset "\\.exe$")"
|
||||
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
|
||||
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
||||
|
||||
build_link() {
|
||||
local name="$1"
|
||||
if [ -n "$name" ]; then
|
||||
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||
fi
|
||||
}
|
||||
|
||||
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
|
||||
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||
|
||||
cat > release_notes.md <<EOF
|
||||
## 更新日志
|
||||
修复了一些已知问题
|
||||
|
||||
## 查看更多日志/获取最新动态
|
||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||
|
||||
## 下载
|
||||
- Windows (Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
||||
- Linux (.deb): ${LINUX_DEB_URL:-$RELEASE_PAGE}
|
||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||
|
||||
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
||||
EOF
|
||||
|
||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -62,7 +62,11 @@ server/
|
||||
chatlab-format.md
|
||||
*.bak
|
||||
AGENTS.md
|
||||
AGENT.md
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.agents/
|
||||
resources/wx_send
|
||||
概述.md
|
||||
pnpm-lock.yaml
|
||||
/pnpm-workspace.yaml
|
||||
|
||||
4
.npmrc
4
.npmrc
@@ -1,3 +1,3 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
electron-mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
|
||||
625
docs/HTTP-API.md
625
docs/HTTP-API.md
@@ -1,33 +1,46 @@
|
||||
# WeFlow HTTP API 接口文档
|
||||
# WeFlow HTTP API / Push 文档
|
||||
|
||||
WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。
|
||||
WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||
|
||||
## 启用 API 服务
|
||||
## 启用方式
|
||||
|
||||
在设置页面 → API 服务 → 点击「启动服务」按钮。
|
||||
在应用设置页启用 `API 服务`。
|
||||
|
||||
默认端口:`5031`
|
||||
|
||||
## 基础地址
|
||||
|
||||
```
|
||||
http://127.0.0.1:5031
|
||||
```
|
||||
|
||||
---
|
||||
- 默认监听地址:`127.0.0.1`
|
||||
- 默认端口:`5031`
|
||||
- 基础地址:`http://127.0.0.1:5031`
|
||||
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 健康检查
|
||||
- `GET /health`
|
||||
- `GET /api/v1/health`
|
||||
- `GET /api/v1/push/messages`
|
||||
- `GET /api/v1/messages`
|
||||
- `GET /api/v1/messages/new`
|
||||
- `GET /api/v1/sessions`
|
||||
- `GET /api/v1/contacts`
|
||||
- `GET /api/v1/group-members`
|
||||
- `GET /api/v1/media/*`
|
||||
|
||||
检查 API 服务是否正常运行。
|
||||
---
|
||||
|
||||
## 1. 健康检查
|
||||
|
||||
**请求**
|
||||
```
|
||||
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
或
|
||||
|
||||
```http
|
||||
GET /api/v1/health
|
||||
```
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
@@ -36,211 +49,223 @@ GET /health
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取消息列表
|
||||
## 2. 主动推送
|
||||
|
||||
获取指定会话的消息,支持 ChatLab 格式输出。
|
||||
通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/v1/push/messages
|
||||
```
|
||||
|
||||
### 说明
|
||||
|
||||
- 需要先在设置页开启 `HTTP API 服务`
|
||||
- 同时需要开启 `主动推送`
|
||||
- 响应类型为 `text/event-stream`
|
||||
- 新消息事件名固定为 `message.new`
|
||||
- 建议接收端按 `messageKey` 去重
|
||||
|
||||
### 事件字段
|
||||
|
||||
- `event`
|
||||
- `sessionId`
|
||||
- `messageKey`
|
||||
- `avatarUrl`
|
||||
- `sourceName`
|
||||
- `groupName`(仅群聊)
|
||||
- `content`
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
|
||||
```
|
||||
|
||||
示例事件:
|
||||
|
||||
```text
|
||||
event: message.new
|
||||
data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 获取消息
|
||||
|
||||
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/v1/messages
|
||||
```
|
||||
|
||||
**参数**
|
||||
### 参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` |
|
||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
||||
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
|
||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
||||
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 |
|
||||
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) |
|
||||
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) |
|
||||
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
|
||||
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||
|
||||
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
|
||||
|
||||
**示例请求**
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 获取消息(原始格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
|
||||
|
||||
# 获取消息(ChatLab 格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
|
||||
|
||||
# 带时间范围查询
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
||||
|
||||
# 开启媒体导出(只导出图片和语音)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0
|
||||
|
||||
# 关键词过滤
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20"
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&chatlab=1"
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260131"
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0"
|
||||
```
|
||||
|
||||
**响应(原始格式)**
|
||||
### JSON 响应字段
|
||||
|
||||
顶层字段:
|
||||
|
||||
- `success`
|
||||
- `talker`
|
||||
- `count`
|
||||
- `hasMore`
|
||||
- `media.enabled`
|
||||
- `media.exportPath`
|
||||
- `media.count`
|
||||
- `messages`
|
||||
|
||||
单条消息字段:
|
||||
|
||||
- `localId`
|
||||
- `serverId`
|
||||
- `localType`
|
||||
- `createTime`
|
||||
- `isSend`
|
||||
- `senderUsername`
|
||||
- `content`
|
||||
- `rawContent`
|
||||
- `parsedContent`
|
||||
- `mediaType`
|
||||
- `mediaFileName`
|
||||
- `mediaUrl`
|
||||
- `mediaLocalPath`
|
||||
|
||||
**示例响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"talker": "wxid_xxx",
|
||||
"count": 50,
|
||||
"talker": "xxx@chatroom",
|
||||
"count": 2,
|
||||
"hasMore": true,
|
||||
"media": {
|
||||
"enabled": true,
|
||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||
"count": 12
|
||||
"count": 1
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"localId": 123,
|
||||
"serverId": "456",
|
||||
"localType": 1,
|
||||
"createTime": 1738713600,
|
||||
"isSend": 0,
|
||||
"senderUsername": "wxid_member",
|
||||
"content": "你好",
|
||||
"rawContent": "你好",
|
||||
"parsedContent": "你好"
|
||||
},
|
||||
{
|
||||
"localId": 124,
|
||||
"localType": 3,
|
||||
"createTime": 1738713660,
|
||||
"isSend": 0,
|
||||
"senderUsername": "wxid_member",
|
||||
"content": "[图片]",
|
||||
"createTime": 1738713600000,
|
||||
"senderUsername": "wxid_sender",
|
||||
"mediaType": "image",
|
||||
"mediaFileName": "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"
|
||||
"mediaFileName": "abc123.jpg",
|
||||
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg",
|
||||
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**响应(ChatLab 格式)**
|
||||
```json
|
||||
{
|
||||
"chatlab": {
|
||||
"version": "0.0.2",
|
||||
"exportedAt": 1738713600000,
|
||||
"generator": "WeFlow",
|
||||
"description": "Exported from WeFlow"
|
||||
},
|
||||
"meta": {
|
||||
"name": "会话名称",
|
||||
"platform": "wechat",
|
||||
"type": "private",
|
||||
"ownerId": "wxid_me"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
"platformId": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"groupNickname": "群昵称"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"sender": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"timestamp": 1738713600000,
|
||||
"type": 0,
|
||||
"content": "消息内容",
|
||||
"mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg"
|
||||
}
|
||||
],
|
||||
"media": {
|
||||
"enabled": true,
|
||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||
"count": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
### ChatLab 响应
|
||||
|
||||
当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构:
|
||||
|
||||
- `chatlab.version`
|
||||
- `chatlab.exportedAt`
|
||||
- `chatlab.generator`
|
||||
- `meta.name`
|
||||
- `meta.platform`
|
||||
- `meta.type`
|
||||
- `meta.groupId`
|
||||
- `meta.groupAvatar`
|
||||
- `meta.ownerId`
|
||||
- `members[].platformId`
|
||||
- `members[].accountName`
|
||||
- `members[].groupNickname`
|
||||
- `members[].avatar`
|
||||
- `messages[].sender`
|
||||
- `messages[].accountName`
|
||||
- `messages[].groupNickname`
|
||||
- `messages[].timestamp`
|
||||
- `messages[].type`
|
||||
- `messages[].content`
|
||||
- `messages[].platformMessageId`
|
||||
- `messages[].mediaPath`
|
||||
|
||||
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
||||
|
||||
---
|
||||
|
||||
### 3. 访问导出媒体文件
|
||||
|
||||
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
|
||||
## 4. 获取会话列表
|
||||
|
||||
**请求**
|
||||
```
|
||||
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. 获取会话列表
|
||||
|
||||
获取所有会话列表。
|
||||
|
||||
**请求**
|
||||
```
|
||||
```http
|
||||
GET /api/v1/sessions
|
||||
```
|
||||
|
||||
**参数**
|
||||
### 参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
GET http://127.0.0.1:5031/api/v1/sessions
|
||||
### 响应字段
|
||||
|
||||
GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
||||
```
|
||||
- `success`
|
||||
- `count`
|
||||
- `sessions[].username`
|
||||
- `sessions[].displayName`
|
||||
- `sessions[].type`
|
||||
- `sessions[].lastTimestamp`
|
||||
- `sessions[].unreadCount`
|
||||
|
||||
**示例响应**
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"total": 100,
|
||||
"count": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"username": "wxid_xxx",
|
||||
"displayName": "用户名",
|
||||
"lastMessage": "最后一条消息",
|
||||
"lastTime": 1738713600000,
|
||||
"username": "xxx@chatroom",
|
||||
"displayName": "项目群",
|
||||
"type": 2,
|
||||
"lastTimestamp": 1738713600,
|
||||
"unreadCount": 0
|
||||
}
|
||||
]
|
||||
@@ -249,40 +274,48 @@ GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
||||
|
||||
---
|
||||
|
||||
### 4. 获取联系人列表
|
||||
|
||||
获取所有联系人信息。
|
||||
## 5. 获取联系人列表
|
||||
|
||||
**请求**
|
||||
```
|
||||
|
||||
```http
|
||||
GET /api/v1/contacts
|
||||
```
|
||||
|
||||
**参数**
|
||||
### 参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `keyword` | string | ❌ | 搜索关键词 |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
GET http://127.0.0.1:5031/api/v1/contacts
|
||||
### 响应字段
|
||||
|
||||
GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
|
||||
```
|
||||
- `success`
|
||||
- `count`
|
||||
- `contacts[].username`
|
||||
- `contacts[].displayName`
|
||||
- `contacts[].remark`
|
||||
- `contacts[].nickname`
|
||||
- `contacts[].alias`
|
||||
- `contacts[].avatarUrl`
|
||||
- `contacts[].type`
|
||||
|
||||
**示例响应**
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"count": 1,
|
||||
"contacts": [
|
||||
{
|
||||
"userName": "wxid_xxx",
|
||||
"alias": "微信号",
|
||||
"nickName": "昵称",
|
||||
"remark": "备注名"
|
||||
"username": "wxid_xxx",
|
||||
"displayName": "张三",
|
||||
"remark": "客户张三",
|
||||
"nickname": "张三",
|
||||
"alias": "zhangsan",
|
||||
"avatarUrl": "https://example.com/avatar.jpg",
|
||||
"type": "friend"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -290,60 +323,157 @@ GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
|
||||
|
||||
---
|
||||
|
||||
## ChatLab 格式说明
|
||||
## 6. 获取群成员列表
|
||||
|
||||
ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。
|
||||
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||
|
||||
### 消息类型映射
|
||||
**请求**
|
||||
|
||||
| ChatLab Type | 值 | 说明 |
|
||||
|--------------|-----|------|
|
||||
| TEXT | 0 | 文本消息 |
|
||||
| IMAGE | 1 | 图片 |
|
||||
| VOICE | 2 | 语音 |
|
||||
| VIDEO | 3 | 视频 |
|
||||
| FILE | 4 | 文件 |
|
||||
| EMOJI | 5 | 表情 |
|
||||
| LINK | 7 | 链接 |
|
||||
| LOCATION | 8 | 位置 |
|
||||
| RED_PACKET | 20 | 红包 |
|
||||
| TRANSFER | 21 | 转账 |
|
||||
| CALL | 23 | 通话 |
|
||||
| SYSTEM | 80 | 系统消息 |
|
||||
| RECALL | 81 | 撤回消息 |
|
||||
| OTHER | 99 | 其他 |
|
||||
```http
|
||||
GET /api/v1/group-members
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||
|
||||
### 响应字段
|
||||
|
||||
- `success`
|
||||
- `chatroomId`
|
||||
- `count`
|
||||
- `fromCache`
|
||||
- `updatedAt`
|
||||
- `members[].wxid`
|
||||
- `members[].displayName`
|
||||
- `members[].nickname`
|
||||
- `members[].remark`
|
||||
- `members[].alias`
|
||||
- `members[].groupNickname`
|
||||
- `members[].avatarUrl`
|
||||
- `members[].isOwner`
|
||||
- `members[].isFriend`
|
||||
- `members[].messageCount`
|
||||
|
||||
**示例请求**
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
||||
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=1"
|
||||
```
|
||||
|
||||
**示例响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"chatroomId": "xxx@chatroom",
|
||||
"count": 2,
|
||||
"fromCache": false,
|
||||
"updatedAt": 1760000000000,
|
||||
"members": [
|
||||
{
|
||||
"wxid": "wxid_member_a",
|
||||
"displayName": "客户A",
|
||||
"nickname": "阿甲",
|
||||
"remark": "客户A",
|
||||
"alias": "kehua",
|
||||
"groupNickname": "甲方",
|
||||
"avatarUrl": "https://example.com/a.jpg",
|
||||
"isOwner": true,
|
||||
"isFriend": true,
|
||||
"messageCount": 128
|
||||
},
|
||||
{
|
||||
"wxid": "wxid_member_b",
|
||||
"displayName": "李四",
|
||||
"nickname": "李四",
|
||||
"remark": "",
|
||||
"alias": "",
|
||||
"groupNickname": "",
|
||||
"avatarUrl": "",
|
||||
"isOwner": false,
|
||||
"isFriend": false,
|
||||
"messageCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `displayName` 是当前应用内的主展示名。
|
||||
- `groupNickname` 是成员在该群里的群昵称。
|
||||
- `remark` 是你对该联系人的备注。
|
||||
- `alias` 是微信号。
|
||||
- 当微信源数据里没有群昵称时,`groupNickname` 会为空。
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
## 7. 访问导出媒体
|
||||
|
||||
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/v1/media/{relativePath}
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg"
|
||||
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/voices/voice_100.wav"
|
||||
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/videos/video_200.mp4"
|
||||
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
||||
```
|
||||
|
||||
### 支持的 Content-Type
|
||||
|
||||
| 扩展名 | Content-Type |
|
||||
| --- | --- |
|
||||
| `.png` | `image/png` |
|
||||
| `.jpg` / `.jpeg` | `image/jpeg` |
|
||||
| `.gif` | `image/gif` |
|
||||
| `.webp` | `image/webp` |
|
||||
| `.wav` | `audio/wav` |
|
||||
| `.mp3` | `audio/mpeg` |
|
||||
| `.mp4` | `video/mp4` |
|
||||
|
||||
常见错误响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Media not found"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 使用示例
|
||||
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
# 健康检查
|
||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
||||
|
||||
# 获取会话列表
|
||||
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
# 获取消息
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
||||
|
||||
# 获取 ChatLab 格式
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:5031/health
|
||||
|
||||
# 获取会话列表
|
||||
curl http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
# 获取消息(ChatLab 格式)
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
||||
curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三"
|
||||
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
||||
```
|
||||
|
||||
### Python
|
||||
@@ -353,39 +483,26 @@ import requests
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5031"
|
||||
|
||||
# 获取会话列表
|
||||
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
|
||||
print(sessions)
|
||||
messages = requests.get(
|
||||
f"{BASE_URL}/api/v1/messages",
|
||||
params={"talker": "xxx@chatroom", "limit": 50}
|
||||
).json()
|
||||
|
||||
members = requests.get(
|
||||
f"{BASE_URL}/api/v1/group-members",
|
||||
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
|
||||
).json()
|
||||
|
||||
# 获取消息
|
||||
messages = requests.get(f"{BASE_URL}/api/v1/messages", params={
|
||||
"talker": "wxid_xxx",
|
||||
"limit": 100,
|
||||
"chatlab": 1
|
||||
}).json()
|
||||
print(messages)
|
||||
```
|
||||
|
||||
### JavaScript / Node.js
|
||||
|
||||
```javascript
|
||||
const BASE_URL = "http://127.0.0.1:5031";
|
||||
|
||||
// 获取会话列表
|
||||
const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json());
|
||||
console.log(sessions);
|
||||
|
||||
// 获取消息(ChatLab 格式)
|
||||
const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`)
|
||||
.then(r => r.json());
|
||||
console.log(messages);
|
||||
print(members)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
## 9. 注意事项
|
||||
|
||||
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
|
||||
2. 需要先连接数据库才能查询数据
|
||||
3. 时间参数格式为 `YYYYMMDD`(如 20260205)
|
||||
4. 支持 CORS,可从浏览器前端直接调用
|
||||
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||
3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。
|
||||
4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。
|
||||
5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。
|
||||
|
||||
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>
|
||||
56
electron/exportWorker.ts
Normal file
56
electron/exportWorker.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import type { ExportOptions } from './services/exportService'
|
||||
|
||||
interface ExportWorkerConfig {
|
||||
sessionIds: string[]
|
||||
outputDir: string
|
||||
options: ExportOptions
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
}
|
||||
|
||||
const config = workerData as ExportWorkerConfig
|
||||
process.env.WEFLOW_WORKER = '1'
|
||||
if (config.resourcesPath) {
|
||||
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||
}
|
||||
if (config.userDataPath) {
|
||||
process.env.WEFLOW_USER_DATA_PATH = config.userDataPath
|
||||
process.env.WEFLOW_CONFIG_CWD = config.userDataPath
|
||||
}
|
||||
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
|
||||
|
||||
async function run() {
|
||||
const [{ wcdbService }, { exportService }] = await Promise.all([
|
||||
import('./services/wcdbService'),
|
||||
import('./services/exportService')
|
||||
])
|
||||
|
||||
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||
|
||||
const result = await exportService.exportSessions(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
String(config.outputDir || ''),
|
||||
config.options || { format: 'json' },
|
||||
(progress) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'export:progress',
|
||||
data: progress
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
parentPort?.postMessage({
|
||||
type: 'export:result',
|
||||
data: result
|
||||
})
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'export:error',
|
||||
error: String(error)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
477
electron/main.ts
477
electron/main.ts
@@ -1,6 +1,7 @@
|
||||
import './preload-env'
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { join, dirname } from 'path'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises'
|
||||
@@ -16,6 +17,8 @@ import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||
import { annualReportService } from './services/annualReportService'
|
||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||
import { KeyService } from './services/keyService'
|
||||
import { KeyServiceLinux } from './services/keyServiceLinux'
|
||||
import { KeyServiceMac } from './services/keyServiceMac'
|
||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
import { videoService } from './services/videoService'
|
||||
import { snsService, isVideoUrl } from './services/snsService'
|
||||
@@ -24,8 +27,9 @@ import { windowsHelloService } from './services/windowsHelloService'
|
||||
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
|
||||
import { cloudControlService } from './services/cloudControlService'
|
||||
|
||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { httpService } from './services/httpService'
|
||||
import { messagePushService } from './services/messagePushService'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
@@ -87,10 +91,31 @@ let onboardingWindow: BrowserWindow | null = null
|
||||
// Splash 启动窗口
|
||||
let splashWindow: BrowserWindow | null = null
|
||||
const sessionChatWindows = new Map<string, BrowserWindow>()
|
||||
const keyService = new KeyService()
|
||||
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
||||
|
||||
let keyService: any
|
||||
if (process.platform === 'darwin') {
|
||||
keyService = new KeyServiceMac()
|
||||
} else if (process.platform === 'linux') {
|
||||
// const { KeyServiceLinux } = require('./services/keyServiceLinux')
|
||||
// keyService = new KeyServiceLinux()
|
||||
|
||||
import('./services/keyServiceLinux').then(({ KeyServiceLinux }) => {
|
||||
keyService = new KeyServiceLinux();
|
||||
});
|
||||
|
||||
} else {
|
||||
keyService = new KeyService()
|
||||
}
|
||||
|
||||
let mainWindowReady = false
|
||||
let shouldShowMain = true
|
||||
let isAppQuitting = false
|
||||
let tray: Tray | null = null
|
||||
let isClosePromptVisible = false
|
||||
const chatHistoryPayloadStore = new Map<string, { sessionId: string; title?: string; recordList: any[] }>()
|
||||
|
||||
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||
|
||||
// 更新下载状态管理(Issue #294 修复)
|
||||
let isDownloadInProgress = false
|
||||
@@ -123,6 +148,47 @@ interface AnnualReportYearsTaskState {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface OpenSessionChatWindowOptions {
|
||||
source?: 'chat' | 'export'
|
||||
initialDisplayName?: string
|
||||
initialAvatarUrl?: string
|
||||
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
|
||||
const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => {
|
||||
return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat'
|
||||
}
|
||||
|
||||
const normalizeSessionChatWindowOptionString = (value: unknown): string => {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
const loadSessionChatWindowContent = (
|
||||
win: BrowserWindow,
|
||||
sessionId: string,
|
||||
source: 'chat' | 'export',
|
||||
options?: OpenSessionChatWindowOptions
|
||||
) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
sessionId,
|
||||
source
|
||||
})
|
||||
const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName)
|
||||
const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl)
|
||||
const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType)
|
||||
if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName)
|
||||
if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl)
|
||||
if (initialContactType) queryParams.set('initialContactType', initialContactType)
|
||||
const query = queryParams.toString()
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`)
|
||||
return
|
||||
}
|
||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/chat-window?${query}`
|
||||
})
|
||||
}
|
||||
|
||||
const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>()
|
||||
const annualReportYearsTaskByCacheKey = new Map<string, string>()
|
||||
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
|
||||
@@ -189,13 +255,51 @@ const isYearsLoadCanceled = (taskId: string): boolean => {
|
||||
return task?.canceled === true
|
||||
}
|
||||
|
||||
const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
|
||||
if (process.platform === 'darwin') {
|
||||
win.setWindowButtonVisibility(false)
|
||||
}
|
||||
|
||||
const emitMaximizeState = () => {
|
||||
if (win.isDestroyed()) return
|
||||
win.webContents.send('window:maximizeStateChanged', win.isMaximized() || win.isFullScreen())
|
||||
}
|
||||
|
||||
win.on('maximize', emitMaximizeState)
|
||||
win.on('unmaximize', emitMaximizeState)
|
||||
win.on('enter-full-screen', emitMaximizeState)
|
||||
win.on('leave-full-screen', emitMaximizeState)
|
||||
win.webContents.on('did-finish-load', emitMaximizeState)
|
||||
}
|
||||
|
||||
const getWindowCloseBehavior = (): WindowCloseBehavior => {
|
||||
const behavior = configService?.get('windowCloseBehavior')
|
||||
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
|
||||
}
|
||||
|
||||
const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
|
||||
if (isClosePromptVisible) return
|
||||
isClosePromptVisible = true
|
||||
win.webContents.send('window:confirmCloseRequested', {
|
||||
canMinimizeToTray: Boolean(tray)
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
// 获取图标路径 - 打包后在 resources 目录
|
||||
const { autoShow = true } = options
|
||||
let iconName = 'icon.ico';
|
||||
if (process.platform === 'linux') {
|
||||
iconName = 'icon.png';
|
||||
} else if (process.platform === 'darwin') {
|
||||
iconName = 'icon.icns';
|
||||
}
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
? join(__dirname, `../public/${iconName}`)
|
||||
: join(process.resourcesPath, iconName);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1400,
|
||||
@@ -210,13 +314,10 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
webSecurity: false // Allow loading local files (video playback)
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
color: '#00000000',
|
||||
symbolColor: '#1a1a1a',
|
||||
height: 40
|
||||
},
|
||||
titleBarOverlay: false,
|
||||
show: false
|
||||
})
|
||||
setupCustomTitleBarWindow(win)
|
||||
|
||||
// 窗口准备好后显示
|
||||
// Splash 模式下不在这里 show,由启动流程统一控制
|
||||
@@ -290,6 +391,40 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
callback(false)
|
||||
})
|
||||
|
||||
win.on('close', (e) => {
|
||||
if (isAppQuitting || win !== mainWindow) return
|
||||
e.preventDefault()
|
||||
const closeBehavior = getWindowCloseBehavior()
|
||||
|
||||
if (closeBehavior === 'quit') {
|
||||
isAppQuitting = true
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
if (closeBehavior === 'tray' && tray) {
|
||||
win.hide()
|
||||
return
|
||||
}
|
||||
|
||||
requestMainWindowCloseConfirmation(win)
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
if (mainWindow !== win) return
|
||||
|
||||
mainWindow = null
|
||||
mainWindowReady = false
|
||||
isClosePromptVisible = false
|
||||
|
||||
if (process.platform !== 'darwin' && !isAppQuitting) {
|
||||
destroyNotificationWindow()
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
@@ -306,7 +441,9 @@ function createAgreementWindow() {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
: (process.platform === 'darwin'
|
||||
? join(process.resourcesPath, 'icon.icns')
|
||||
: join(process.resourcesPath, 'icon.ico'))
|
||||
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
|
||||
@@ -356,7 +493,9 @@ function createSplashWindow(): BrowserWindow {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
: (process.platform === 'darwin'
|
||||
? join(process.resourcesPath, 'icon.icns')
|
||||
: join(process.resourcesPath, 'icon.ico'))
|
||||
|
||||
splashWindow = new BrowserWindow({
|
||||
width: 760,
|
||||
@@ -427,7 +566,9 @@ function createOnboardingWindow() {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
: (process.platform === 'darwin'
|
||||
? join(process.resourcesPath, 'icon.icns')
|
||||
: join(process.resourcesPath, 'icon.ico'))
|
||||
|
||||
onboardingWindow = new BrowserWindow({
|
||||
width: 960,
|
||||
@@ -473,7 +614,9 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
: (process.platform === 'darwin'
|
||||
? join(process.resourcesPath, 'icon.icns')
|
||||
: join(process.resourcesPath, 'icon.ico'))
|
||||
|
||||
// 获取屏幕尺寸
|
||||
const { screen } = require('electron')
|
||||
@@ -571,7 +714,9 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
: (process.platform === 'darwin'
|
||||
? join(process.resourcesPath, 'icon.icns')
|
||||
: join(process.resourcesPath, 'icon.ico'))
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 900,
|
||||
@@ -585,17 +730,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
|
||||
nodeIntegration: false,
|
||||
webSecurity: false // 允许加载本地文件
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
color: '#00000000',
|
||||
symbolColor: '#ffffff',
|
||||
height: 40
|
||||
},
|
||||
frame: false,
|
||||
show: false,
|
||||
backgroundColor: '#000000',
|
||||
autoHideMenuBar: true
|
||||
})
|
||||
|
||||
setupCustomTitleBarWindow(win)
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
})
|
||||
@@ -629,10 +771,20 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
|
||||
* 创建独立的聊天记录窗口
|
||||
*/
|
||||
function createChatHistoryWindow(sessionId: string, messageId: number) {
|
||||
return createChatHistoryRouteWindow(`/chat-history/${sessionId}/${messageId}`)
|
||||
}
|
||||
|
||||
function createChatHistoryPayloadWindow(payloadId: string) {
|
||||
return createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`)
|
||||
}
|
||||
|
||||
function createChatHistoryRouteWindow(route: string) {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
: (process.platform === 'darwin'
|
||||
? join(process.resourcesPath, 'icon.icns')
|
||||
: join(process.resourcesPath, 'icon.ico'))
|
||||
|
||||
// 根据系统主题设置窗口背景色
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
@@ -649,22 +801,19 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
||||
nodeIntegration: false
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
color: '#00000000',
|
||||
symbolColor: isDark ? '#ffffff' : '#1a1a1a',
|
||||
height: 32
|
||||
},
|
||||
titleBarOverlay: false,
|
||||
show: false,
|
||||
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
|
||||
autoHideMenuBar: true
|
||||
})
|
||||
setupCustomTitleBarWindow(win)
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-history/${sessionId}/${messageId}`)
|
||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${route}`)
|
||||
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||
@@ -678,7 +827,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
||||
})
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/chat-history/${sessionId}/${messageId}`
|
||||
hash: route
|
||||
})
|
||||
}
|
||||
|
||||
@@ -688,12 +837,18 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
||||
/**
|
||||
* 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域)
|
||||
*/
|
||||
function createSessionChatWindow(sessionId: string) {
|
||||
function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return null
|
||||
const normalizedSource = normalizeSessionChatWindowSource(options?.source)
|
||||
|
||||
const existing = sessionChatWindows.get(normalizedSessionId)
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat'
|
||||
if (trackedSource !== normalizedSource) {
|
||||
loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource, options)
|
||||
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
|
||||
}
|
||||
if (existing.isMinimized()) {
|
||||
existing.restore()
|
||||
}
|
||||
@@ -704,7 +859,9 @@ function createSessionChatWindow(sessionId: string) {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
: (process.platform === 'darwin'
|
||||
? join(process.resourcesPath, 'icon.icns')
|
||||
: join(process.resourcesPath, 'icon.ico'))
|
||||
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
|
||||
@@ -730,10 +887,9 @@ function createSessionChatWindow(sessionId: string) {
|
||||
autoHideMenuBar: true
|
||||
})
|
||||
|
||||
const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}`
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`)
|
||||
loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource, options)
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||
if (win.webContents.isDevToolsOpened()) {
|
||||
@@ -744,10 +900,6 @@ function createSessionChatWindow(sessionId: string) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/chat-window?${sessionParam}`
|
||||
})
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
@@ -759,10 +911,12 @@ function createSessionChatWindow(sessionId: string) {
|
||||
const tracked = sessionChatWindows.get(normalizedSessionId)
|
||||
if (tracked === win) {
|
||||
sessionChatWindows.delete(normalizedSessionId)
|
||||
sessionChatWindowSources.delete(normalizedSessionId)
|
||||
}
|
||||
})
|
||||
|
||||
sessionChatWindows.set(normalizedSessionId, win)
|
||||
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
|
||||
return win
|
||||
}
|
||||
|
||||
@@ -841,11 +995,14 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
ipcMain.handle('config:set', async (_, key: string, value: any) => {
|
||||
return configService?.set(key as any, value)
|
||||
const result = configService?.set(key as any, value)
|
||||
void messagePushService.handleConfigChanged(key)
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('config:clear', async () => {
|
||||
configService?.clear()
|
||||
messagePushService.handleConfigCleared()
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -900,6 +1057,17 @@ function registerIpcHandlers() {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('log:clear', async () => {
|
||||
try {
|
||||
const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log')
|
||||
await mkdir(dirname(logPath), { recursive: true })
|
||||
await writeFile(logPath, '', 'utf8')
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => {
|
||||
return exportCardDiagnosticsService.snapshot(options?.limit)
|
||||
})
|
||||
@@ -1039,10 +1207,42 @@ function registerIpcHandlers() {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('window:isMaximized', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
return Boolean(win?.isMaximized() || win?.isFullScreen())
|
||||
})
|
||||
|
||||
ipcMain.on('window:close', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.close()
|
||||
})
|
||||
|
||||
ipcMain.handle('window:respondCloseConfirm', async (_event, action: 'tray' | 'quit' | 'cancel') => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
isClosePromptVisible = false
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'tray') {
|
||||
if (tray) {
|
||||
mainWindow.hide()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (action === 'quit') {
|
||||
isAppQuitting = true
|
||||
app.quit()
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
} finally {
|
||||
isClosePromptVisible = false
|
||||
}
|
||||
})
|
||||
|
||||
// 更新窗口控件主题色
|
||||
ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
@@ -1070,9 +1270,26 @@ function registerIpcHandlers() {
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => {
|
||||
const payloadId = randomUUID()
|
||||
chatHistoryPayloadStore.set(payloadId, {
|
||||
sessionId: String(payload?.sessionId || '').trim(),
|
||||
title: String(payload?.title || '').trim() || '聊天记录',
|
||||
recordList: Array.isArray(payload?.recordList) ? payload.recordList : []
|
||||
})
|
||||
createChatHistoryPayloadWindow(payloadId)
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => {
|
||||
const payload = chatHistoryPayloadStore.get(String(payloadId || '').trim())
|
||||
if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' }
|
||||
return { success: true, payload }
|
||||
})
|
||||
|
||||
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
|
||||
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => {
|
||||
const win = createSessionChatWindow(sessionId)
|
||||
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => {
|
||||
const win = createSessionChatWindow(sessionId, options)
|
||||
return Boolean(win)
|
||||
})
|
||||
|
||||
@@ -1410,6 +1627,7 @@ function registerIpcHandlers() {
|
||||
forceRefresh?: boolean
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
}) => {
|
||||
return chatService.getExportSessionStats(sessionIds, options)
|
||||
})
|
||||
@@ -1443,7 +1661,7 @@ function registerIpcHandlers() {
|
||||
|
||||
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
|
||||
return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
|
||||
event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
|
||||
event.sender.send('chat:voiceTranscriptPartial', { sessionId, msgId, createTime, text })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1451,8 +1669,8 @@ function registerIpcHandlers() {
|
||||
return chatService.getMessageById(sessionId, localId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
|
||||
return chatService.execQuery(kind, path, sql)
|
||||
ipcMain.handle('chat:searchMessages', async (_, keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => {
|
||||
return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
|
||||
@@ -1463,6 +1681,10 @@ function registerIpcHandlers() {
|
||||
return snsService.getSnsUsernames()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getUserPostCounts', async () => {
|
||||
return snsService.getUserPostCounts()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getExportStats', async () => {
|
||||
return snsService.getExportStats()
|
||||
})
|
||||
@@ -1471,6 +1693,10 @@ function registerIpcHandlers() {
|
||||
return snsService.getExportStatsFast()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getUserPostStats', async (_, username: string) => {
|
||||
return snsService.getUserPostStats(username)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
||||
return snsService.debugResource(url)
|
||||
})
|
||||
@@ -1658,7 +1884,83 @@ function registerIpcHandlers() {
|
||||
}
|
||||
}
|
||||
|
||||
const runMainFallback = async (reason: string) => {
|
||||
console.warn(`[fallback-export-main] ${reason}`)
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||
}
|
||||
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
const logEnabled = cfg.get('logEnabled')
|
||||
const resourcesPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
const userDataPath = app.getPath('userData')
|
||||
const workerPath = join(__dirname, 'exportWorker.js')
|
||||
|
||||
const runWorker = async () => {
|
||||
return await new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
sessionIds,
|
||||
outputDir,
|
||||
options,
|
||||
resourcesPath,
|
||||
userDataPath,
|
||||
logEnabled
|
||||
}
|
||||
})
|
||||
|
||||
let settled = false
|
||||
const finalizeResolve = (value: any) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
resolve(value)
|
||||
}
|
||||
const finalizeReject = (error: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'export:progress') {
|
||||
onProgress(msg.data as ExportProgress)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:result') {
|
||||
finalizeResolve(msg.data)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:error') {
|
||||
finalizeReject(new Error(String(msg.error || '导出 Worker 执行失败')))
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (error) => {
|
||||
finalizeReject(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return
|
||||
if (code === 0) {
|
||||
finalizeResolve({ success: false, successCount: 0, failCount: 0, error: '导出 Worker 未返回结果' })
|
||||
} else {
|
||||
finalizeReject(new Error(`导出 Worker 异常退出: ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
return await runWorker()
|
||||
} catch (error) {
|
||||
return runMainFallback(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
@@ -1769,6 +2071,18 @@ function registerIpcHandlers() {
|
||||
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'groupAnalytics:getGroupMemberMessages',
|
||||
async (
|
||||
_,
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||
) => {
|
||||
return groupAnalyticsService.getGroupMemberMessages(chatroomId, memberUsername, options)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
|
||||
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
||||
})
|
||||
@@ -1929,7 +2243,6 @@ function registerIpcHandlers() {
|
||||
dbPath,
|
||||
decryptKey,
|
||||
wxid,
|
||||
nativeTimeoutMs: 5000,
|
||||
onProgress: (progress) => {
|
||||
if (isYearsLoadCanceled(taskId)) return
|
||||
const snapshot = updateTaskSnapshot({
|
||||
@@ -2317,6 +2630,10 @@ app.whenReady().then(async () => {
|
||||
// 注册 IPC 处理器
|
||||
updateSplashProgress(25, '正在初始化...')
|
||||
registerIpcHandlers()
|
||||
chatService.addDbMonitorListener((type, json) => {
|
||||
messagePushService.handleDbMonitorChange(type, json)
|
||||
})
|
||||
messagePushService.start()
|
||||
await delay(200)
|
||||
|
||||
// 检查配置状态
|
||||
@@ -2327,6 +2644,63 @@ app.whenReady().then(async () => {
|
||||
updateSplashProgress(30, '正在加载界面...')
|
||||
mainWindow = createWindow({ autoShow: false })
|
||||
|
||||
let iconName = 'icon.ico';
|
||||
if (process.platform === 'linux') {
|
||||
iconName = 'icon.png';
|
||||
} else if (process.platform === 'darwin') {
|
||||
iconName = 'icon.icns';
|
||||
}
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
|
||||
const resolvedTrayIcon = isDev
|
||||
? join(__dirname, `../public/${iconName}`)
|
||||
: join(process.resourcesPath, iconName);
|
||||
|
||||
|
||||
try {
|
||||
tray = new Tray(resolvedTrayIcon)
|
||||
tray.setToolTip('WeFlow')
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: '显示主窗口',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
isAppQuitting = true
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
])
|
||||
tray.setContextMenu(contextMenu)
|
||||
tray.on('click', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
}
|
||||
})
|
||||
tray.on('double-click', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[Tray] Failed to create tray icon:', e)
|
||||
}
|
||||
|
||||
// 配置网络服务
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||
{
|
||||
@@ -2373,10 +2747,21 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
isAppQuitting = true
|
||||
// 销毁 tray 图标
|
||||
if (tray) { try { tray.destroy() } catch {} tray = null }
|
||||
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
||||
destroyNotificationWindow()
|
||||
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
console.warn('[App] Force exit after timeout')
|
||||
app.exit(0)
|
||||
}, 5000)
|
||||
forceExitTimer.unref()
|
||||
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
||||
try { await httpService.stop() } catch {}
|
||||
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
||||
try { wcdbService.shutdown() } catch {}
|
||||
try { await wcdbService.shutdown() } catch {}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
|
||||
@@ -70,6 +70,7 @@ 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)
|
||||
},
|
||||
|
||||
@@ -86,7 +87,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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'),
|
||||
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => {
|
||||
const listener = (_: unknown, payload: { canMinimizeToTray: boolean }) => callback(payload)
|
||||
ipcRenderer.on('window:confirmCloseRequested', listener)
|
||||
return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener)
|
||||
},
|
||||
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') =>
|
||||
ipcRenderer.invoke('window:respondCloseConfirm', action),
|
||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||
@@ -99,8 +113,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
||||
openSessionChatWindow: (sessionId: string) =>
|
||||
ipcRenderer.invoke('window:openSessionChatWindow', sessionId)
|
||||
openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: any[] }) =>
|
||||
ipcRenderer.invoke('window:openChatHistoryPayloadWindow', payload),
|
||||
getChatHistoryPayload: (payloadId: string) =>
|
||||
ipcRenderer.invoke('window:getChatHistoryPayload', payloadId),
|
||||
openSessionChatWindow: (
|
||||
sessionId: string,
|
||||
options?: {
|
||||
source?: 'chat' | 'export'
|
||||
initialDisplayName?: string
|
||||
initialAvatarUrl?: string
|
||||
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
) =>
|
||||
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
|
||||
},
|
||||
|
||||
// 数据库路径
|
||||
@@ -174,7 +200,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
|
||||
getExportSessionStats: (
|
||||
sessionIds: string[],
|
||||
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean }
|
||||
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),
|
||||
@@ -187,16 +219,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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) => {
|
||||
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
||||
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
|
||||
const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload)
|
||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||
},
|
||||
execQuery: (kind: string, path: string | null, sql: string) =>
|
||||
ipcRenderer.invoke('chat:execQuery', kind, path, sql),
|
||||
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
||||
getMessage: (sessionId: string, localId: number) =>
|
||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
|
||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
||||
ipcRenderer.on('wcdb-change', callback)
|
||||
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
||||
@@ -214,12 +246,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
||||
ipcRenderer.invoke('image:preload', payloads),
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||
ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('image:updateAvailable')
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||
ipcRenderer.on('image:updateAvailable', listener)
|
||||
return () => ipcRenderer.removeListener('image:updateAvailable', listener)
|
||||
},
|
||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
|
||||
ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('image:cacheResolved')
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
|
||||
ipcRenderer.on('image:cacheResolved', listener)
|
||||
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -262,6 +296,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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),
|
||||
getGroupMemberMessages: (
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||
) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options),
|
||||
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||
@@ -317,7 +356,20 @@ 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; currentSessionId?: string; phase: string }) => void) => {
|
||||
onProgress: (callback: (payload: {
|
||||
current: number
|
||||
total: number
|
||||
currentSession: string
|
||||
currentSessionId?: string
|
||||
phase: string
|
||||
phaseProgress?: number
|
||||
phaseTotal?: number
|
||||
phaseLabel?: string
|
||||
collectedMessages?: number
|
||||
exportedMessages?: number
|
||||
estimatedTotalMessages?: number
|
||||
writtenFiles?: number
|
||||
}) => void) => {
|
||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||
}
|
||||
@@ -339,8 +391,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),
|
||||
|
||||
@@ -68,29 +68,14 @@ class AnalyticsService {
|
||||
return new Set(this.getExcludedUsernamesList())
|
||||
}
|
||||
|
||||
private escapeSqlValue(value: string): string {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {}
|
||||
if (usernames.length === 0) return map
|
||||
|
||||
// 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(',')
|
||||
const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
if (!result.success || !result.rows) continue
|
||||
for (const row of result.rows as Record<string, any>[]) {
|
||||
const username = row.username || ''
|
||||
const alias = row.alias || ''
|
||||
if (username && alias) {
|
||||
map[username] = alias
|
||||
}
|
||||
}
|
||||
const result = await wcdbService.getContactAliasMap(usernames)
|
||||
if (!result.success || !result.map) return map
|
||||
for (const [username, alias] of Object.entries(result.map)) {
|
||||
if (username && alias) map[username] = alias
|
||||
}
|
||||
|
||||
return map
|
||||
|
||||
@@ -278,16 +278,16 @@ class AnnualReportService {
|
||||
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) {
|
||||
const result = await wcdbService.getMessageTableColumns(dbPath, tableName)
|
||||
if (!result.success || !Array.isArray(result.columns) || result.columns.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()
|
||||
for (const columnName of result.columns) {
|
||||
const name = String(columnName || '').trim().toLowerCase()
|
||||
if (name) columns.add(name)
|
||||
}
|
||||
|
||||
@@ -309,10 +309,11 @@ class AnnualReportService {
|
||||
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 result = await wcdbService.getMessageTableTimeRange(dbPath, tableName)
|
||||
if (!result.success || !result.data) return null
|
||||
const row = result.data as Record<string, any>
|
||||
const actualColumn = String(row.column || '').trim().toLowerCase()
|
||||
if (column && actualColumn && column.toLowerCase() !== actualColumn) return null
|
||||
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 }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ class CloudControlService {
|
||||
private deviceId: string = ''
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private pages: Set<string> = new Set()
|
||||
private platformVersionCache: string | null = null
|
||||
|
||||
async init() {
|
||||
this.deviceId = this.getDeviceId()
|
||||
@@ -47,7 +48,12 @@ class CloudControlService {
|
||||
}
|
||||
|
||||
private getPlatformVersion(): string {
|
||||
if (this.platformVersionCache) {
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
const os = require('os')
|
||||
const fs = require('fs')
|
||||
const platform = process.platform
|
||||
|
||||
if (platform === 'win32') {
|
||||
@@ -59,14 +65,79 @@ class CloudControlService {
|
||||
|
||||
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
|
||||
if (major === 10 && minor === 0 && build >= 22000) {
|
||||
return 'Windows 11'
|
||||
this.platformVersionCache = 'Windows 11'
|
||||
return this.platformVersionCache
|
||||
} else if (major === 10) {
|
||||
return 'Windows 10'
|
||||
this.platformVersionCache = 'Windows 10'
|
||||
return this.platformVersionCache
|
||||
}
|
||||
return `Windows ${release}`
|
||||
this.platformVersionCache = `Windows ${release}`
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
return platform
|
||||
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()
|
||||
this.platformVersionCache = `macOS ${macVersion}`
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
try {
|
||||
const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release']
|
||||
for (const filePath of osReleasePaths) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const values: Record<string, string> = {}
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const separatorIndex = trimmed.indexOf('=')
|
||||
if (separatorIndex <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, separatorIndex)
|
||||
let value = trimmed.slice(separatorIndex + 1).trim()
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
if (values.PRETTY_NAME) {
|
||||
this.platformVersionCache = values.PRETTY_NAME
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
if (values.NAME && values.VERSION_ID) {
|
||||
this.platformVersionCache = `${values.NAME} ${values.VERSION_ID}`
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
if (values.NAME) {
|
||||
this.platformVersionCache = values.NAME
|
||||
return this.platformVersionCache
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[CloudControl] Failed to detect Linux distro version:', error)
|
||||
}
|
||||
|
||||
this.platformVersionCache = `Linux ${os.release()}`
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
this.platformVersionCache = platform
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
recordPage(pageName: string) {
|
||||
@@ -88,4 +159,3 @@ class CloudControlService {
|
||||
|
||||
export const cloudControlService = new CloudControlService()
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ interface ConfigSchema {
|
||||
imageXorKey: number
|
||||
imageAesKey: string
|
||||
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||||
|
||||
exportPath?: string;
|
||||
// 缓存相关
|
||||
cachePath: string
|
||||
lastOpenedDb: string
|
||||
@@ -47,9 +47,11 @@ interface ConfigSchema {
|
||||
|
||||
// 通知
|
||||
notificationEnabled: boolean
|
||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
messagePushEnabled: boolean
|
||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||
wordCloudExcludeWords: string[]
|
||||
}
|
||||
|
||||
@@ -82,9 +84,7 @@ export class ConfigService {
|
||||
return ConfigService.instance
|
||||
}
|
||||
ConfigService.instance = this
|
||||
this.store = new Store<ConfigSchema>({
|
||||
name: 'WeFlow-config',
|
||||
defaults: {
|
||||
const defaults: ConfigSchema = {
|
||||
dbPath: '',
|
||||
decryptKey: '',
|
||||
myWxid: '',
|
||||
@@ -116,9 +116,39 @@ export class ConfigService {
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: [],
|
||||
messagePushEnabled: false,
|
||||
windowCloseBehavior: 'ask',
|
||||
wordCloudExcludeWords: []
|
||||
}
|
||||
})
|
||||
|
||||
const storeOptions: any = {
|
||||
name: 'WeFlow-config',
|
||||
defaults,
|
||||
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
|
||||
}
|
||||
const runningInWorker = process.env.WEFLOW_WORKER === '1'
|
||||
if (runningInWorker) {
|
||||
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
|
||||
if (cwd) {
|
||||
storeOptions.cwd = cwd
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.store = new Store<ConfigSchema>(storeOptions)
|
||||
} catch (error) {
|
||||
const message = String((error as Error)?.message || error || '')
|
||||
if (message.includes('projectName')) {
|
||||
const fallbackOptions = {
|
||||
...storeOptions,
|
||||
projectName: 'WeFlow',
|
||||
cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd()
|
||||
}
|
||||
this.store = new Store<ConfigSchema>(fallbackOptions)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
this.migrateAuthFields()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,90 @@
|
||||
import { join, basename } from 'path'
|
||||
import { existsSync, readdirSync, statSync } from 'fs'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { createDecipheriv } from 'crypto'
|
||||
|
||||
export interface WxidInfo {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export class DbPathService {
|
||||
private readVarint(buf: Buffer, offset: number): { value: number, length: number } {
|
||||
let value = 0;
|
||||
let length = 0;
|
||||
let shift = 0;
|
||||
while (offset < buf.length && shift < 32) {
|
||||
const b = buf[offset++];
|
||||
value |= (b & 0x7f) << shift;
|
||||
length++;
|
||||
if ((b & 0x80) === 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
return { value, length };
|
||||
}
|
||||
|
||||
private extractMmkvString(buf: Buffer, keyName: string): string {
|
||||
const keyBuf = Buffer.from(keyName, 'utf8');
|
||||
const idx = buf.indexOf(keyBuf);
|
||||
if (idx === -1) return '';
|
||||
|
||||
try {
|
||||
let offset = idx + keyBuf.length;
|
||||
const v1 = this.readVarint(buf, offset);
|
||||
offset += v1.length;
|
||||
const v2 = this.readVarint(buf, offset);
|
||||
offset += v2.length;
|
||||
|
||||
// 合理性检查
|
||||
if (v2.value > 0 && v2.value <= 10000 && offset + v2.value <= buf.length) {
|
||||
return buf.toString('utf8', offset, offset + v2.value);
|
||||
}
|
||||
} catch { }
|
||||
return '';
|
||||
}
|
||||
|
||||
private parseGlobalConfig(rootPath: string): { wxid: string, nickname: string, avatarUrl: string } | null {
|
||||
try {
|
||||
const configPath = join(rootPath, 'all_users', 'config', 'global_config');
|
||||
if (!existsSync(configPath)) return null;
|
||||
|
||||
const fullData = readFileSync(configPath);
|
||||
if (fullData.length <= 4) return null;
|
||||
const encryptedData = fullData.subarray(4);
|
||||
|
||||
const key = Buffer.alloc(16, 0);
|
||||
Buffer.from('xwechat_crypt_key').copy(key); // 直接硬编码,iv更是不重要
|
||||
const iv = Buffer.alloc(16, 0);
|
||||
|
||||
const decipher = createDecipheriv('aes-128-cfb', key, iv);
|
||||
decipher.setAutoPadding(false);
|
||||
const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
|
||||
|
||||
const wxid = this.extractMmkvString(decrypted, 'mmkv_key_user_name');
|
||||
const nickname = this.extractMmkvString(decrypted, 'mmkv_key_nick_name');
|
||||
let avatarUrl = this.extractMmkvString(decrypted, 'mmkv_key_head_img_url');
|
||||
|
||||
if (!avatarUrl && decrypted.includes('http')) {
|
||||
const httpIdx = decrypted.indexOf('http');
|
||||
const nullIdx = decrypted.indexOf(0x00, httpIdx);
|
||||
if (nullIdx !== -1) {
|
||||
avatarUrl = decrypted.toString('utf8', httpIdx, nullIdx);
|
||||
}
|
||||
}
|
||||
|
||||
if (wxid || nickname) {
|
||||
return { wxid, nickname, avatarUrl };
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error('解析 global_config 失败:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 自动检测微信数据库根目录
|
||||
*/
|
||||
@@ -16,8 +93,13 @@ export class DbPathService {
|
||||
const possiblePaths: string[] = []
|
||||
const home = homedir()
|
||||
|
||||
// 微信4.x 数据目录
|
||||
// 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) {
|
||||
@@ -130,21 +212,16 @@ export class DbPathService {
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try {
|
||||
stat = statSync(entryPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
try { stat = statSync(entryPath) } catch { continue }
|
||||
if (!stat.isDirectory()) continue
|
||||
const lower = entry.toLowerCase()
|
||||
if (lower === 'all_users') continue
|
||||
if (!entry.includes('_')) continue
|
||||
|
||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (wxids.length === 0) {
|
||||
const rootName = basename(rootPath)
|
||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||
@@ -154,11 +231,24 @@ export class DbPathService {
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return wxids.sort((a, b) => {
|
||||
const sorted = wxids.sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
});
|
||||
|
||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||
if (globalInfo) {
|
||||
for (const w of sorted) {
|
||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||
w.nickname = globalInfo.nickname;
|
||||
w.avatarUrl = globalInfo.avatarUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 扫描 wxid 列表
|
||||
@@ -182,10 +272,21 @@ export class DbPathService {
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return wxids.sort((a, b) => {
|
||||
const sorted = wxids.sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
});
|
||||
|
||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||
if (globalInfo) {
|
||||
for (const w of sorted) {
|
||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||
w.nickname = globalInfo.nickname;
|
||||
w.avatarUrl = globalInfo.avatarUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,6 +294,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')
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,12 @@ export interface GroupMediaStats {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface GroupMemberMessagesPage {
|
||||
messages: Message[]
|
||||
hasMore: boolean
|
||||
nextCursor: number
|
||||
}
|
||||
|
||||
interface GroupMemberContactInfo {
|
||||
remark: string
|
||||
nickName: string
|
||||
@@ -224,10 +230,9 @@ class GroupAnalyticsService {
|
||||
}
|
||||
|
||||
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])
|
||||
const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
||||
if (roomExt.success && roomExt.extBuffer) {
|
||||
const owner = tryResolve({ ext_buffer: roomExt.extBuffer })
|
||||
if (owner) return owner
|
||||
}
|
||||
} catch {
|
||||
@@ -255,20 +260,46 @@ class GroupAnalyticsService {
|
||||
* 从 DLL 获取群成员的群昵称
|
||||
*/
|
||||
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||
const nicknameMap = new Map<string, string>()
|
||||
|
||||
try {
|
||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||
return new Map<string, string>()
|
||||
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||
if (dllResult.success && dllResult.nicknames) {
|
||||
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom dll error:', e)
|
||||
}
|
||||
|
||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
||||
if (!extBuffer) return new Map<string, string>()
|
||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
|
||||
try {
|
||||
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
||||
if (!result.success || !result.extBuffer) {
|
||||
return nicknameMap
|
||||
}
|
||||
|
||||
const extBuffer = this.decodeExtBuffer(result.extBuffer)
|
||||
if (!extBuffer) return nicknameMap
|
||||
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
|
||||
return nicknameMap
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom error:', e)
|
||||
return new Map<string, string>()
|
||||
return nicknameMap
|
||||
}
|
||||
}
|
||||
|
||||
private mergeGroupNicknameEntries(
|
||||
target: Map<string, string>,
|
||||
entries: Iterable<[string, string]>
|
||||
): void {
|
||||
for (const [memberIdRaw, nicknameRaw] of entries) {
|
||||
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
|
||||
if (!nickname) continue
|
||||
for (const alias of this.buildIdCandidates([memberIdRaw])) {
|
||||
if (!alias) continue
|
||||
if (!target.has(alias)) target.set(alias, nickname)
|
||||
const lower = alias.toLowerCase()
|
||||
if (!target.has(lower)) target.set(lower, nickname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,19 +581,9 @@ class GroupAnalyticsService {
|
||||
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>[])
|
||||
const result = await wcdbService.getContactsCompact(batch)
|
||||
if (!result.success || !result.contacts) continue
|
||||
appendContactsToLookup(result.contacts as Record<string, unknown>[])
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
@@ -741,36 +762,246 @@ class GroupAnalyticsService {
|
||||
return ''
|
||||
}
|
||||
|
||||
private normalizeCursorTimestamp(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
const normalized = Math.floor(value)
|
||||
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
|
||||
}
|
||||
|
||||
private extractRowSenderUsername(row: Record<string, any>): string {
|
||||
const candidates = [
|
||||
row.sender_username,
|
||||
row.senderUsername,
|
||||
row.sender,
|
||||
row.WCDB_CT_sender_username
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const value = String(candidate || '').trim()
|
||||
if (value) return value
|
||||
}
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
const normalizedKey = key.toLowerCase()
|
||||
if (
|
||||
normalizedKey === 'sender_username' ||
|
||||
normalizedKey === 'senderusername' ||
|
||||
normalizedKey === 'sender' ||
|
||||
normalizedKey === 'wcdb_ct_sender_username'
|
||||
) {
|
||||
const normalizedValue = String(value || '').trim()
|
||||
if (normalizedValue) return normalizedValue
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
||||
try {
|
||||
const mapped = chatService.mapRowsToMessagesForApi([row])
|
||||
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async openMemberMessageCursor(
|
||||
chatroomId: string,
|
||||
batchSize: number,
|
||||
ascending: boolean,
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): Promise<{ success: boolean; cursor?: number; error?: string }> {
|
||||
const beginTimestamp = this.normalizeCursorTimestamp(startTime)
|
||||
const endTimestamp = this.normalizeCursorTimestamp(endTime)
|
||||
const liteResult = await wcdbService.openMessageCursorLite(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
if (liteResult.success && liteResult.cursor) return liteResult
|
||||
return wcdbService.openMessageCursor(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
}
|
||||
|
||||
private async collectMessagesByMember(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
|
||||
const batchSize = 500
|
||||
const batchSize = 800
|
||||
const matchedMessages: Message[] = []
|
||||
let offset = 0
|
||||
|
||||
while (true) {
|
||||
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true)
|
||||
if (!batch.success || !batch.messages) {
|
||||
return { success: false, error: batch.error || '获取群消息失败' }
|
||||
const senderMatchCache = new Map<string, boolean>()
|
||||
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||
const key = String(sender || '').trim().toLowerCase()
|
||||
if (!key) return false
|
||||
const cached = senderMatchCache.get(key)
|
||||
if (typeof cached === 'boolean') return cached
|
||||
const matched = this.isSameAccountIdentity(memberUsername, sender)
|
||||
senderMatchCache.set(key, matched)
|
||||
return matched
|
||||
}
|
||||
|
||||
for (const message of batch.messages) {
|
||||
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) {
|
||||
const cursorResult = await this.openMemberMessageCursor(chatroomId, batchSize, true, startTime, endTime)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '创建群消息游标失败' }
|
||||
}
|
||||
|
||||
const cursor = cursorResult.cursor
|
||||
try {
|
||||
while (true) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batch.success) {
|
||||
return { success: false, error: batch.error || '获取群消息失败' }
|
||||
}
|
||||
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||
if (rows.length === 0) break
|
||||
|
||||
for (const row of rows) {
|
||||
const senderFromRow = this.extractRowSenderUsername(row)
|
||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
const message = this.parseSingleMessageRow(row)
|
||||
if (!message) continue
|
||||
if (matchesTargetSender(message.senderUsername)) {
|
||||
matchedMessages.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchedCount = batch.messages.length
|
||||
if (fetchedCount <= 0 || !batch.hasMore) break
|
||||
offset += fetchedCount
|
||||
if (!batch.hasMore) break
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
}
|
||||
|
||||
return { success: true, data: matchedMessages }
|
||||
}
|
||||
|
||||
async getGroupMemberMessages(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||
): Promise<{ success: boolean; data?: GroupMemberMessagesPage; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success) return { success: false, error: conn.error }
|
||||
|
||||
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||
if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' }
|
||||
|
||||
const startTimeValue = Number.isFinite(options?.startTime) && typeof options?.startTime === 'number'
|
||||
? Math.max(0, Math.floor(options.startTime))
|
||||
: 0
|
||||
const endTimeValue = Number.isFinite(options?.endTime) && typeof options?.endTime === 'number'
|
||||
? Math.max(0, Math.floor(options.endTime))
|
||||
: 0
|
||||
const limit = Number.isFinite(options?.limit) && typeof options?.limit === 'number'
|
||||
? Math.max(1, Math.min(100, Math.floor(options.limit)))
|
||||
: 50
|
||||
let cursor = Number.isFinite(options?.cursor) && typeof options?.cursor === 'number'
|
||||
? Math.max(0, Math.floor(options.cursor))
|
||||
: 0
|
||||
|
||||
const matchedMessages: Message[] = []
|
||||
const senderMatchCache = new Map<string, boolean>()
|
||||
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||
const key = String(sender || '').trim().toLowerCase()
|
||||
if (!key) return false
|
||||
const cached = senderMatchCache.get(key)
|
||||
if (typeof cached === 'boolean') return cached
|
||||
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
|
||||
senderMatchCache.set(key, matched)
|
||||
return matched
|
||||
}
|
||||
const batchSize = Math.max(limit * 4, 240)
|
||||
let hasMore = false
|
||||
|
||||
const cursorResult = await this.openMemberMessageCursor(
|
||||
normalizedChatroomId,
|
||||
batchSize,
|
||||
false,
|
||||
startTimeValue,
|
||||
endTimeValue
|
||||
)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '创建群成员消息游标失败' }
|
||||
}
|
||||
|
||||
let consumedRows = 0
|
||||
const dbCursor = cursorResult.cursor
|
||||
|
||||
try {
|
||||
while (matchedMessages.length < limit) {
|
||||
const batch = await wcdbService.fetchMessageBatch(dbCursor)
|
||||
if (!batch.success) {
|
||||
return { success: false, error: batch.error || '获取群成员消息失败' }
|
||||
}
|
||||
|
||||
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||
if (rows.length === 0) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
let startIndex = 0
|
||||
if (cursor > consumedRows) {
|
||||
const skipCount = Math.min(cursor - consumedRows, rows.length)
|
||||
consumedRows += skipCount
|
||||
startIndex = skipCount
|
||||
if (startIndex >= rows.length) {
|
||||
if (!batch.hasMore) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = startIndex; index < rows.length; index += 1) {
|
||||
const row = rows[index]
|
||||
consumedRows += 1
|
||||
|
||||
const senderFromRow = this.extractRowSenderUsername(row)
|
||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const message = this.parseSingleMessageRow(row)
|
||||
if (!message) continue
|
||||
if (!matchesTargetSender(message.senderUsername)) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchedMessages.push(message)
|
||||
if (matchedMessages.length >= limit) {
|
||||
cursor = consumedRows
|
||||
hasMore = index < rows.length - 1 || batch.hasMore === true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedMessages.length >= limit) break
|
||||
|
||||
cursor = consumedRows
|
||||
if (!batch.hasMore) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(dbCursor)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
messages: matchedMessages,
|
||||
hasMore,
|
||||
nextCursor: cursor
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
|
||||
@@ -11,6 +11,7 @@ import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { videoService } from './videoService'
|
||||
import { imageDecryptService } from './imageDecryptService'
|
||||
import { groupAnalyticsService } from './groupAnalyticsService'
|
||||
|
||||
// ChatLab 格式定义
|
||||
interface ChatLabHeader {
|
||||
@@ -102,6 +103,8 @@ class HttpService {
|
||||
private port: number = 5031
|
||||
private running: boolean = false
|
||||
private connections: Set<import('net').Socket> = new Set()
|
||||
private messagePushClients: Set<http.ServerResponse> = new Set()
|
||||
private messagePushHeartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private connectionMutex: boolean = false
|
||||
|
||||
constructor() {
|
||||
@@ -152,6 +155,7 @@ class HttpService {
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => {
|
||||
this.running = true
|
||||
this.startMessagePushHeartbeat()
|
||||
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
||||
resolve({ success: true, port: this.port })
|
||||
})
|
||||
@@ -164,6 +168,16 @@ class HttpService {
|
||||
async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
for (const client of this.messagePushClients) {
|
||||
try {
|
||||
client.end()
|
||||
} catch {}
|
||||
}
|
||||
this.messagePushClients.clear()
|
||||
if (this.messagePushHeartbeatTimer) {
|
||||
clearInterval(this.messagePushHeartbeatTimer)
|
||||
this.messagePushHeartbeatTimer = null
|
||||
}
|
||||
// 使用互斥锁保护连接集合操作
|
||||
this.connectionMutex = true
|
||||
const socketsToClose = Array.from(this.connections)
|
||||
@@ -210,6 +224,28 @@ class HttpService {
|
||||
return this.getApiMediaExportPath()
|
||||
}
|
||||
|
||||
getMessagePushStreamUrl(): string {
|
||||
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
|
||||
}
|
||||
|
||||
broadcastMessagePush(payload: Record<string, unknown>): void {
|
||||
if (!this.running || this.messagePushClients.size === 0) return
|
||||
const eventBody = `event: message.new\ndata: ${JSON.stringify(payload)}\n\n`
|
||||
|
||||
for (const client of Array.from(this.messagePushClients)) {
|
||||
try {
|
||||
if (client.writableEnded || client.destroyed) {
|
||||
this.messagePushClients.delete(client)
|
||||
continue
|
||||
}
|
||||
client.write(eventBody)
|
||||
} catch {
|
||||
this.messagePushClients.delete(client)
|
||||
try { client.end() } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求
|
||||
*/
|
||||
@@ -232,12 +268,16 @@ class HttpService {
|
||||
// 路由处理
|
||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||
this.sendJson(res, { status: 'ok' })
|
||||
} else if (pathname === '/api/v1/push/messages') {
|
||||
this.handleMessagePushStream(req, res)
|
||||
} else if (pathname === '/api/v1/messages') {
|
||||
await this.handleMessages(url, res)
|
||||
} else if (pathname === '/api/v1/sessions') {
|
||||
await this.handleSessions(url, res)
|
||||
} else if (pathname === '/api/v1/contacts') {
|
||||
await this.handleContacts(url, res)
|
||||
} else if (pathname === '/api/v1/group-members') {
|
||||
await this.handleGroupMembers(url, res)
|
||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||
this.handleMediaRequest(pathname, res)
|
||||
} else {
|
||||
@@ -249,6 +289,50 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
private startMessagePushHeartbeat(): void {
|
||||
if (this.messagePushHeartbeatTimer) return
|
||||
this.messagePushHeartbeatTimer = setInterval(() => {
|
||||
for (const client of Array.from(this.messagePushClients)) {
|
||||
try {
|
||||
if (client.writableEnded || client.destroyed) {
|
||||
this.messagePushClients.delete(client)
|
||||
continue
|
||||
}
|
||||
client.write(': ping\n\n')
|
||||
} catch {
|
||||
this.messagePushClients.delete(client)
|
||||
try { client.end() } catch {}
|
||||
}
|
||||
}
|
||||
}, 25000)
|
||||
}
|
||||
|
||||
private handleMessagePushStream(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
if (this.configService.get('messagePushEnabled') !== true) {
|
||||
this.sendError(res, 403, 'Message push is disabled')
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
res.flushHeaders?.()
|
||||
res.write(`event: ready\ndata: ${JSON.stringify({ success: true, stream: this.getMessagePushStreamUrl() })}\n\n`)
|
||||
|
||||
this.messagePushClients.add(res)
|
||||
|
||||
const cleanup = () => {
|
||||
this.messagePushClients.delete(res)
|
||||
}
|
||||
|
||||
req.on('close', cleanup)
|
||||
res.on('close', cleanup)
|
||||
res.on('error', cleanup)
|
||||
}
|
||||
|
||||
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
||||
const mediaBasePath = this.getApiMediaExportPath()
|
||||
const relativePath = pathname.replace('/api/v1/media/', '')
|
||||
@@ -340,6 +424,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)
|
||||
@@ -359,6 +444,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)
|
||||
@@ -553,6 +673,54 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理群成员查询
|
||||
* GET /api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=0
|
||||
*/
|
||||
private async handleGroupMembers(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const chatroomId = (url.searchParams.get('chatroomId') || url.searchParams.get('talker') || '').trim()
|
||||
const includeMessageCounts = this.parseBooleanParam(url, ['includeMessageCounts', 'withCounts'], false)
|
||||
const forceRefresh = this.parseBooleanParam(url, ['forceRefresh'], false)
|
||||
|
||||
if (!chatroomId) {
|
||||
this.sendError(res, 400, 'Missing chatroomId')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await groupAnalyticsService.getGroupMembersPanelData(chatroomId, {
|
||||
forceRefresh,
|
||||
includeMessageCounts
|
||||
})
|
||||
if (!result.success || !result.data) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get group members')
|
||||
return
|
||||
}
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
chatroomId,
|
||||
count: result.data.length,
|
||||
fromCache: result.fromCache,
|
||||
updatedAt: result.updatedAt,
|
||||
members: result.data.map((member) => ({
|
||||
wxid: member.username,
|
||||
displayName: member.displayName,
|
||||
nickname: member.nickname || '',
|
||||
remark: member.remark || '',
|
||||
alias: member.alias || '',
|
||||
groupNickname: member.groupNickname || '',
|
||||
avatarUrl: member.avatarUrl,
|
||||
isOwner: Boolean(member.isOwner),
|
||||
isFriend: Boolean(member.isFriend),
|
||||
messageCount: Number.isFinite(member.messageCount) ? member.messageCount : 0
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
private getApiMediaExportPath(): string {
|
||||
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||
}
|
||||
@@ -762,6 +930,20 @@ class HttpService {
|
||||
return 0
|
||||
}
|
||||
|
||||
private normalizeAccountId(value: string): string {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return trimmed
|
||||
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
if (match) return match[1]
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取显示名称
|
||||
*/
|
||||
@@ -778,6 +960,110 @@ class HttpService {
|
||||
return {}
|
||||
}
|
||||
|
||||
private async getAvatarUrls(usernames: string[]): Promise<Record<string, string>> {
|
||||
const lookupUsernames = Array.from(new Set(
|
||||
usernames.flatMap((username) => {
|
||||
const normalized = String(username || '').trim()
|
||||
if (!normalized) return []
|
||||
const cleaned = this.normalizeAccountId(normalized)
|
||||
return cleaned && cleaned !== normalized ? [normalized, cleaned] : [normalized]
|
||||
})
|
||||
))
|
||||
|
||||
if (lookupUsernames.length === 0) return {}
|
||||
|
||||
try {
|
||||
const result = await wcdbService.getAvatarUrls(lookupUsernames)
|
||||
if (result.success && result.map) {
|
||||
const avatarMap: Record<string, string> = {}
|
||||
for (const [username, avatarUrl] of Object.entries(result.map)) {
|
||||
const normalizedUsername = String(username || '').trim()
|
||||
const normalizedAvatarUrl = String(avatarUrl || '').trim()
|
||||
if (!normalizedUsername || !normalizedAvatarUrl) continue
|
||||
|
||||
avatarMap[normalizedUsername] = normalizedAvatarUrl
|
||||
avatarMap[normalizedUsername.toLowerCase()] = normalizedAvatarUrl
|
||||
|
||||
const cleaned = this.normalizeAccountId(normalizedUsername)
|
||||
if (cleaned) {
|
||||
avatarMap[cleaned] = normalizedAvatarUrl
|
||||
avatarMap[cleaned.toLowerCase()] = normalizedAvatarUrl
|
||||
}
|
||||
}
|
||||
return avatarMap
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HttpService] Failed to get avatar urls:', e)
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
private resolveAvatarUrl(avatarMap: Record<string, string>, candidates: Array<string | undefined | null>): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
const normalized = String(candidate || '').trim()
|
||||
if (!normalized) continue
|
||||
|
||||
const cleaned = this.normalizeAccountId(normalized)
|
||||
const avatarUrl = avatarMap[normalized]
|
||||
|| avatarMap[normalized.toLowerCase()]
|
||||
|| avatarMap[cleaned]
|
||||
|| avatarMap[cleaned.toLowerCase()]
|
||||
|
||||
if (avatarUrl) return avatarUrl
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
|
||||
if (!sender) return ''
|
||||
const cleaned = this.normalizeAccountId(sender)
|
||||
return groupNicknamesMap.get(sender)
|
||||
|| groupNicknamesMap.get(sender.toLowerCase())
|
||||
|| groupNicknamesMap.get(cleaned)
|
||||
|| groupNicknamesMap.get(cleaned.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 格式
|
||||
*/
|
||||
@@ -789,6 +1075,7 @@ class HttpService {
|
||||
): Promise<ChatLabData> {
|
||||
const isGroup = talkerId.endsWith('@chatroom')
|
||||
const myWxid = this.configService.get('myWxid') || ''
|
||||
const normalizedMyWxid = this.normalizeAccountId(myWxid).toLowerCase()
|
||||
|
||||
// 收集所有发送者
|
||||
const senderSet = new Set<string>()
|
||||
@@ -807,7 +1094,21 @@ class HttpService {
|
||||
try {
|
||||
const result = await wcdbService.getGroupNicknames(talkerId)
|
||||
if (result.success && result.nicknames) {
|
||||
groupNicknamesMap = new Map(Object.entries(result.nicknames))
|
||||
groupNicknamesMap = new Map()
|
||||
for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) {
|
||||
const memberId = String(memberIdRaw || '').trim()
|
||||
const nickname = String(nicknameRaw || '').trim()
|
||||
if (!memberId || !nickname) continue
|
||||
|
||||
groupNicknamesMap.set(memberId, nickname)
|
||||
groupNicknamesMap.set(memberId.toLowerCase(), nickname)
|
||||
|
||||
const cleaned = this.normalizeAccountId(memberId)
|
||||
if (cleaned) {
|
||||
groupNicknamesMap.set(cleaned, nickname)
|
||||
groupNicknamesMap.set(cleaned.toLowerCase(), nickname)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HttpService] Failed to get group nicknames:', e)
|
||||
@@ -817,36 +1118,45 @@ 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 [memberAvatarMap, myAvatarResult, sessionAvatarInfo] = await Promise.all([
|
||||
this.getAvatarUrls(Array.from(memberMap.keys()).filter((sender) => !sender.startsWith('unknown_sender_'))),
|
||||
myWxid
|
||||
? chatService.getMyAvatarUrl()
|
||||
: Promise.resolve<{ success: boolean; avatarUrl?: string }>({ success: true }),
|
||||
isGroup ? chatService.getContactAvatar(talkerId) : Promise.resolve(null)
|
||||
])
|
||||
|
||||
for (const [sender, member] of memberMap.entries()) {
|
||||
if (sender.startsWith('unknown_sender_')) continue
|
||||
|
||||
const normalizedSender = this.normalizeAccountId(sender).toLowerCase()
|
||||
const isSelfMember = Boolean(normalizedMyWxid && normalizedSender && normalizedSender === normalizedMyWxid)
|
||||
const avatarUrl = (isSelfMember ? myAvatarResult.avatarUrl : undefined)
|
||||
|| this.resolveAvatarUrl(memberAvatarMap, isSelfMember ? [sender, myWxid] : [sender])
|
||||
|
||||
if (avatarUrl) {
|
||||
member.avatar = avatarUrl
|
||||
}
|
||||
}
|
||||
|
||||
// 转换消息
|
||||
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),
|
||||
@@ -866,6 +1176,7 @@ class HttpService {
|
||||
platform: 'wechat',
|
||||
type: isGroup ? 'group' : 'private',
|
||||
groupId: isGroup ? talkerId : undefined,
|
||||
groupAvatar: isGroup ? sessionAvatarInfo?.avatarUrl : undefined,
|
||||
ownerId: myWxid || undefined
|
||||
},
|
||||
members: Array.from(memberMap.values()),
|
||||
|
||||
@@ -55,14 +55,19 @@ type DecryptResult = {
|
||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||
}
|
||||
|
||||
type HardlinkState = {
|
||||
imageTable?: string
|
||||
dirTable?: string
|
||||
type CachedImagePayload = {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
preferFilePath?: boolean
|
||||
}
|
||||
|
||||
type DecryptImagePayload = CachedImagePayload & {
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export class ImageDecryptService {
|
||||
private configService = new ConfigService()
|
||||
private hardlinkCache = new Map<string, HardlinkState>()
|
||||
private resolvedCache = new Map<string, string>()
|
||||
private pending = new Map<string, Promise<DecryptResult>>()
|
||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||
@@ -106,7 +111,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
await this.ensureCacheIndexed()
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
@@ -116,7 +121,7 @@ export class ImageDecryptService {
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const dataUrl = this.fileToDataUrl(cached)
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(cached)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
@@ -124,8 +129,8 @@ export class ImageDecryptService {
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
|
||||
this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
this.resolvedCache.delete(key)
|
||||
@@ -136,7 +141,7 @@ export class ImageDecryptService {
|
||||
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
||||
if (existing) {
|
||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
||||
const dataUrl = this.fileToDataUrl(existing)
|
||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
@@ -144,27 +149,53 @@ export class ImageDecryptService {
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
|
||||
this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
}
|
||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到缓存图片' }
|
||||
}
|
||||
|
||||
async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise<DecryptResult> {
|
||||
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
|
||||
await this.ensureCacheIndexed()
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
}
|
||||
|
||||
if (payload.force) {
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
return { success: true, localPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
this.resolvedCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of cacheKeys) {
|
||||
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
|
||||
if (!existingHd || this.isThumbnailPath(existingHd)) continue
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
|
||||
return { success: true, localPath }
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.force) {
|
||||
const cached = this.resolvedCache.get(cacheKey)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const dataUrl = this.fileToDataUrl(cached)
|
||||
const localPath = dataUrl || this.filePathToUrl(cached)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
return { success: true, localPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
@@ -184,8 +215,44 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
async preloadImageHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||
const normalizedList = Array.from(
|
||||
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
|
||||
)
|
||||
if (normalizedList.length === 0) return
|
||||
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) return
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return
|
||||
|
||||
try {
|
||||
const ready = await this.ensureWcdbReady()
|
||||
if (!ready) return
|
||||
const requests = normalizedList.map((md5) => ({ md5, accountDir }))
|
||||
const result = await wcdbService.resolveImageHardlinkBatch(requests)
|
||||
if (!result.success || !Array.isArray(result.rows)) return
|
||||
|
||||
for (const row of result.rows) {
|
||||
const md5 = String(row?.md5 || '').trim().toLowerCase()
|
||||
if (!md5) continue
|
||||
const fullPath = String(row?.data?.full_path || '').trim()
|
||||
if (!fullPath || !existsSync(fullPath)) continue
|
||||
this.cacheDatPath(accountDir, md5, fullPath)
|
||||
const fileName = String(row?.data?.file_name || '').trim().toLowerCase()
|
||||
if (fileName) {
|
||||
this.cacheDatPath(accountDir, fileName, fullPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
}
|
||||
}
|
||||
|
||||
private async decryptImageInternal(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
|
||||
payload: DecryptImagePayload,
|
||||
cacheKey: string
|
||||
): Promise<DecryptResult> {
|
||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
|
||||
@@ -225,10 +292,9 @@ export class ImageDecryptService {
|
||||
|
||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
|
||||
const dataUrl = this.fileToDataUrl(datPath)
|
||||
const localPath = dataUrl || this.filePathToUrl(datPath)
|
||||
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(datPath)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
|
||||
@@ -240,10 +306,9 @@ export class ImageDecryptService {
|
||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||
if (!(payload.force && !isHd)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
||||
const dataUrl = this.fileToDataUrl(existing)
|
||||
const localPath = dataUrl || this.filePathToUrl(existing)
|
||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
}
|
||||
@@ -303,9 +368,11 @@ export class ImageDecryptService {
|
||||
if (!isThumb) {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
}
|
||||
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
||||
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
const localPath = payload.preferFilePath
|
||||
? outputPath
|
||||
: (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath))
|
||||
const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, emitPath)
|
||||
return { success: true, localPath, isThumb }
|
||||
} catch (e) {
|
||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
@@ -414,23 +481,37 @@ export class ImageDecryptService {
|
||||
if (!skipResolvedCache) {
|
||||
if (imageMd5) {
|
||||
const cached = this.resolvedCache.get(imageMd5)
|
||||
if (cached && existsSync(cached)) return cached
|
||||
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)) return cached
|
||||
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(accountDir, imageMd5, allowThumbnail)
|
||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
||||
if (res) return res
|
||||
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
|
||||
const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||
if (datNameRes) return datNameRes
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||
const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail)
|
||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
@@ -439,16 +520,17 @@ export class ImageDecryptService {
|
||||
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)
|
||||
@@ -462,16 +544,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
|
||||
@@ -484,14 +569,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
|
||||
@@ -510,9 +596,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
|
||||
}
|
||||
}
|
||||
@@ -634,45 +721,19 @@ export class ImageDecryptService {
|
||||
|
||||
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
|
||||
try {
|
||||
const hardlinkPath = this.resolveHardlinkDbPath(accountDir)
|
||||
if (!hardlinkPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ready = await this.ensureWcdbReady()
|
||||
if (!ready) {
|
||||
this.logInfo('[ImageDecrypt] hardlink db not ready')
|
||||
return null
|
||||
}
|
||||
|
||||
const state = await this.getHardlinkState(accountDir, hardlinkPath)
|
||||
if (!state.imageTable) {
|
||||
this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath })
|
||||
return null
|
||||
}
|
||||
const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir)
|
||||
if (!resolveResult.success || !resolveResult.data) return null
|
||||
const fileName = String(resolveResult.data.file_name || '').trim()
|
||||
const fullPath = String(resolveResult.data.full_path || '').trim()
|
||||
if (!fileName) return null
|
||||
|
||||
const escapedMd5 = this.escapeSqlString(md5)
|
||||
const rowResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
`SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1`
|
||||
)
|
||||
const row = rowResult.success && rowResult.rows ? rowResult.rows[0] : null
|
||||
|
||||
if (!row) {
|
||||
this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable })
|
||||
return null
|
||||
}
|
||||
|
||||
const dir1 = this.getRowValue(row, 'dir1')
|
||||
const dir2 = this.getRowValue(row, 'dir2')
|
||||
const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName')
|
||||
if (dir1 === undefined || dir2 === undefined || !fileName) {
|
||||
this.logInfo('[ImageDecrypt] hardlink row incomplete', { row })
|
||||
return null
|
||||
}
|
||||
|
||||
const lowerFileName = fileName.toLowerCase()
|
||||
const lowerFileName = String(fileName).toLowerCase()
|
||||
if (lowerFileName.endsWith('.dat')) {
|
||||
const baseLower = lowerFileName.slice(0, -4)
|
||||
if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) {
|
||||
@@ -681,57 +742,11 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
// dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名
|
||||
let dir1Name: string | null = null
|
||||
let dir2Name: string | null = null
|
||||
|
||||
if (state.dirTable) {
|
||||
try {
|
||||
// 通过 rowid 查询目录名
|
||||
const dir1Result = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1`
|
||||
)
|
||||
if (dir1Result.success && dir1Result.rows && dir1Result.rows.length > 0) {
|
||||
const value = this.getRowValue(dir1Result.rows[0], 'username')
|
||||
if (value) dir1Name = String(value)
|
||||
}
|
||||
|
||||
const dir2Result = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir2)} LIMIT 1`
|
||||
)
|
||||
if (dir2Result.success && dir2Result.rows && dir2Result.rows.length > 0) {
|
||||
const value = this.getRowValue(dir2Result.rows[0], 'username')
|
||||
if (value) dir2Name = String(value)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!dir1Name || !dir2Name) {
|
||||
this.logInfo('[ImageDecrypt] hardlink dir resolve miss', { dir1, dir2, dir1Name, dir2Name })
|
||||
return null
|
||||
}
|
||||
|
||||
// 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName}
|
||||
const possiblePaths = [
|
||||
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName),
|
||||
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName),
|
||||
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName),
|
||||
]
|
||||
|
||||
for (const fullPath of possiblePaths) {
|
||||
if (existsSync(fullPath)) {
|
||||
if (fullPath && existsSync(fullPath)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
|
||||
this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths })
|
||||
this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 })
|
||||
return null
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -739,35 +754,6 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise<HardlinkState> {
|
||||
const cached = this.hardlinkCache.get(hardlinkPath)
|
||||
if (cached) return cached
|
||||
|
||||
const imageResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1"
|
||||
)
|
||||
const dirResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1"
|
||||
)
|
||||
const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0
|
||||
? this.getRowValue(imageResult.rows[0], 'name')
|
||||
: undefined
|
||||
const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0
|
||||
? this.getRowValue(dirResult.rows[0], 'name')
|
||||
: undefined
|
||||
const state: HardlinkState = {
|
||||
imageTable: imageTable ? String(imageTable) : undefined,
|
||||
dirTable: dirTable ? String(dirTable) : undefined
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable })
|
||||
this.hardlinkCache.set(hardlinkPath, state)
|
||||
return state
|
||||
}
|
||||
|
||||
private async ensureWcdbReady(): Promise<boolean> {
|
||||
if (wcdbService.isReady()) return true
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
@@ -801,7 +787,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')
|
||||
@@ -810,7 +797,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
|
||||
@@ -830,34 +817,29 @@ export class ImageDecryptService {
|
||||
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
|
||||
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
|
||||
*/
|
||||
private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): 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)
|
||||
for (const targetName of targetNames) {
|
||||
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)
|
||||
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) {
|
||||
try {
|
||||
@@ -877,19 +859,13 @@ export class ImageDecryptService {
|
||||
|
||||
const now = new Date()
|
||||
const months: string[] = []
|
||||
for (let i = 0; i < 2; i++) {
|
||||
// Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months".
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
months.push(mStr)
|
||||
}
|
||||
|
||||
const targetNames = [datName]
|
||||
if (baseName !== lowerName) {
|
||||
targetNames.push(`${baseName}.dat`)
|
||||
targetNames.push(`${baseName}_t.dat`)
|
||||
targetNames.push(`${baseName}_thumb.dat`)
|
||||
}
|
||||
|
||||
const batchSize = 20
|
||||
for (let i = 0; i < sessionDirs.length; i += batchSize) {
|
||||
const batch = sessionDirs.slice(i, i + batchSize)
|
||||
@@ -919,36 +895,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
|
||||
}
|
||||
@@ -1001,23 +954,105 @@ export class ImageDecryptService {
|
||||
})
|
||||
}
|
||||
|
||||
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 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 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 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 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 {
|
||||
let base = name.toLowerCase()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private hasImageVariantSuffix(baseLower: string): boolean {
|
||||
return /[._][a-z]$/.test(baseLower)
|
||||
return this.stripDatVariantSuffix(baseLower) !== baseLower
|
||||
}
|
||||
|
||||
private isLikelyImageDatBase(baseLower: string): boolean {
|
||||
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
|
||||
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower))
|
||||
}
|
||||
|
||||
|
||||
@@ -1206,24 +1241,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 {
|
||||
@@ -1231,8 +1249,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 {
|
||||
@@ -1521,6 +1538,16 @@ export class ImageDecryptService {
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
}
|
||||
|
||||
private resolveLocalPathForPayload(filePath: string, preferFilePath?: boolean): string {
|
||||
if (preferFilePath) return filePath
|
||||
return this.resolveEmitPath(filePath, false)
|
||||
}
|
||||
|
||||
private resolveEmitPath(filePath: string, preferFilePath?: boolean): string {
|
||||
if (preferFilePath) return this.filePathToUrl(filePath)
|
||||
return this.fileToDataUrl(filePath) || this.filePathToUrl(filePath)
|
||||
}
|
||||
|
||||
private fileToDataUrl(filePath: string): string | null {
|
||||
try {
|
||||
const ext = extname(filePath).toLowerCase()
|
||||
@@ -1858,7 +1885,7 @@ export class ImageDecryptService {
|
||||
|
||||
private hasXVariant(base: string): boolean {
|
||||
const lower = base.toLowerCase()
|
||||
return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t')
|
||||
return this.stripDatVariantSuffix(lower) !== lower
|
||||
}
|
||||
|
||||
private isHdPath(p: string): boolean {
|
||||
@@ -1912,7 +1939,6 @@ export class ImageDecryptService {
|
||||
|
||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||
this.resolvedCache.clear()
|
||||
this.hardlinkCache.clear()
|
||||
this.pending.clear()
|
||||
this.updateFlags.clear()
|
||||
this.cacheIndexed = false
|
||||
|
||||
@@ -12,6 +12,7 @@ type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: stri
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||
|
||||
export class KeyService {
|
||||
private readonly isMac = process.platform === 'darwin'
|
||||
private koffi: any = null
|
||||
private lib: any = null
|
||||
private initialized = false
|
||||
@@ -605,34 +606,14 @@ export class KeyService {
|
||||
|
||||
const logs: string[] = []
|
||||
|
||||
onStatus?.('正在定位微信安装路径...', 0)
|
||||
let wechatPath = await this.findWeChatInstallPath()
|
||||
if (!wechatPath) {
|
||||
const err = '未找到微信安装路径,请确认已安装PC微信'
|
||||
onStatus?.('正在查找微信进程...', 0)
|
||||
const pid = await this.findWeChatPid()
|
||||
if (!pid) {
|
||||
const err = '未找到微信进程,请先启动微信'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
onStatus?.('正在关闭微信以进行获取...', 0)
|
||||
const closed = await this.killWeChatProcesses()
|
||||
if (!closed) {
|
||||
const err = '无法自动关闭微信,请手动退出后重试'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
onStatus?.('正在启动微信...', 0)
|
||||
const sub = spawn(wechatPath, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: dirname(wechatPath)
|
||||
})
|
||||
sub.unref()
|
||||
|
||||
onStatus?.('等待微信界面就绪...', 0)
|
||||
const pid = await this.waitForWeChatWindow()
|
||||
if (!pid) return { success: false, error: '启动微信失败或等待界面就绪超时' }
|
||||
|
||||
onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
|
||||
onStatus?.('正在检测微信界面组件...', 0)
|
||||
await this.waitForWeChatWindowComponents(pid, 15000)
|
||||
@@ -714,6 +695,68 @@ export class KeyService {
|
||||
return wxid.substring(0, second)
|
||||
}
|
||||
|
||||
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
const xorKey = code & 0xFF
|
||||
const dataToHash = code.toString() + cleanedWxid
|
||||
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
|
||||
const aesKey = md5Full.substring(0, 16)
|
||||
return { xorKey, aesKey }
|
||||
}
|
||||
|
||||
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
|
||||
try {
|
||||
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
|
||||
decipher.setAutoPadding(false)
|
||||
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise<string[]> {
|
||||
const candidates: string[] = []
|
||||
const pushUnique = (value: string) => {
|
||||
const v = String(value || '').trim()
|
||||
if (!v || candidates.includes(v)) return
|
||||
candidates.push(v)
|
||||
}
|
||||
|
||||
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
|
||||
|
||||
if (manualDir) {
|
||||
const normalized = manualDir.replace(/[\\/]+$/, '')
|
||||
const dirName = normalized.split(/[\\/]/).pop() ?? ''
|
||||
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
||||
|
||||
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
|
||||
if (marker) {
|
||||
const root = normalized.slice(0, marker.index! + marker[0].length)
|
||||
try {
|
||||
const { readdirSync, statSync } = await import('fs')
|
||||
const { join } = await import('path')
|
||||
for (const entry of readdirSync(root)) {
|
||||
if (!entry.startsWith('wxid_')) continue
|
||||
const full = join(root, entry)
|
||||
try {
|
||||
if (statSync(full).isDirectory()) pushUnique(entry)
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
pushUnique('unknown')
|
||||
return candidates
|
||||
}
|
||||
|
||||
async autoGetImageKey(
|
||||
manualDir?: string,
|
||||
onProgress?: (message: string) => void,
|
||||
@@ -749,52 +792,34 @@ export class KeyService {
|
||||
const codes: number[] = accounts[0].keys.map((k: any) => k.code)
|
||||
console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid))
|
||||
|
||||
// 优先级: 1. 直接传入的wxidParam 2. 从manualDir提取 3. DLL返回的wxid(可能是unknown)
|
||||
let targetWxid = ''
|
||||
|
||||
// 方案1: 直接使用传入的wxidParam(最优先)
|
||||
if (wxidParam && wxidParam.startsWith('wxid_')) {
|
||||
targetWxid = wxidParam
|
||||
console.log('[ImageKey] 使用直接传入的 wxid:', targetWxid)
|
||||
const wxidCandidates = await this.collectWxidCandidates(manualDir, wxidParam)
|
||||
let verifyCiphertext: Buffer | null = null
|
||||
if (manualDir && existsSync(manualDir)) {
|
||||
const template = await this._findTemplateData(manualDir, 32)
|
||||
verifyCiphertext = template.ciphertext
|
||||
}
|
||||
|
||||
// 方案2: 从 manualDir 提取前端已配置好的正确 wxid
|
||||
// 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234"
|
||||
if (!targetWxid && manualDir) {
|
||||
const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
|
||||
if (dirName.startsWith('wxid_')) {
|
||||
targetWxid = dirName
|
||||
console.log('[ImageKey] 从 manualDir 提取 wxid:', targetWxid)
|
||||
if (verifyCiphertext) {
|
||||
onProgress?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`)
|
||||
for (const candidateWxid of wxidCandidates) {
|
||||
for (const code of codes) {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
||||
onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||
console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code)
|
||||
return { success: true, xorKey, aesKey }
|
||||
}
|
||||
}
|
||||
|
||||
// 方案3: 回退到 DLL 发现的第一个(可能是 unknown)
|
||||
if (!targetWxid) {
|
||||
targetWxid = accounts[0].wxid
|
||||
console.log('[ImageKey] 无法获取 wxid,使用 DLL 发现的:', targetWxid)
|
||||
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
||||
}
|
||||
|
||||
// CleanWxid: 截断到第二个下划线,与 xkey 算法一致
|
||||
const cleanedWxid = this.cleanWxid(targetWxid)
|
||||
console.log('[ImageKey] wxid:', targetWxid, '→ cleaned:', cleanedWxid)
|
||||
|
||||
// 用 cleanedWxid + code 本地计算密钥
|
||||
// xorKey = code & 0xFF
|
||||
// aesKey = MD5(code.toString() + cleanedWxid).substring(0, 16)
|
||||
const code = codes[0]
|
||||
const xorKey = code & 0xFF
|
||||
const dataToHash = code.toString() + cleanedWxid
|
||||
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
|
||||
const aesKey = md5Full.substring(0, 16)
|
||||
|
||||
onProgress?.(`密钥获取成功 (wxid: ${targetWxid}, code: ${code})`)
|
||||
console.log('[ImageKey] 计算结果: xorKey=', xorKey, 'aesKey=', aesKey)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
xorKey,
|
||||
aesKey
|
||||
}
|
||||
// 无模板密文可验真时回退旧策略
|
||||
const fallbackWxid = wxidCandidates[0] || accounts[0].wxid || 'unknown'
|
||||
const fallbackCode = codes[0]
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||
onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
||||
console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode)
|
||||
return { success: true, xorKey, aesKey }
|
||||
}
|
||||
|
||||
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
||||
@@ -810,10 +835,20 @@ export class KeyService {
|
||||
try {
|
||||
// 1. 查找模板文件获取密文和 XOR 密钥
|
||||
onProgress?.('正在查找模板文件...')
|
||||
const { ciphertext, xorKey } = await this._findTemplateData(userDir)
|
||||
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||
let result = await this._findTemplateData(userDir, 32)
|
||||
let { ciphertext, xorKey } = result
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${(xorKey ?? 0).toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
// 如果找不到密钥,尝试扫描更多文件
|
||||
if (ciphertext && xorKey === null) {
|
||||
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
||||
result = await this._findTemplateData(userDir, 100)
|
||||
xorKey = result.xorKey
|
||||
}
|
||||
|
||||
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
|
||||
// 2. 找微信 PID
|
||||
const pid = await this.findWeChatPid()
|
||||
@@ -830,7 +865,7 @@ export class KeyService {
|
||||
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
||||
if (aesKey) {
|
||||
onProgress?.('密钥获取成功')
|
||||
return { success: true, xorKey: xorKey ?? 0, aesKey }
|
||||
return { success: true, xorKey, aesKey }
|
||||
}
|
||||
// 等 5 秒再试
|
||||
await new Promise(r => setTimeout(r, 5000))
|
||||
@@ -845,26 +880,26 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
private async _findTemplateData(userDir: string): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||
const { readdirSync, readFileSync, statSync } = await import('fs')
|
||||
const { join } = await import('path')
|
||||
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
|
||||
// 递归收集 *_t.dat 文件
|
||||
const collect = (dir: string, results: string[], limit = 32) => {
|
||||
if (results.length >= limit) return
|
||||
const collect = (dir: string, results: string[], maxFiles: number) => {
|
||||
if (results.length >= maxFiles) return
|
||||
try {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (results.length >= limit) break
|
||||
if (results.length >= maxFiles) break
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) collect(full, results, limit)
|
||||
if (entry.isDirectory()) collect(full, results, maxFiles)
|
||||
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||
}
|
||||
} catch { /* 忽略无权限目录 */ }
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
collect(userDir, files)
|
||||
collect(userDir, files, limit)
|
||||
|
||||
// 按修改时间降序
|
||||
files.sort((a, b) => {
|
||||
|
||||
292
electron/services/keyServiceLinux.ts
Normal file
292
electron/services/keyServiceLinux.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { execFile, exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||
|
||||
export class KeyServiceLinux {
|
||||
private sudo: any
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
this.sudo = require('sudo-prompt');
|
||||
} catch (e) {
|
||||
console.error('Failed to load sudo-prompt', e);
|
||||
}
|
||||
}
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const candidates: string[] = []
|
||||
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
||||
} else {
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
|
||||
}
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p
|
||||
}
|
||||
throw new Error('找不到 xkey_helper_linux,请检查路径')
|
||||
}
|
||||
|
||||
public async autoGetDbKey(
|
||||
timeoutMs = 60_000,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<DbKeyResult> {
|
||||
try {
|
||||
onStatus?.('正在尝试结束当前微信进程...', 0)
|
||||
await execAsync('killall -9 wechat wechat-bin xwechat').catch(() => {})
|
||||
// 稍微等待进程完全退出
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
onStatus?.('正在尝试拉起微信...', 0)
|
||||
const startCmds = [
|
||||
'nohup wechat >/dev/null 2>&1 &',
|
||||
'nohup wechat-bin >/dev/null 2>&1 &',
|
||||
'nohup xwechat >/dev/null 2>&1 &'
|
||||
]
|
||||
for (const cmd of startCmds) execAsync(cmd).catch(() => {})
|
||||
|
||||
onStatus?.('等待微信进程出现...', 0)
|
||||
let pid = 0
|
||||
for (let i = 0; i < 15; i++) { // 最多等 15 秒
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
|
||||
const pids = stdout.trim().split(/\s+/).filter(p => p)
|
||||
if (pids.length > 0) {
|
||||
pid = parseInt(pids[0], 10)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!pid) {
|
||||
const err = '未能自动启动微信,请手动启动并登录。'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
onStatus?.(`捕获到微信 PID: ${pid},准备获取密钥...`, 0)
|
||||
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
|
||||
return await this.getDbKey(pid, onStatus)
|
||||
} catch (err: any) {
|
||||
const errMsg = '自动获取微信 PID 失败: ' + err.message
|
||||
onStatus?.(errMsg, 2)
|
||||
return { success: false, error: errMsg }
|
||||
}
|
||||
}
|
||||
|
||||
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
|
||||
try {
|
||||
const helperPath = this.getHelperPath()
|
||||
|
||||
onStatus?.('正在扫描数据库基址...', 0)
|
||||
const { stdout: scanOut } = await execFileAsync(helperPath, ['db_scan', pid.toString()])
|
||||
const scanRes = JSON.parse(scanOut.trim())
|
||||
|
||||
if (!scanRes.success) {
|
||||
const err = scanRes.result || '扫描失败,请确保微信已完全登录'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
const targetAddr = scanRes.target_addr
|
||||
onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0)
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const options = { name: 'WeFlow' }
|
||||
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
|
||||
|
||||
this.sudo.exec(command, options, (error, stdout) => {
|
||||
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
||||
if (error) {
|
||||
onStatus?.('授权失败或被取消', 2)
|
||||
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const hookRes = JSON.parse((stdout as string).trim())
|
||||
if (hookRes.success) {
|
||||
onStatus?.('密钥获取成功', 1)
|
||||
resolve({ success: true, key: hookRes.key })
|
||||
} else {
|
||||
onStatus?.(hookRes.result, 2)
|
||||
resolve({ success: false, error: hookRes.result })
|
||||
}
|
||||
} catch (e) {
|
||||
onStatus?.('解析 Hook 结果失败', 2)
|
||||
resolve({ success: false, error: '解析 Hook 结果失败' })
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (err: any) {
|
||||
onStatus?.(err.message, 2)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
public async autoGetImageKey(
|
||||
accountPath?: string,
|
||||
onProgress?: (msg: string) => void,
|
||||
wxid?: string
|
||||
): Promise<ImageKeyResult> {
|
||||
try {
|
||||
onProgress?.('正在初始化缓存扫描...');
|
||||
const helperPath = this.getHelperPath()
|
||||
const { stdout } = await execFileAsync(helperPath, ['image_local'])
|
||||
const res = JSON.parse(stdout.trim())
|
||||
if (!res.success) return { success: false, error: res.result }
|
||||
|
||||
const accounts = res.data.accounts || []
|
||||
let account = accounts.find((a: any) => a.wxid === wxid)
|
||||
if (!account && accounts.length > 0) account = accounts[0]
|
||||
|
||||
if (account && account.keys && account.keys.length > 0) {
|
||||
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
|
||||
const keyObj = account.keys[0]
|
||||
return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey }
|
||||
}
|
||||
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
public async autoGetImageKeyByMemoryScan(
|
||||
accountPath: string,
|
||||
onProgress?: (msg: string) => void
|
||||
): Promise<ImageKeyResult> {
|
||||
try {
|
||||
onProgress?.('正在查找模板文件...')
|
||||
let result = await this._findTemplateData(accountPath, 32)
|
||||
let { ciphertext, xorKey } = result
|
||||
|
||||
if (ciphertext && xorKey === null) {
|
||||
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
||||
result = await this._findTemplateData(accountPath, 100)
|
||||
xorKey = result.xorKey
|
||||
}
|
||||
|
||||
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' }
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
|
||||
// 2. 找微信 PID
|
||||
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
|
||||
const pids = stdout.trim().split(/\s+/).filter(p => p)
|
||||
if (pids.length === 0) return { success: false, error: '微信未运行,无法扫描内存' }
|
||||
const pid = parseInt(pids[0], 10)
|
||||
|
||||
onProgress?.(`已找到微信进程 PID=${pid},正在提权扫描进程内存...`);
|
||||
|
||||
// 3. 将 Buffer 转换为 hex 传递给 helper
|
||||
const ciphertextHex = ciphertext.toString('hex')
|
||||
const helperPath = this.getHelperPath()
|
||||
|
||||
try {
|
||||
console.log(`[Debug] 准备执行 Helper: ${helperPath} image_mem ${pid} ${ciphertextHex}`);
|
||||
|
||||
const { stdout: memOut, stderr } = await execFileAsync(helperPath, ['image_mem', pid.toString(), ciphertextHex])
|
||||
|
||||
console.log(`[Debug] Helper stdout: ${memOut}`);
|
||||
if (stderr) {
|
||||
console.warn(`[Debug] Helper stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
if (!memOut || memOut.trim() === '') {
|
||||
return { success: false, error: 'Helper 返回为空,请检查是否有足够的权限(如需sudo)读取进程内存。' }
|
||||
}
|
||||
|
||||
const res = JSON.parse(memOut.trim())
|
||||
|
||||
if (res.success) {
|
||||
onProgress?.('内存扫描成功');
|
||||
return { success: true, xorKey, aesKey: res.key }
|
||||
}
|
||||
return { success: false, error: res.result || '未知错误' }
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('[Debug] 执行或解析 Helper 时发生崩溃:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: `内存扫描失败: ${err.message}\nstdout: ${err.stdout || '无'}\nstderr: ${err.stderr || '无'}`
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `内存扫描失败: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
|
||||
// 递归收集 *_t.dat 文件
|
||||
const collect = (dir: string, results: string[], maxFiles: number) => {
|
||||
if (results.length >= maxFiles) return
|
||||
try {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (results.length >= maxFiles) break
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) collect(full, results, maxFiles)
|
||||
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||
}
|
||||
} catch { /* 忽略无权限目录 */ }
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
collect(userDir, files, limit)
|
||||
|
||||
// 按修改时间降序
|
||||
files.sort((a, b) => {
|
||||
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
|
||||
})
|
||||
|
||||
let ciphertext: Buffer | null = null
|
||||
const tailCounts: Record<string, number> = {}
|
||||
|
||||
for (const f of files.slice(0, 32)) {
|
||||
try {
|
||||
const data = readFileSync(f)
|
||||
if (data.length < 8) continue
|
||||
|
||||
// 统计末尾两字节用于 XOR 密钥
|
||||
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
||||
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
||||
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
// 提取密文(取第一个有效的)
|
||||
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
|
||||
ciphertext = data.subarray(0xF, 0x1F)
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
// 计算 XOR 密钥
|
||||
let xorKey: number | null = null
|
||||
let maxCount = 0
|
||||
for (const [key, count] of Object.entries(tailCounts)) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count
|
||||
const [x, y] = key.split('_').map(Number)
|
||||
const k = x ^ 0xFF
|
||||
if (k === (y ^ 0xD9)) xorKey = k
|
||||
}
|
||||
}
|
||||
|
||||
return { ciphertext, xorKey }
|
||||
}
|
||||
}
|
||||
1173
electron/services/keyServiceMac.ts
Normal file
1173
electron/services/keyServiceMac.ts
Normal file
File diff suppressed because it is too large
Load Diff
371
electron/services/messagePushService.ts
Normal file
371
electron/services/messagePushService.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, type ChatSession, type Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { httpService } from './httpService'
|
||||
|
||||
interface SessionBaseline {
|
||||
lastTimestamp: number
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
interface MessagePushPayload {
|
||||
event: 'message.new'
|
||||
sessionId: string
|
||||
messageKey: string
|
||||
avatarUrl?: string
|
||||
sourceName: string
|
||||
groupName?: string
|
||||
content: string | null
|
||||
}
|
||||
|
||||
const PUSH_CONFIG_KEYS = new Set([
|
||||
'messagePushEnabled',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
])
|
||||
|
||||
class MessagePushService {
|
||||
private readonly configService: ConfigService
|
||||
private readonly sessionBaseline = new Map<string, SessionBaseline>()
|
||||
private readonly recentMessageKeys = new Map<string, number>()
|
||||
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
|
||||
private readonly debounceMs = 350
|
||||
private readonly recentMessageTtlMs = 10 * 60 * 1000
|
||||
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private processing = false
|
||||
private rerunRequested = false
|
||||
private started = false
|
||||
private baselineReady = false
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
void this.refreshConfiguration('startup')
|
||||
}
|
||||
|
||||
handleDbMonitorChange(type: string, json: string): void {
|
||||
if (!this.started) return
|
||||
if (!this.isPushEnabled()) return
|
||||
|
||||
let payload: Record<string, unknown> | null = null
|
||||
try {
|
||||
payload = JSON.parse(json)
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
|
||||
const tableName = String(payload?.table || '').trim().toLowerCase()
|
||||
if (tableName && tableName !== 'session') {
|
||||
return
|
||||
}
|
||||
|
||||
this.scheduleSync()
|
||||
}
|
||||
|
||||
async handleConfigChanged(key: string): Promise<void> {
|
||||
if (!PUSH_CONFIG_KEYS.has(String(key || '').trim())) return
|
||||
if (key === 'dbPath' || key === 'decryptKey' || key === 'myWxid') {
|
||||
this.resetRuntimeState()
|
||||
chatService.close()
|
||||
}
|
||||
await this.refreshConfiguration(`config:${key}`)
|
||||
}
|
||||
|
||||
handleConfigCleared(): void {
|
||||
this.resetRuntimeState()
|
||||
chatService.close()
|
||||
}
|
||||
|
||||
private isPushEnabled(): boolean {
|
||||
return this.configService.get('messagePushEnabled') === true
|
||||
}
|
||||
|
||||
private resetRuntimeState(): void {
|
||||
this.sessionBaseline.clear()
|
||||
this.recentMessageKeys.clear()
|
||||
this.groupNicknameCache.clear()
|
||||
this.baselineReady = false
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshConfiguration(reason: string): Promise<void> {
|
||||
if (!this.isPushEnabled()) {
|
||||
this.resetRuntimeState()
|
||||
return
|
||||
}
|
||||
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
console.warn(`[MessagePushService] Bootstrap connect failed (${reason}):`, connectResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
await this.bootstrapBaseline()
|
||||
}
|
||||
|
||||
private async bootstrapBaseline(): Promise<void> {
|
||||
const sessionsResult = await chatService.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||
return
|
||||
}
|
||||
this.setBaseline(sessionsResult.sessions as ChatSession[])
|
||||
this.baselineReady = true
|
||||
}
|
||||
|
||||
private scheduleSync(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null
|
||||
void this.flushPendingChanges()
|
||||
}, this.debounceMs)
|
||||
}
|
||||
|
||||
private async flushPendingChanges(): Promise<void> {
|
||||
if (this.processing) {
|
||||
this.rerunRequested = true
|
||||
return
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
try {
|
||||
if (!this.isPushEnabled()) return
|
||||
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
console.warn('[MessagePushService] Sync connect failed:', connectResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
const sessionsResult = await chatService.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessions = sessionsResult.sessions as ChatSession[]
|
||||
if (!this.baselineReady) {
|
||||
this.setBaseline(sessions)
|
||||
this.baselineReady = true
|
||||
return
|
||||
}
|
||||
|
||||
const previousBaseline = new Map(this.sessionBaseline)
|
||||
this.setBaseline(sessions)
|
||||
|
||||
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
|
||||
for (const session of candidates) {
|
||||
await this.pushSessionMessages(session, previousBaseline.get(session.username))
|
||||
}
|
||||
} finally {
|
||||
this.processing = false
|
||||
if (this.rerunRequested) {
|
||||
this.rerunRequested = false
|
||||
this.scheduleSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setBaseline(sessions: ChatSession[]): void {
|
||||
this.sessionBaseline.clear()
|
||||
for (const session of sessions) {
|
||||
this.sessionBaseline.set(session.username, {
|
||||
lastTimestamp: Number(session.lastTimestamp || 0),
|
||||
unreadCount: Number(session.unreadCount || 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||
const sessionId = String(session.username || '').trim()
|
||||
if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const summary = String(session.summary || '').trim()
|
||||
if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastTimestamp = Number(session.lastTimestamp || 0)
|
||||
const unreadCount = Number(session.unreadCount || 0)
|
||||
|
||||
if (!previous) {
|
||||
return unreadCount > 0 && lastTimestamp > 0
|
||||
}
|
||||
|
||||
if (lastTimestamp <= previous.lastTimestamp) {
|
||||
return false
|
||||
}
|
||||
|
||||
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
|
||||
return unreadCount > previous.unreadCount
|
||||
}
|
||||
|
||||
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
|
||||
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
|
||||
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
|
||||
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const message of newMessagesResult.messages) {
|
||||
const messageKey = String(message.messageKey || '').trim()
|
||||
if (!messageKey) continue
|
||||
if (message.isSend === 1) continue
|
||||
|
||||
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.isRecentMessage(messageKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const payload = await this.buildPayload(session, message)
|
||||
if (!payload) continue
|
||||
|
||||
httpService.broadcastMessagePush(payload)
|
||||
this.rememberMessageKey(messageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async buildPayload(session: ChatSession, message: Message): Promise<MessagePushPayload | null> {
|
||||
const sessionId = String(session.username || '').trim()
|
||||
const messageKey = String(message.messageKey || '').trim()
|
||||
if (!sessionId || !messageKey) return null
|
||||
|
||||
const isGroup = sessionId.endsWith('@chatroom')
|
||||
const content = this.getMessageDisplayContent(message)
|
||||
|
||||
if (isGroup) {
|
||||
const groupInfo = await chatService.getContactAvatar(sessionId)
|
||||
const groupName = session.displayName || groupInfo?.displayName || sessionId
|
||||
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
||||
groupName,
|
||||
sourceName,
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
const contactInfo = await chatService.getContactAvatar(sessionId)
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
||||
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private getMessageDisplayContent(message: Message): string | null {
|
||||
switch (Number(message.localType || 0)) {
|
||||
case 1:
|
||||
return message.rawContent || null
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
return '[语音]'
|
||||
case 43:
|
||||
return '[视频]'
|
||||
case 47:
|
||||
return '[表情]'
|
||||
case 42:
|
||||
return message.cardNickname || '[名片]'
|
||||
case 48:
|
||||
return '[位置]'
|
||||
case 49:
|
||||
return message.linkTitle || message.fileName || '[消息]'
|
||||
default:
|
||||
return message.parsedContent || message.rawContent || null
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveGroupSourceName(chatroomId: string, message: Message, session: ChatSession): Promise<string> {
|
||||
const senderUsername = String(message.senderUsername || '').trim()
|
||||
if (!senderUsername) {
|
||||
return session.lastSenderDisplayName || '未知发送者'
|
||||
}
|
||||
|
||||
const groupNicknames = await this.getGroupNicknames(chatroomId)
|
||||
const normalizedSender = this.normalizeAccountId(senderUsername)
|
||||
const nickname = groupNicknames[senderUsername]
|
||||
|| groupNicknames[senderUsername.toLowerCase()]
|
||||
|| groupNicknames[normalizedSender]
|
||||
|| groupNicknames[normalizedSender.toLowerCase()]
|
||||
|
||||
if (nickname) {
|
||||
return nickname
|
||||
}
|
||||
|
||||
const contactInfo = await chatService.getContactAvatar(senderUsername)
|
||||
return contactInfo?.displayName || senderUsername
|
||||
}
|
||||
|
||||
private async getGroupNicknames(chatroomId: string): Promise<Record<string, string>> {
|
||||
const cacheKey = String(chatroomId || '').trim()
|
||||
if (!cacheKey) return {}
|
||||
|
||||
const cached = this.groupNicknameCache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.updatedAt < this.groupNicknameCacheTtlMs) {
|
||||
return cached.nicknames
|
||||
}
|
||||
|
||||
const result = await wcdbService.getGroupNicknames(cacheKey)
|
||||
const nicknames = result.success && result.nicknames ? result.nicknames : {}
|
||||
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
|
||||
return nicknames
|
||||
}
|
||||
|
||||
private normalizeAccountId(value: string): string {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return trimmed
|
||||
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match ? match[1] : trimmed
|
||||
}
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
private isRecentMessage(messageKey: string): boolean {
|
||||
this.pruneRecentMessageKeys()
|
||||
const timestamp = this.recentMessageKeys.get(messageKey)
|
||||
return typeof timestamp === 'number' && Date.now() - timestamp < this.recentMessageTtlMs
|
||||
}
|
||||
|
||||
private rememberMessageKey(messageKey: string): void {
|
||||
this.recentMessageKeys.set(messageKey, Date.now())
|
||||
this.pruneRecentMessageKeys()
|
||||
}
|
||||
|
||||
private pruneRecentMessageKeys(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, timestamp] of this.recentMessageKeys.entries()) {
|
||||
if (now - timestamp > this.recentMessageTtlMs) {
|
||||
this.recentMessageKeys.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const messagePushService = new MessagePushService()
|
||||
@@ -292,7 +292,9 @@ class SnsService {
|
||||
private contactCache: ContactCacheService
|
||||
private imageCache = new Map<string, string>()
|
||||
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
|
||||
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
|
||||
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
||||
private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000
|
||||
private lastTimelineFallbackAt = 0
|
||||
private readonly timelineFallbackCooldownMs = 3 * 60 * 1000
|
||||
|
||||
@@ -661,102 +663,26 @@ class SnsService {
|
||||
}
|
||||
|
||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||
const collect = (rows?: any[]): string[] => {
|
||||
if (!Array.isArray(rows)) return []
|
||||
const usernames: string[] = []
|
||||
for (const row of rows) {
|
||||
const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0]
|
||||
const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim()
|
||||
if (username) usernames.push(username)
|
||||
const result = await wcdbService.getSnsUsernames()
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
||||
}
|
||||
return usernames
|
||||
}
|
||||
|
||||
const primary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||||
)
|
||||
const fallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||||
)
|
||||
|
||||
const merged = Array.from(new Set([
|
||||
...collect(primary.rows),
|
||||
...collect(fallback.rows)
|
||||
]))
|
||||
|
||||
// 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。
|
||||
if (merged.length > 0) {
|
||||
return { success: true, usernames: merged }
|
||||
}
|
||||
|
||||
// 两条查询都成功但无数据,说明确实没有朋友圈发布者。
|
||||
if (primary.success || fallback.success) {
|
||||
return { success: true, usernames: [] }
|
||||
}
|
||||
|
||||
return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' }
|
||||
return { success: true, usernames: result.usernames || [] }
|
||||
}
|
||||
|
||||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
let totalPosts = 0
|
||||
let totalFriends = 0
|
||||
let myPosts: number | null = null
|
||||
|
||||
const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine')
|
||||
if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) {
|
||||
totalPosts = this.parseCountValue(postCountResult.rows[0])
|
||||
}
|
||||
|
||||
if (totalPosts > 0) {
|
||||
const friendCountPrimary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||||
)
|
||||
if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) {
|
||||
totalFriends = this.parseCountValue(friendCountPrimary.rows[0])
|
||||
} else {
|
||||
const friendCountFallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||||
)
|
||||
if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) {
|
||||
totalFriends = this.parseCountValue(friendCountFallback.rows[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedMyWxid = this.toOptionalString(myWxid)
|
||||
if (normalizedMyWxid) {
|
||||
const myPostPrimary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?",
|
||||
[normalizedMyWxid]
|
||||
)
|
||||
if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) {
|
||||
myPosts = this.parseCountValue(myPostPrimary.rows[0])
|
||||
} else {
|
||||
const myPostFallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?",
|
||||
[normalizedMyWxid]
|
||||
)
|
||||
if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) {
|
||||
myPosts = this.parseCountValue(myPostFallback.rows[0])
|
||||
const result = await wcdbService.getSnsExportStats(normalizedMyWxid || undefined)
|
||||
if (!result.success || !result.data) {
|
||||
return { totalPosts: 0, totalFriends: 0, myPosts: normalizedMyWxid ? 0 : null }
|
||||
}
|
||||
return {
|
||||
totalPosts: Number(result.data.totalPosts || 0),
|
||||
totalFriends: Number(result.data.totalFriends || 0),
|
||||
myPosts: result.data.myPosts === null || result.data.myPosts === undefined ? null : Number(result.data.myPosts || 0)
|
||||
}
|
||||
}
|
||||
|
||||
return { totalPosts, totalFriends, myPosts }
|
||||
}
|
||||
|
||||
async getExportStats(options?: {
|
||||
allowTimelineFallback?: boolean
|
||||
preferCache?: boolean
|
||||
@@ -864,6 +790,84 @@ class SnsService {
|
||||
})
|
||||
}
|
||||
|
||||
private async getUserPostCountsFromTimeline(): Promise<Record<string, number>> {
|
||||
const pageSize = 500
|
||||
const counts: Record<string, number> = {}
|
||||
let offset = 0
|
||||
|
||||
for (let round = 0; round < 2000; round++) {
|
||||
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
|
||||
if (!result.success || !Array.isArray(result.timeline)) {
|
||||
throw new Error(result.error || '获取朋友圈用户总条数失败')
|
||||
}
|
||||
|
||||
const rows = result.timeline
|
||||
if (rows.length === 0) break
|
||||
|
||||
for (const row of rows) {
|
||||
const username = this.pickTimelineUsername(row)
|
||||
if (!username) continue
|
||||
counts[username] = (counts[username] || 0) + 1
|
||||
}
|
||||
|
||||
if (rows.length < pageSize) break
|
||||
offset += rows.length
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
async getUserPostCounts(options?: {
|
||||
preferCache?: boolean
|
||||
}): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
const preferCache = options?.preferCache ?? true
|
||||
const now = Date.now()
|
||||
|
||||
try {
|
||||
if (
|
||||
preferCache &&
|
||||
this.userPostCountsCache &&
|
||||
now - this.userPostCountsCache.updatedAt <= this.userPostCountsCacheTtlMs
|
||||
) {
|
||||
return { success: true, counts: this.userPostCountsCache.counts }
|
||||
}
|
||||
|
||||
const counts = await this.getUserPostCountsFromTimeline()
|
||||
this.userPostCountsCache = {
|
||||
counts,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
return { success: true, counts }
|
||||
} catch (error) {
|
||||
console.error('[SnsService] getUserPostCounts failed:', error)
|
||||
if (this.userPostCountsCache) {
|
||||
return { success: true, counts: this.userPostCountsCache.counts }
|
||||
}
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> {
|
||||
const normalizedUsername = this.toOptionalString(username)
|
||||
if (!normalizedUsername) {
|
||||
return { success: false, error: '用户名不能为空' }
|
||||
}
|
||||
|
||||
const countsResult = await this.getUserPostCounts({ preferCache: true })
|
||||
if (countsResult.success) {
|
||||
const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
username: normalizedUsername,
|
||||
totalPosts: Math.max(0, Number(totalPosts || 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' }
|
||||
}
|
||||
|
||||
// 安装朋友圈删除拦截
|
||||
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||
return wcdbService.installSnsBlockDeleteTrigger()
|
||||
@@ -881,7 +885,12 @@ class SnsService {
|
||||
|
||||
// 从数据库直接删除朋友圈记录
|
||||
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||
return wcdbService.deleteSnsPost(postId)
|
||||
const result = await wcdbService.deleteSnsPost(postId)
|
||||
if (result.success) {
|
||||
this.userPostCountsCache = null
|
||||
this.exportStatsCache = null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
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 {
|
||||
@@ -12,8 +11,28 @@ export interface VideoInfo {
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
interface TimedCacheEntry<T> {
|
||||
value: T
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
interface VideoIndexEntry {
|
||||
videoPath?: string
|
||||
coverPath?: string
|
||||
thumbPath?: string
|
||||
}
|
||||
|
||||
class VideoService {
|
||||
private configService: ConfigService
|
||||
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
|
||||
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
|
||||
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
|
||||
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
|
||||
private readonly videoIndexCacheTtlMs = 90 * 1000
|
||||
private readonly maxCacheEntries = 2000
|
||||
private readonly maxIndexEntries = 6
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
@@ -26,7 +45,41 @@ class VideoService {
|
||||
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 {}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
private readTimedCache<T>(cache: Map<string, TimedCacheEntry<T>>, key: string): T | undefined {
|
||||
const hit = cache.get(key)
|
||||
if (!hit) return undefined
|
||||
if (hit.expiresAt <= Date.now()) {
|
||||
cache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
return hit.value
|
||||
}
|
||||
|
||||
private writeTimedCache<T>(
|
||||
cache: Map<string, TimedCacheEntry<T>>,
|
||||
key: string,
|
||||
value: T,
|
||||
ttlMs: number,
|
||||
maxEntries: number
|
||||
): void {
|
||||
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
|
||||
if (cache.size <= maxEntries) return
|
||||
|
||||
const now = Date.now()
|
||||
for (const [cacheKey, entry] of cache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
cache.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
while (cache.size > maxEntries) {
|
||||
const oldestKey = cache.keys().next().value as string | undefined
|
||||
if (!oldestKey) break
|
||||
cache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,13 +96,6 @@ class VideoService {
|
||||
return this.configService.get('myWxid') || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存目录(解密后的数据库存放位置)
|
||||
*/
|
||||
private getCachePath(): string {
|
||||
return this.configService.getCacheBasePath()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 wxid 目录名(去掉后缀)
|
||||
*/
|
||||
@@ -69,109 +115,153 @@ class VideoService {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
||||
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
*/
|
||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||
const cachePath = this.getCachePath()
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
private getScopeKey(dbPath: string, wxid: string): string {
|
||||
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
|
||||
}
|
||||
|
||||
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath })
|
||||
|
||||
if (!wxid) {
|
||||
this.log('queryVideoFileName: wxid 为空')
|
||||
return undefined
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||
if (dbPathContainsWxid) {
|
||||
return join(dbPath, 'msg', 'video')
|
||||
}
|
||||
return join(dbPath, wxid, 'msg', 'video')
|
||||
}
|
||||
|
||||
// 方法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 {
|
||||
this.log('尝试缓存 hardlink.db', { path: p })
|
||||
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(/\.[^.]+$/, '')
|
||||
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||
return realMd5
|
||||
}
|
||||
this.log('缓存 hardlink.db 未命中', { path: p })
|
||||
} catch (e) {
|
||||
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
if (dbPath) {
|
||||
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||
|
||||
const encryptedDbPaths: string[] = []
|
||||
if (dbPathContainsWxid) {
|
||||
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||
} else {
|
||||
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
|
||||
}
|
||||
|
||||
return [
|
||||
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||
* 使用 wcdb 专属接口查询加密的 hardlink.db
|
||||
*/
|
||||
private async resolveVideoHardlinks(
|
||||
md5List: string[],
|
||||
dbPath: string,
|
||||
wxid: string,
|
||||
cleanedWxid: string
|
||||
): Promise<Map<string, string>> {
|
||||
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||
const normalizedList = Array.from(
|
||||
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
|
||||
)
|
||||
const resolvedMap = new Map<string, string>()
|
||||
const unresolvedSet = new Set(normalizedList)
|
||||
|
||||
for (const md5 of normalizedList) {
|
||||
const cacheKey = `${scopeKey}|${md5}`
|
||||
const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey)
|
||||
if (cached === undefined) continue
|
||||
if (cached) resolvedMap.set(md5, cached)
|
||||
unresolvedSet.delete(md5)
|
||||
}
|
||||
|
||||
if (unresolvedSet.size === 0) return resolvedMap
|
||||
|
||||
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
|
||||
for (const p of encryptedDbPaths) {
|
||||
if (existsSync(p)) {
|
||||
if (!existsSync(p) || unresolvedSet.size === 0) continue
|
||||
const unresolved = Array.from(unresolvedSet)
|
||||
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
|
||||
try {
|
||||
this.log('尝试加密 hardlink.db', { path: p })
|
||||
const escapedMd5 = md5.replace(/'/g, "''")
|
||||
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) {
|
||||
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) })
|
||||
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
|
||||
if (batchResult.success && Array.isArray(batchResult.rows)) {
|
||||
for (const row of batchResult.rows) {
|
||||
const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1
|
||||
const inputMd5 = index >= 0 && index < requests.length
|
||||
? requests[index].md5
|
||||
: String(row?.md5 || '').trim().toLowerCase()
|
||||
if (!inputMd5) continue
|
||||
const resolvedMd5 = row?.success && row?.data?.resolved_md5
|
||||
? String(row.data.resolved_md5).trim().toLowerCase()
|
||||
: ''
|
||||
if (!resolvedMd5) continue
|
||||
const cacheKey = `${scopeKey}|${inputMd5}`
|
||||
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||
resolvedMap.set(inputMd5, resolvedMd5)
|
||||
unresolvedSet.delete(inputMd5)
|
||||
}
|
||||
} else {
|
||||
this.log('加密 hardlink.db 不存在', { path: p })
|
||||
// 兼容不支持批量接口的版本,回退单条请求。
|
||||
for (const req of requests) {
|
||||
try {
|
||||
const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath)
|
||||
const resolvedMd5 = single.success && single.data?.resolved_md5
|
||||
? String(single.data.resolved_md5).trim().toLowerCase()
|
||||
: ''
|
||||
if (!resolvedMd5) continue
|
||||
const cacheKey = `${scopeKey}|${req.md5}`
|
||||
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||
resolvedMap.set(req.md5, resolvedMd5)
|
||||
unresolvedSet.delete(req.md5)
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
|
||||
}
|
||||
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
|
||||
}
|
||||
|
||||
for (const md5 of unresolvedSet) {
|
||||
const cacheKey = `${scopeKey}|${md5}`
|
||||
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||
}
|
||||
|
||||
return resolvedMap
|
||||
}
|
||||
|
||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath })
|
||||
|
||||
if (!normalizedMd5 || !wxid || !dbPath) {
|
||||
this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid)
|
||||
const resolved = resolvedMap.get(normalizedMd5)
|
||||
if (resolved) {
|
||||
this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved })
|
||||
return resolved
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
if (!dbPath || !wxid) return
|
||||
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 data URL
|
||||
*/
|
||||
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
|
||||
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
|
||||
try {
|
||||
if (!existsSync(filePath)) return undefined
|
||||
if (!filePath || !existsSync(filePath)) return undefined
|
||||
const buffer = readFileSync(filePath)
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
} catch {
|
||||
@@ -179,79 +269,151 @@ class VideoService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据视频MD5获取视频文件信息
|
||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||
*/
|
||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
private getOrBuildVideoIndex(videoBaseDir: string): Map<string, VideoIndexEntry> {
|
||||
const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir)
|
||||
if (cached) return cached
|
||||
|
||||
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
|
||||
|
||||
if (!dbPath || !wxid || !videoMd5) {
|
||||
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
|
||||
return { exists: false }
|
||||
const index = new Map<string, VideoIndexEntry>()
|
||||
const ensureEntry = (key: string): VideoIndexEntry => {
|
||||
let entry = index.get(key)
|
||||
if (!entry) {
|
||||
entry = {}
|
||||
index.set(key, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// 先尝试从数据库查询真正的视频文件名
|
||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
||||
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
|
||||
|
||||
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
let videoBaseDir: string
|
||||
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
||||
videoBaseDir = join(dbPath, 'msg', 'video')
|
||||
} else {
|
||||
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)
|
||||
const yearMonthDirs = allDirs
|
||||
.filter(dir => {
|
||||
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||
.filter((dir) => {
|
||||
const dirPath = join(videoBaseDir, dir)
|
||||
try {
|
||||
return statSync(dirPath).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.localeCompare(a))
|
||||
|
||||
this.log('扫描目录', { dirs: yearMonthDirs })
|
||||
for (const yearMonth of yearMonthDirs) {
|
||||
const dirPath = join(videoBaseDir, yearMonth)
|
||||
let files: string[] = []
|
||||
try {
|
||||
files = readdirSync(dirPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const lower = file.toLowerCase()
|
||||
const fullPath = join(dirPath, file)
|
||||
|
||||
if (lower.endsWith('.mp4')) {
|
||||
const md5 = lower.slice(0, -4)
|
||||
const entry = ensureEntry(md5)
|
||||
if (!entry.videoPath) entry.videoPath = fullPath
|
||||
if (md5.endsWith('_raw')) {
|
||||
const baseMd5 = md5.replace(/_raw$/, '')
|
||||
const baseEntry = ensureEntry(baseMd5)
|
||||
if (!baseEntry.videoPath) baseEntry.videoPath = fullPath
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!lower.endsWith('.jpg')) continue
|
||||
const jpgBase = lower.slice(0, -4)
|
||||
if (jpgBase.endsWith('_thumb')) {
|
||||
const baseMd5 = jpgBase.slice(0, -6)
|
||||
const entry = ensureEntry(baseMd5)
|
||||
if (!entry.thumbPath) entry.thumbPath = fullPath
|
||||
} else {
|
||||
const entry = ensureEntry(jpgBase)
|
||||
if (!entry.coverPath) entry.coverPath = fullPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, entry] of index) {
|
||||
if (!key.endsWith('_raw')) continue
|
||||
const baseKey = key.replace(/_raw$/, '')
|
||||
const baseEntry = index.get(baseKey)
|
||||
if (!baseEntry) continue
|
||||
if (!entry.coverPath) entry.coverPath = baseEntry.coverPath
|
||||
if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('构建视频索引失败', { videoBaseDir, error: String(e) })
|
||||
}
|
||||
|
||||
this.writeTimedCache(
|
||||
this.videoDirIndexCache,
|
||||
videoBaseDir,
|
||||
index,
|
||||
this.videoIndexCacheTtlMs,
|
||||
this.maxIndexEntries
|
||||
)
|
||||
return index
|
||||
}
|
||||
|
||||
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string, includePoster = true): VideoInfo | null {
|
||||
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||
if (!normalizedMd5) return null
|
||||
|
||||
const candidates = [normalizedMd5]
|
||||
const baseMd5 = normalizedMd5.replace(/_raw$/, '')
|
||||
if (baseMd5 !== normalizedMd5) {
|
||||
candidates.push(baseMd5)
|
||||
} else {
|
||||
candidates.push(`${normalizedMd5}_raw`)
|
||||
}
|
||||
|
||||
for (const key of candidates) {
|
||||
const entry = index.get(key)
|
||||
if (!entry?.videoPath) continue
|
||||
if (!existsSync(entry.videoPath)) continue
|
||||
if (!includePoster) {
|
||||
return {
|
||||
videoUrl: entry.videoPath,
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
videoUrl: entry.videoPath,
|
||||
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null {
|
||||
try {
|
||||
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||
.filter((dir) => {
|
||||
const dirPath = join(videoBaseDir, dir)
|
||||
try {
|
||||
return statSync(dirPath).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.localeCompare(a))
|
||||
|
||||
for (const yearMonth of yearMonthDirs) {
|
||||
const dirPath = join(videoBaseDir, yearMonth)
|
||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||
|
||||
if (existsSync(videoPath)) {
|
||||
// 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw,但封面不带)
|
||||
if (!existsSync(videoPath)) continue
|
||||
if (!includePoster) {
|
||||
return {
|
||||
videoUrl: videoPath,
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
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,
|
||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||
@@ -259,39 +421,76 @@ class VideoService {
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('fallback 扫描视频目录失败', { error: String(e) })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 没找到,列出所有目录里的 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) })
|
||||
}
|
||||
/**
|
||||
* 根据视频MD5获取视频文件信息
|
||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||
*/
|
||||
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise<VideoInfo> {
|
||||
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
|
||||
const includePoster = options?.includePoster !== false
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
|
||||
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
|
||||
this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid })
|
||||
|
||||
if (!dbPath || !wxid || !normalizedMd5) {
|
||||
this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 })
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}`
|
||||
|
||||
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
||||
if (cachedInfo) return cachedInfo
|
||||
|
||||
const pending = this.pendingVideoInfo.get(cacheKey)
|
||||
if (pending) return pending
|
||||
|
||||
const task = (async (): Promise<VideoInfo> => {
|
||||
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
|
||||
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
||||
|
||||
if (!existsSync(videoBaseDir)) {
|
||||
const miss = { exists: false }
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return miss
|
||||
}
|
||||
|
||||
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
||||
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster)
|
||||
if (indexed) {
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return indexed
|
||||
}
|
||||
|
||||
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster)
|
||||
if (fallback) {
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return fallback
|
||||
}
|
||||
|
||||
const miss = { exists: false }
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
|
||||
return miss
|
||||
})()
|
||||
|
||||
this.pendingVideoInfo.set(cacheKey, task)
|
||||
try {
|
||||
return await task
|
||||
} finally {
|
||||
this.pendingVideoInfo.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息内容解析视频MD5
|
||||
*/
|
||||
|
||||
@@ -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: {
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(() => { });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,10 +174,10 @@ export class WcdbService {
|
||||
/**
|
||||
* 关闭服务
|
||||
*/
|
||||
shutdown(): void {
|
||||
this.close()
|
||||
async shutdown(): Promise<void> {
|
||||
try { await this.close() } catch {}
|
||||
if (this.worker) {
|
||||
this.worker.terminate()
|
||||
try { await this.worker.terminate() } catch {}
|
||||
this.worker = null
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,48 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageCounts', { sessionIds })
|
||||
}
|
||||
|
||||
async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageCounts', { sessionIds })
|
||||
}
|
||||
|
||||
async getSessionMessageTypeStats(
|
||||
sessionId: string,
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0
|
||||
): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
async getSessionMessageTypeStatsBatch(
|
||||
sessionIds: string[],
|
||||
options?: {
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
quickMode?: boolean
|
||||
includeGroupSenderCount?: boolean
|
||||
}
|
||||
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options })
|
||||
}
|
||||
|
||||
async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageDateCounts', { sessionId })
|
||||
}
|
||||
|
||||
async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record<string, Record<string, number>>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds })
|
||||
}
|
||||
|
||||
async getMessagesByType(
|
||||
sessionId: string,
|
||||
localType: number,
|
||||
ascending = false,
|
||||
limit = 0,
|
||||
offset = 0
|
||||
): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人昵称
|
||||
*/
|
||||
@@ -287,6 +329,14 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset })
|
||||
}
|
||||
|
||||
async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> {
|
||||
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
|
||||
}
|
||||
|
||||
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人详情
|
||||
*/
|
||||
@@ -301,6 +351,26 @@ export class WcdbService {
|
||||
return this.callWorker('getContactStatus', { usernames })
|
||||
}
|
||||
|
||||
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
|
||||
return this.callWorker('getContactTypeCounts')
|
||||
}
|
||||
|
||||
async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> {
|
||||
return this.callWorker('getContactsCompact', { usernames })
|
||||
}
|
||||
|
||||
async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||
return this.callWorker('getContactAliasMap', { usernames })
|
||||
}
|
||||
|
||||
async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record<string, boolean>; error?: string }> {
|
||||
return this.callWorker('getContactFriendFlags', { usernames })
|
||||
}
|
||||
|
||||
async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> {
|
||||
return this.callWorker('getChatRoomExtBuffer', { chatroomId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聚合统计数据
|
||||
*/
|
||||
@@ -372,7 +442,7 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询(支持参数化查询)
|
||||
* 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容)
|
||||
*/
|
||||
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 })
|
||||
@@ -406,6 +476,10 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageById', { sessionId, localId })
|
||||
}
|
||||
|
||||
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||
return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音数据
|
||||
*/
|
||||
@@ -413,6 +487,40 @@ export class WcdbService {
|
||||
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
||||
}
|
||||
|
||||
async getVoiceDataBatch(
|
||||
requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }>
|
||||
): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> {
|
||||
return this.callWorker('getVoiceDataBatch', { requests })
|
||||
}
|
||||
|
||||
async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getMediaSchemaSummary', { dbPath })
|
||||
}
|
||||
|
||||
async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||
return this.callWorker('getHeadImageBuffers', { usernames })
|
||||
}
|
||||
|
||||
async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('resolveImageHardlink', { md5, accountDir })
|
||||
}
|
||||
|
||||
async resolveImageHardlinkBatch(
|
||||
requests: Array<{ md5: string; accountDir?: string }>
|
||||
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||
return this.callWorker('resolveImageHardlinkBatch', { requests })
|
||||
}
|
||||
|
||||
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath })
|
||||
}
|
||||
|
||||
async resolveVideoHardlinkMd5Batch(
|
||||
requests: Array<{ md5: string; dbPath?: string }>
|
||||
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||
return this.callWorker('resolveVideoHardlinkMd5Batch', { requests })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取朋友圈
|
||||
*/
|
||||
@@ -427,6 +535,14 @@ export class WcdbService {
|
||||
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||
return this.callWorker('getSnsUsernames')
|
||||
}
|
||||
|
||||
async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
|
||||
return this.callWorker('getSnsExportStats', { myWxid })
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装朋友圈删除拦截
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -57,6 +59,24 @@ if (parentPort) {
|
||||
case 'getMessageCounts':
|
||||
result = await core.getMessageCounts(payload.sessionIds)
|
||||
break
|
||||
case 'getSessionMessageCounts':
|
||||
result = await core.getSessionMessageCounts(payload.sessionIds)
|
||||
break
|
||||
case 'getSessionMessageTypeStats':
|
||||
result = await core.getSessionMessageTypeStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getSessionMessageTypeStatsBatch':
|
||||
result = await core.getSessionMessageTypeStatsBatch(payload.sessionIds, payload.options)
|
||||
break
|
||||
case 'getSessionMessageDateCounts':
|
||||
result = await core.getSessionMessageDateCounts(payload.sessionId)
|
||||
break
|
||||
case 'getSessionMessageDateCountsBatch':
|
||||
result = await core.getSessionMessageDateCountsBatch(payload.sessionIds)
|
||||
break
|
||||
case 'getMessagesByType':
|
||||
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
|
||||
break
|
||||
case 'getDisplayNames':
|
||||
result = await core.getDisplayNames(payload.usernames)
|
||||
break
|
||||
@@ -87,12 +107,33 @@ if (parentPort) {
|
||||
case 'getMessageMeta':
|
||||
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
||||
break
|
||||
case 'getMessageTableColumns':
|
||||
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
|
||||
break
|
||||
case 'getMessageTableTimeRange':
|
||||
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
|
||||
break
|
||||
case 'getContact':
|
||||
result = await core.getContact(payload.username)
|
||||
break
|
||||
case 'getContactStatus':
|
||||
result = await core.getContactStatus(payload.usernames)
|
||||
break
|
||||
case 'getContactTypeCounts':
|
||||
result = await core.getContactTypeCounts()
|
||||
break
|
||||
case 'getContactsCompact':
|
||||
result = await core.getContactsCompact(payload.usernames)
|
||||
break
|
||||
case 'getContactAliasMap':
|
||||
result = await core.getContactAliasMap(payload.usernames)
|
||||
break
|
||||
case 'getContactFriendFlags':
|
||||
result = await core.getContactFriendFlags(payload.usernames)
|
||||
break
|
||||
case 'getChatRoomExtBuffer':
|
||||
result = await core.getChatRoomExtBuffer(payload.chatroomId)
|
||||
break
|
||||
case 'getAggregateStats':
|
||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
@@ -138,18 +179,48 @@ if (parentPort) {
|
||||
case 'getMessageById':
|
||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||
break
|
||||
case 'searchMessages':
|
||||
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getVoiceData':
|
||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||
if (!result.success) {
|
||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||
}
|
||||
break
|
||||
case 'getVoiceDataBatch':
|
||||
result = await core.getVoiceDataBatch(payload.requests)
|
||||
break
|
||||
case 'getMediaSchemaSummary':
|
||||
result = await core.getMediaSchemaSummary(payload.dbPath)
|
||||
break
|
||||
case 'getHeadImageBuffers':
|
||||
result = await core.getHeadImageBuffers(payload.usernames)
|
||||
break
|
||||
case 'resolveImageHardlink':
|
||||
result = await core.resolveImageHardlink(payload.md5, payload.accountDir)
|
||||
break
|
||||
case 'resolveImageHardlinkBatch':
|
||||
result = await core.resolveImageHardlinkBatch(payload.requests)
|
||||
break
|
||||
case 'resolveVideoHardlinkMd5':
|
||||
result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath)
|
||||
break
|
||||
case 'resolveVideoHardlinkMd5Batch':
|
||||
result = await core.resolveVideoHardlinkMd5Batch(payload.requests)
|
||||
break
|
||||
case 'getSnsTimeline':
|
||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||
break
|
||||
case 'getSnsAnnualStats':
|
||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getSnsUsernames':
|
||||
result = await core.getSnsUsernames()
|
||||
break
|
||||
case 'getSnsExportStats':
|
||||
result = await core.getSnsExportStats(payload.myWxid)
|
||||
break
|
||||
case 'installSnsBlockDeleteTrigger':
|
||||
result = await core.installSnsBlockDeleteTrigger()
|
||||
break
|
||||
|
||||
@@ -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
|
||||
@@ -110,7 +132,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
||||
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||
const winWidth = 344
|
||||
const winWidth = position === 'top-center' ? 280 : 344
|
||||
const winHeight = 114
|
||||
const padding = 20
|
||||
|
||||
@@ -118,6 +140,10 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
||||
let y = 0
|
||||
|
||||
switch (position) {
|
||||
case 'top-center':
|
||||
x = (screenWidth - winWidth) / 2
|
||||
y = padding
|
||||
break
|
||||
case 'top-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = padding
|
||||
@@ -144,7 +170,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
||||
win.showInactive() // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||
|
||||
win.webContents.send('notification:show', data)
|
||||
win.webContents.send('notification:show', { ...data, position })
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
|
||||
235
package-lock.json
generated
235
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",
|
||||
@@ -30,12 +29,12 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"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 +2783,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 +3857,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 +3879,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 +4890,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 +4906,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 +4915,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 +5006,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 +5777,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 +5915,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 +6217,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 +6683,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 +8436,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 +8461,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 +8924,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 +8984,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 +9014,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",
|
||||
@@ -9823,6 +9692,9 @@
|
||||
"sherpa-onnx-win-x64": "^1.12.23"
|
||||
}
|
||||
},
|
||||
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sherpa-onnx-win-ia32": {
|
||||
"version": "1.12.23",
|
||||
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
|
||||
@@ -9865,51 +9737,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",
|
||||
@@ -10139,15 +9966,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",
|
||||
@@ -10181,6 +9999,13 @@
|
||||
"inline-style-parser": "0.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/sudo-prompt": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
|
||||
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sumchecker": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
@@ -10225,24 +10050,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",
|
||||
@@ -10519,18 +10326,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",
|
||||
|
||||
39
package.json
39
package.json
@@ -3,7 +3,10 @@
|
||||
"version": "2.1.0",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "cc",
|
||||
"author": {
|
||||
"name": "cc",
|
||||
"email": "yccccccy@proton.me"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hicccc77/WeFlow"
|
||||
@@ -20,7 +23,6 @@
|
||||
"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",
|
||||
@@ -41,12 +43,12 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"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",
|
||||
@@ -72,12 +74,34 @@
|
||||
"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"
|
||||
],
|
||||
"icon": "public/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "public/icon.png",
|
||||
"target": [
|
||||
"deb",
|
||||
"tar.gz"
|
||||
],
|
||||
"category": "Utility",
|
||||
"executableName": "weflow",
|
||||
"synopsis": "WeFlow for Linux"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"differentialPackage": false,
|
||||
@@ -108,6 +132,10 @@
|
||||
"from": "public/icon.ico",
|
||||
"to": "icon.ico"
|
||||
},
|
||||
{
|
||||
"from": "public/icon.png",
|
||||
"to": "icon.png"
|
||||
},
|
||||
{
|
||||
"from": "electron/assets/wasm/",
|
||||
"to": "assets/wasm/"
|
||||
@@ -120,6 +148,8 @@
|
||||
"asarUnpack": [
|
||||
"node_modules/silk-wasm/**/*",
|
||||
"node_modules/sherpa-onnx-node/**/*",
|
||||
"node_modules/sherpa-onnx-*/*",
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"extraFiles": [
|
||||
@@ -139,6 +169,7 @@
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
]
|
||||
],
|
||||
"icon": "resources/icon.icns"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
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/libwcdb_api.dylib
Executable file
BIN
resources/libwcdb_api.dylib
Executable file
Binary file not shown.
BIN
resources/libwx_key.dylib
Executable file
BIN
resources/libwx_key.dylib
Executable file
Binary file not shown.
BIN
resources/linux/libwcdb_api.so
Executable file
BIN
resources/linux/libwcdb_api.so
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.
BIN
resources/xkey_helper_linux
Executable file
BIN
resources/xkey_helper_linux
Executable file
Binary file not shown.
138
src/App.tsx
138
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'
|
||||
@@ -36,10 +37,24 @@ import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||
|
||||
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,
|
||||
@@ -60,11 +75,19 @@ function App() {
|
||||
const isAgreementWindow = location.pathname === '/agreement-window'
|
||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/')
|
||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||
const isNotificationWindow = location.pathname === '/notification-window'
|
||||
const isExportRoute = location.pathname === '/export'
|
||||
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 [showCloseDialog, setShowCloseDialog] = useState(false)
|
||||
const [canMinimizeToTray, setCanMinimizeToTray] = useState(false)
|
||||
|
||||
// 锁定状态
|
||||
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||
@@ -81,6 +104,21 @@ function App() {
|
||||
// 数据收集同意状态
|
||||
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname !== '/settings') {
|
||||
settingsBackgroundRef.current = location
|
||||
}
|
||||
}, [location])
|
||||
|
||||
useEffect(() => {
|
||||
const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => {
|
||||
setCanMinimizeToTray(Boolean(payload.canMinimizeToTray))
|
||||
setShowCloseDialog(true)
|
||||
})
|
||||
|
||||
return () => removeCloseConfirmListener()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const body = document.body
|
||||
@@ -112,10 +150,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)
|
||||
@@ -293,6 +327,26 @@ function App() {
|
||||
setUpdateInfo(null)
|
||||
}
|
||||
|
||||
const handleWindowCloseAction = async (
|
||||
action: 'tray' | 'quit' | 'cancel',
|
||||
rememberChoice = false
|
||||
) => {
|
||||
setShowCloseDialog(false)
|
||||
if (rememberChoice && action !== 'cancel') {
|
||||
try {
|
||||
await configService.setWindowCloseBehavior(action)
|
||||
} catch (error) {
|
||||
console.error('保存关闭偏好失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI.window.respondCloseConfirm(action)
|
||||
} catch (error) {
|
||||
console.error('处理关闭确认失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动时自动检查配置并连接数据库
|
||||
useEffect(() => {
|
||||
if (isAgreementWindow || isOnboardingWindow) return
|
||||
@@ -405,8 +459,22 @@ function App() {
|
||||
|
||||
// 独立会话聊天窗口(仅显示聊天内容区域)
|
||||
if (isStandaloneChatWindow) {
|
||||
const sessionId = new URLSearchParams(location.search).get('sessionId') || ''
|
||||
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} />
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立通知窗口
|
||||
@@ -415,6 +483,25 @@ function App() {
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
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" />
|
||||
@@ -425,7 +512,10 @@ function App() {
|
||||
useHello={lockUseHello}
|
||||
/>
|
||||
)}
|
||||
<TitleBar />
|
||||
<TitleBar
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)}
|
||||
/>
|
||||
|
||||
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
||||
<UpdateProgressCapsule />
|
||||
@@ -535,36 +625,50 @@ function App() {
|
||||
progress={downloadProgress}
|
||||
/>
|
||||
|
||||
<WindowCloseDialog
|
||||
open={showCloseDialog}
|
||||
canMinimizeToTray={canMinimizeToTray}
|
||||
onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
|
||||
onCancel={() => handleWindowCloseAction('cancel')}
|
||||
/>
|
||||
|
||||
<div className="main-layout">
|
||||
<Sidebar />
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<main className="content">
|
||||
<RouteGuard>
|
||||
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||
<ExportPage />
|
||||
</div>
|
||||
|
||||
<Routes>
|
||||
<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={<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 />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{isSettingsRoute && (
|
||||
<SettingsPage onClose={handleCloseSettings} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,21 @@
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.avatar-loading {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary, #999);
|
||||
background-color: var(--bg-tertiary, #e0e0e0);
|
||||
border-radius: inherit;
|
||||
|
||||
.avatar-loading-icon {
|
||||
animation: avatar-spin 0.9s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading Skeleton */
|
||||
.avatar-skeleton {
|
||||
position: absolute;
|
||||
@@ -76,4 +91,14 @@
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes avatar-spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { User } from 'lucide-react'
|
||||
import { Loader2, User } from 'lucide-react'
|
||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||
import './Avatar.scss'
|
||||
|
||||
@@ -13,6 +13,7 @@ interface AvatarProps {
|
||||
shape?: 'circle' | 'square' | 'rounded'
|
||||
className?: string
|
||||
lazy?: boolean
|
||||
loading?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
@@ -23,12 +24,14 @@ export const Avatar = React.memo(function Avatar({
|
||||
shape = 'rounded',
|
||||
className = '',
|
||||
lazy = true,
|
||||
loading = false,
|
||||
onClick
|
||||
}: AvatarProps) {
|
||||
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
||||
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
||||
const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src])
|
||||
const [imageLoaded, setImageLoaded] = useState(isCached)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [imageError, setImageError] = useState(isFailed)
|
||||
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
||||
const [isInQueue, setIsInQueue] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
@@ -42,7 +45,7 @@ export const Avatar = React.memo(function Avatar({
|
||||
|
||||
// Intersection Observer for lazy loading
|
||||
useEffect(() => {
|
||||
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return
|
||||
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached || imageError || isFailed) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@@ -50,10 +53,11 @@ export const Avatar = React.memo(function Avatar({
|
||||
if (entry.isIntersecting && !isInQueue) {
|
||||
setIsInQueue(true)
|
||||
avatarLoadQueue.enqueue(src).then(() => {
|
||||
setImageError(false)
|
||||
setShouldLoad(true)
|
||||
}).catch(() => {
|
||||
// 加载失败不要立刻显示错误,让浏览器渲染去报错
|
||||
setShouldLoad(true)
|
||||
setImageError(true)
|
||||
setShouldLoad(false)
|
||||
}).finally(() => {
|
||||
setIsInQueue(false)
|
||||
})
|
||||
@@ -67,14 +71,18 @@ export const Avatar = React.memo(function Avatar({
|
||||
observer.observe(containerRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [src, lazy, shouldLoad, isInQueue, isCached])
|
||||
}, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed])
|
||||
|
||||
// Reset state when src changes
|
||||
useEffect(() => {
|
||||
const cached = src ? loadedAvatarCache.has(src) : false
|
||||
const failed = src ? avatarLoadQueue.hasFailed(src) : false
|
||||
setImageLoaded(cached)
|
||||
setImageError(false)
|
||||
if (lazy && !cached) {
|
||||
setImageError(failed)
|
||||
if (failed) {
|
||||
setShouldLoad(false)
|
||||
setIsInQueue(false)
|
||||
} else if (lazy && !cached) {
|
||||
setShouldLoad(false)
|
||||
setIsInQueue(false)
|
||||
} else {
|
||||
@@ -95,6 +103,7 @@ export const Avatar = React.memo(function Avatar({
|
||||
}
|
||||
|
||||
const hasValidUrl = !!src && !imageError && shouldLoad
|
||||
const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -112,13 +121,30 @@ export const Avatar = React.memo(function Avatar({
|
||||
alt={name || 'avatar'}
|
||||
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
||||
onLoad={() => {
|
||||
if (src) loadedAvatarCache.add(src)
|
||||
if (src) {
|
||||
avatarLoadQueue.clearFailed(src)
|
||||
loadedAvatarCache.add(src)
|
||||
}
|
||||
setImageLoaded(true)
|
||||
setImageError(false)
|
||||
}}
|
||||
onError={() => {
|
||||
if (src) {
|
||||
avatarLoadQueue.markFailed(src)
|
||||
loadedAvatarCache.delete(src)
|
||||
}
|
||||
setImageLoaded(false)
|
||||
setImageError(true)
|
||||
setShouldLoad(false)
|
||||
}}
|
||||
onError={() => setImageError(true)}
|
||||
loading={lazy ? "lazy" : "eager"}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</>
|
||||
) : shouldShowLoadingPlaceholder ? (
|
||||
<div className="avatar-loading">
|
||||
<Loader2 size="50%" className="avatar-loading-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock } from 'lucide-react'
|
||||
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock, Mic } from 'lucide-react'
|
||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||
import '../styles/batchTranscribe.scss'
|
||||
|
||||
@@ -17,6 +17,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
||||
result,
|
||||
sessionName,
|
||||
startTime,
|
||||
taskType,
|
||||
setShowToast,
|
||||
setShowResult
|
||||
} = useBatchTranscribeStore()
|
||||
@@ -64,7 +65,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<Loader2 size={14} className="spin" />
|
||||
<span>批量转写中{sessionName ? `(${sessionName})` : ''}</span>
|
||||
<span>{taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `(${sessionName})` : ''}</span>
|
||||
</div>
|
||||
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||
<X size={14} />
|
||||
@@ -108,8 +109,8 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
||||
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
|
||||
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="batch-modal-header">
|
||||
<CheckCircle size={20} />
|
||||
<h3>转写完成</h3>
|
||||
{taskType === 'decrypt' ? <Mic size={20} /> : <CheckCircle size={20} />}
|
||||
<h3>{taskType === 'decrypt' ? '语音解密完成' : '转写完成'}</h3>
|
||||
</div>
|
||||
<div className="batch-modal-body">
|
||||
<div className="result-summary">
|
||||
@@ -129,7 +130,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
||||
{result.fail > 0 && (
|
||||
<div className="result-tip">
|
||||
<AlertCircle size={16} />
|
||||
<span>部分语音转写失败,可能是语音文件损坏或网络问题</span>
|
||||
<span>{taskType === 'decrypt' ? '部分语音解密失败,可能是语音未缓存或文件损坏' : '部分语音转写失败,可能是语音文件损坏或网络问题'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
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
|
||||
123
src/components/ConfirmDialog.scss
Normal file
123
src/components/ConfirmDialog.scss
Normal file
@@ -0,0 +1,123 @@
|
||||
.confirm-dialog-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
|
||||
.confirm-dialog {
|
||||
width: 480px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
animation: slideUp 0.2s ease-out;
|
||||
overflow: hidden;
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
padding: 40px 40px 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 0 40px 24px;
|
||||
|
||||
p {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
padding: 0 40px 40px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
|
||||
&.btn-cancel {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-confirm {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
32
src/components/ConfirmDialog.tsx
Normal file
32
src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { X } from 'lucide-react'
|
||||
import './ConfirmDialog.scss'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title?: string
|
||||
message: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: ConfirmDialogProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog" onClick={e => e.stopPropagation()}>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{title && <div className="dialog-title">{title}</div>}
|
||||
<div className="dialog-content">
|
||||
<p style={{ whiteSpace: 'pre-line' }}>{message}</p>
|
||||
</div>
|
||||
<div className="dialog-actions">
|
||||
<button className="btn-cancel" onClick={onCancel}>取消</button>
|
||||
<button className="btn-confirm" onClick={onConfirm}>开始获取</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import type { ChatSession } from '../types/models'
|
||||
import type { ChatSession, Message } from '../types/models'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export function GlobalSessionMonitor() {
|
||||
@@ -20,9 +20,9 @@ export function GlobalSessionMonitor() {
|
||||
}, [sessions])
|
||||
|
||||
// 去重辅助函数:获取消息 key
|
||||
const getMessageKey = (msg: any) => {
|
||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||
const getMessageKey = (msg: Message) => {
|
||||
if (msg.messageKey) return msg.messageKey
|
||||
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||
}
|
||||
|
||||
// 处理数据库变更
|
||||
@@ -46,7 +46,6 @@ export function GlobalSessionMonitor() {
|
||||
return () => {
|
||||
removeListener()
|
||||
}
|
||||
} else {
|
||||
}
|
||||
return () => { }
|
||||
}, [])
|
||||
@@ -268,7 +267,12 @@ export function GlobalSessionMonitor() {
|
||||
try {
|
||||
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
||||
if (result.success && result.messages && result.messages.length > 0) {
|
||||
appendMessages(result.messages, false) // 追加到末尾
|
||||
const latestMessages = useChatStore.getState().messages || []
|
||||
const existingKeys = new Set(latestMessages.map(getMessageKey))
|
||||
const newMessages = result.messages.filter((msg: Message) => !existingKeys.has(getMessageKey(msg)))
|
||||
if (newMessages.length > 0) {
|
||||
appendMessages(newMessages, false)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('后台活跃会话刷新失败:', e)
|
||||
|
||||
@@ -137,18 +137,22 @@
|
||||
margin-top: 1px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: #16a34a;
|
||||
color: var(--primary, #07c160);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.selected .day-count {
|
||||
color: #86efac;
|
||||
color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%);
|
||||
}
|
||||
|
||||
.jump-date-popover .day-count-loading {
|
||||
position: static;
|
||||
margin-top: 1px;
|
||||
color: #22c55e;
|
||||
color: var(--primary, #07c160);
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.selected .day-count-loading {
|
||||
color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%);
|
||||
}
|
||||
|
||||
.jump-date-popover .spin {
|
||||
|
||||
@@ -6,6 +6,7 @@ interface JumpToDatePopoverProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSelect: (date: Date) => void
|
||||
onMonthChange?: (date: Date) => void
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
currentDate?: Date
|
||||
@@ -20,6 +21,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
onMonthChange,
|
||||
className,
|
||||
style,
|
||||
currentDate = new Date(),
|
||||
@@ -112,13 +114,17 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
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={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
aria-label="上一月"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
@@ -126,7 +132,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
aria-label="下一月"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
|
||||
@@ -134,6 +134,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.top-center {
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
|
||||
// 灵动岛样式
|
||||
border-radius: 40px !important;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&.static {
|
||||
border-radius: 40px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface NotificationToastProps {
|
||||
onClose: () => void
|
||||
onClick: (sessionId: string) => void
|
||||
duration?: number
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
isStatic?: boolean
|
||||
initialVisible?: boolean
|
||||
}
|
||||
|
||||
@@ -43,31 +43,62 @@
|
||||
.sidebar-user-card-wrap {
|
||||
position: relative;
|
||||
margin: 0 12px 10px;
|
||||
--sidebar-user-menu-width: 172px;
|
||||
}
|
||||
|
||||
.sidebar-user-clear-trigger {
|
||||
.sidebar-user-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
right: auto;
|
||||
bottom: calc(100% + 8px);
|
||||
width: max(100%, var(--sidebar-user-menu-width));
|
||||
z-index: 12;
|
||||
border: 1px solid rgba(255, 59, 48, 0.28);
|
||||
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: var(--bg-secondary);
|
||||
color: #d93025;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 9px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
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);
|
||||
border-color: rgba(255, 59, 48, 0.46);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,24 +275,183 @@
|
||||
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 {
|
||||
@@ -273,6 +463,7 @@
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog {
|
||||
@@ -282,6 +473,7 @@
|
||||
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;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, ChevronUp, Trash2 } from 'lucide-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 { UserRound } from 'lucide-react'
|
||||
|
||||
import './Sidebar.scss'
|
||||
|
||||
@@ -15,11 +18,29 @@ interface SidebarUserProfile {
|
||||
}
|
||||
|
||||
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
|
||||
nickname?: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
@@ -46,11 +67,32 @@ const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
|
||||
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 ''
|
||||
@@ -62,9 +104,13 @@ const normalizeAccountId = (value?: string | null): string => {
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
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>({
|
||||
@@ -72,12 +118,14 @@ function Sidebar() {
|
||||
displayName: '未识别用户'
|
||||
})
|
||||
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
||||
const [showClearAccountDialog, setShowClearAccountDialog] = useState(false)
|
||||
const [shouldClearCacheData, setShouldClearCacheData] = useState(false)
|
||||
const [shouldClearExportData, setShouldClearExportData] = useState(false)
|
||||
const [isClearingAccountData, setIsClearingAccountData] = 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(() => {
|
||||
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||
@@ -139,6 +187,9 @@ function Sidebar() {
|
||||
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(),
|
||||
@@ -164,77 +215,36 @@ function Sidebar() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fallbackDisplayName = resolvedWxid || '未识别用户'
|
||||
|
||||
// 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。
|
||||
patchUserProfile({
|
||||
wxid: resolvedWxid,
|
||||
displayName: fallbackDisplayName
|
||||
})
|
||||
|
||||
if (!resolvedWxidRaw && !resolvedWxid) return
|
||||
|
||||
// 第二阶段:后台补齐名称(不会阻塞首屏)。
|
||||
void (async () => {
|
||||
try {
|
||||
let myContact: Awaited<ReturnType<typeof window.electronAPI.chat.getContact>> | null = null
|
||||
for (const candidate of Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))) {
|
||||
// 并行获取名称和头像
|
||||
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) continue
|
||||
if (!myContact) myContact = contact
|
||||
if (contact.remark || contact.nickName || contact.alias) {
|
||||
myContact = contact
|
||||
break
|
||||
if (contact?.remark || contact?.nickName || contact?.alias) {
|
||||
return contact
|
||||
}
|
||||
}
|
||||
const fromContact = pickFirstValidName(
|
||||
return null
|
||||
})(),
|
||||
window.electronAPI.chat.getMyAvatarUrl()
|
||||
])
|
||||
|
||||
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
|
||||
const displayName = pickFirstValidName(
|
||||
myContact?.remark,
|
||||
myContact?.nickName,
|
||||
myContact?.alias
|
||||
)
|
||||
) || resolvedWxid || '未识别用户'
|
||||
|
||||
if (fromContact) {
|
||||
patchUserProfile({ displayName: fromContact }, resolvedWxid)
|
||||
// 同步补充微信号(alias)
|
||||
if (myContact?.alias) {
|
||||
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const enrichTargets = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid, 'self'].filter(Boolean)))
|
||||
const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets)
|
||||
const enrichedDisplayName = pickFirstValidName(
|
||||
enrichedResult.contacts?.[resolvedWxidRaw]?.displayName,
|
||||
enrichedResult.contacts?.[resolvedWxid]?.displayName,
|
||||
enrichedResult.contacts?.[cleanedWxid]?.displayName,
|
||||
enrichedResult.contacts?.self?.displayName,
|
||||
myContact?.alias
|
||||
)
|
||||
const bestName = enrichedDisplayName
|
||||
if (bestName) {
|
||||
patchUserProfile({ displayName: bestName }, resolvedWxid)
|
||||
}
|
||||
// 降级分支也补充微信号
|
||||
if (myContact?.alias) {
|
||||
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
||||
}
|
||||
} catch (nameError) {
|
||||
console.error('加载侧边栏用户昵称失败:', nameError)
|
||||
}
|
||||
})()
|
||||
|
||||
// 第二阶段:后台补齐头像(不会阻塞首屏)。
|
||||
void (async () => {
|
||||
try {
|
||||
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
|
||||
if (avatarResult.success && avatarResult.avatarUrl) {
|
||||
patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid)
|
||||
}
|
||||
} catch (avatarError) {
|
||||
console.error('加载侧边栏用户头像失败:', avatarError)
|
||||
}
|
||||
})()
|
||||
patchUserProfile({
|
||||
wxid: resolvedWxid,
|
||||
displayName,
|
||||
alias: myContact?.alias,
|
||||
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
|
||||
? avatarResult.value.avatarUrl
|
||||
: undefined
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载侧边栏用户信息失败:', error)
|
||||
}
|
||||
@@ -242,10 +252,7 @@ function Sidebar() {
|
||||
|
||||
const cachedProfile = readSidebarUserProfileCache()
|
||||
if (cachedProfile) {
|
||||
setUserProfile(prev => ({
|
||||
...prev,
|
||||
...cachedProfile
|
||||
}))
|
||||
setUserProfile(cachedProfile)
|
||||
}
|
||||
|
||||
void loadCurrentUser()
|
||||
@@ -259,79 +266,127 @@ function Sidebar() {
|
||||
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: WxidOption) => {
|
||||
const normalizedWxid = normalizeAccountId(option.wxid)
|
||||
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
|
||||
|
||||
let displayName = option.nickname || option.wxid
|
||||
let avatarUrl = option.avatarUrl
|
||||
|
||||
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
|
||||
displayName = userProfile.displayName || displayName
|
||||
avatarUrl = userProfile.avatarUrl || avatarUrl
|
||||
}
|
||||
|
||||
else if (cached) {
|
||||
displayName = cached.displayName || displayName
|
||||
avatarUrl = cached.avatarUrl || avatarUrl
|
||||
}
|
||||
|
||||
return {
|
||||
...option,
|
||||
displayName,
|
||||
avatarUrl
|
||||
}
|
||||
})
|
||||
|
||||
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}`
|
||||
const canConfirmClear = shouldClearCacheData || shouldClearExportData
|
||||
|
||||
const resetClearDialogState = () => {
|
||||
setShouldClearCacheData(false)
|
||||
setShouldClearExportData(false)
|
||||
setShowClearAccountDialog(false)
|
||||
}
|
||||
|
||||
const openClearAccountDialog = () => {
|
||||
setIsAccountMenuOpen(false)
|
||||
setShouldClearCacheData(false)
|
||||
setShouldClearExportData(false)
|
||||
setShowClearAccountDialog(true)
|
||||
}
|
||||
|
||||
const handleConfirmClearAccountData = async () => {
|
||||
if (!canConfirmClear || isClearingAccountData) return
|
||||
setIsClearingAccountData(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.clearCurrentAccountData({
|
||||
clearCache: shouldClearCacheData,
|
||||
clearExports: shouldClearExportData
|
||||
})
|
||||
if (!result.success) {
|
||||
window.alert(result.error || '清理失败,请稍后重试。')
|
||||
return
|
||||
}
|
||||
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
setUserProfile({ wxid: '', displayName: '未识别用户' })
|
||||
window.dispatchEvent(new Event('wxid-changed'))
|
||||
|
||||
const removedPaths = Array.isArray(result.removedPaths) ? result.removedPaths : []
|
||||
const selectedScopes = [
|
||||
shouldClearCacheData ? '缓存数据' : '',
|
||||
shouldClearExportData ? '导出数据' : ''
|
||||
].filter(Boolean)
|
||||
const detailLines: string[] = [
|
||||
`清理范围:${selectedScopes.join('、') || '未选择'}`,
|
||||
`已清理项目:${removedPaths.length} 项`
|
||||
]
|
||||
if (removedPaths.length > 0) {
|
||||
detailLines.push('', '清理明细(最多显示 8 项):')
|
||||
for (const [index, path] of removedPaths.slice(0, 8).entries()) {
|
||||
detailLines.push(`${index + 1}. ${path}`)
|
||||
}
|
||||
if (removedPaths.length > 8) {
|
||||
detailLines.push(`... 其余 ${removedPaths.length - 8} 项已省略`)
|
||||
}
|
||||
}
|
||||
if (result.warning) {
|
||||
detailLines.push('', `注意:${result.warning}`)
|
||||
}
|
||||
const followupHint = shouldClearCacheData
|
||||
? '若需再次获取数据,请手动登录微信客户端并重新在 WeFlow 完成配置。'
|
||||
: '你可以继续使用当前登录状态,无需重新登录。'
|
||||
window.alert(`账号数据清理完成。\n\n${detailLines.join('\n')}\n\n为保障数据安全,WeFlow 已清除该账号本地缓存/导出相关数据。${followupHint}`)
|
||||
resetClearDialogState()
|
||||
if (shouldClearCacheData) {
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清理账号数据失败:', error)
|
||||
window.alert('清理失败,请稍后重试。')
|
||||
} finally {
|
||||
setIsClearingAccountData(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<nav className="nav-menu">
|
||||
{/* 首页 */}
|
||||
@@ -374,24 +429,14 @@ function Sidebar() {
|
||||
<span className="nav-label">通讯录</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 私聊分析 */}
|
||||
{/* 聊天分析 */}
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
|
||||
title={collapsed ? '私聊分析' : undefined}
|
||||
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>
|
||||
<span className="nav-label">聊天分析</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 年度报告 */}
|
||||
@@ -426,17 +471,47 @@ function Sidebar() {
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
|
||||
{isAccountMenuOpen && (
|
||||
<button
|
||||
className="sidebar-user-clear-trigger"
|
||||
onClick={openClearAccountDialog}
|
||||
type="button"
|
||||
className="nav-item"
|
||||
onClick={() => {
|
||||
if (authEnabled) {
|
||||
setLocked(true)
|
||||
return
|
||||
}
|
||||
navigate('/settings', {
|
||||
state: {
|
||||
initialTab: 'security',
|
||||
backgroundLocation: location
|
||||
}
|
||||
})
|
||||
}}
|
||||
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>清除此账号所有数据</span>
|
||||
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
|
||||
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<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}
|
||||
@@ -464,81 +539,47 @@ function Sidebar() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authEnabled && (
|
||||
<button
|
||||
className="nav-item"
|
||||
onClick={() => setLocked(true)}
|
||||
title={collapsed ? '锁定' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Lock size={20} /></span>
|
||||
<span className="nav-label">锁定</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
||||
title={collapsed ? '设置' : undefined}
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<Settings size={20} />
|
||||
</span>
|
||||
<span className="nav-label">设置</span>
|
||||
</NavLink>
|
||||
|
||||
<button
|
||||
className="collapse-btn"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
title={collapsed ? '展开菜单' : '收起菜单'}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showClearAccountDialog && (
|
||||
<div className="sidebar-clear-dialog-overlay" onClick={() => !isClearingAccountData && resetClearDialogState()}>
|
||||
<div className="sidebar-clear-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>清除此账号所有数据</h3>
|
||||
<p>
|
||||
操作后可将该账户在 weflow 下产生的所有缓存文件、导出文件等彻底清除。
|
||||
清除后必须手动登录微信客户端 weflow 才能再次获取,保障你的数据安全。
|
||||
</p>
|
||||
<div className="sidebar-clear-options">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shouldClearCacheData}
|
||||
onChange={(event) => setShouldClearCacheData(event.target.checked)}
|
||||
disabled={isClearingAccountData}
|
||||
/>
|
||||
缓存数据
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shouldClearExportData}
|
||||
onChange={(event) => setShouldClearExportData(event.target.checked)}
|
||||
disabled={isClearingAccountData}
|
||||
/>
|
||||
导出数据
|
||||
</label>
|
||||
</div>
|
||||
<div className="sidebar-clear-actions">
|
||||
<button type="button" onClick={resetClearDialogState} disabled={isClearingAccountData}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
disabled={!canConfirmClear || isClearingAccountData}
|
||||
onClick={handleConfirmClearAccountData}
|
||||
>
|
||||
{isClearingAccountData ? '清除中...' : '确认清除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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="" />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-tertiary)', borderRadius: '6px', color: 'var(--text-tertiary)' }}>
|
||||
<UserRound size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="wxid-info">
|
||||
<div className="wxid-name">{option.displayName}</div>
|
||||
{option.displayName !== option.wxid && <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,60 +1,66 @@
|
||||
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 = () => {
|
||||
@@ -71,7 +77,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
<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>
|
||||
@@ -99,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>
|
||||
|
||||
@@ -152,18 +128,57 @@ 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 => {
|
||||
const isPostCountReady = contact.postCountStatus === 'ready'
|
||||
const isSelected = selectedContactLookup.has(contact.username)
|
||||
const isActive = activeContactUsername === contact.username
|
||||
return (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
|
||||
onClick={() => toggleUserSelection(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>
|
||||
)
|
||||
})}
|
||||
@@ -171,6 +186,19 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
<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>
|
||||
|
||||
@@ -243,10 +243,12 @@ interface SnsPostItemProps {
|
||||
post: SnsPost
|
||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||
onDebug: (post: SnsPost) => void
|
||||
onDelete?: (postId: string) => void
|
||||
onDelete?: (postId: string, username: string) => void
|
||||
onOpenAuthorPosts?: (post: SnsPost) => void
|
||||
hideAuthorMeta?: boolean
|
||||
}
|
||||
|
||||
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete }) => {
|
||||
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)
|
||||
@@ -299,31 +301,56 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
|
||||
if (r.success) {
|
||||
setDbDeleted(true)
|
||||
onDelete?.(post.id)
|
||||
onDelete?.(post.id, post.username)
|
||||
}
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenAuthorPosts = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onOpenAuthorPosts?.(post)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{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 || dbDeleted) && (
|
||||
<span className="post-deleted-badge">
|
||||
|
||||
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,15 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// 繁花如梦:标题栏毛玻璃
|
||||
@@ -16,6 +20,12 @@
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.title-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-logo {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -26,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" />
|
||||
<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 }} />
|
||||
) : (
|
||||
|
||||
306
src/components/WindowCloseDialog.scss
Normal file
306
src/components/WindowCloseDialog.scss
Normal file
@@ -0,0 +1,306 @@
|
||||
.window-close-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(36, 42, 54, 0.18), transparent 48%),
|
||||
rgba(7, 10, 18, 0.56);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 3000;
|
||||
animation: windowCloseDialogFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.window-close-dialog {
|
||||
width: min(560px, 100%);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 94%, white 6%) 0%, var(--bg-primary) 100%);
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.window-close-dialog-header {
|
||||
padding: 28px 30px 18px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
|
||||
.window-close-dialog-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 14px 0 8px;
|
||||
font-size: 26px;
|
||||
line-height: 1.1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-body {
|
||||
padding: 20px 24px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.window-close-dialog-option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 18px 18px 18px 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 86%, white 14%) 0%, var(--bg-secondary) 100%);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
background 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
|
||||
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.is-danger:hover {
|
||||
border-color: rgba(205, 73, 73, 0.42);
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-option-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--primary) 14%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.window-close-dialog-option.is-danger .window-close-dialog-option-icon {
|
||||
background: rgba(205, 73, 73, 0.12);
|
||||
color: #cd4949;
|
||||
}
|
||||
|
||||
.window-close-dialog-option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-actions {
|
||||
padding: 8px 24px 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.window-close-dialog-remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 24px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 76%, transparent);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
border: 1.5px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg) scale(0.7);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent);
|
||||
}
|
||||
|
||||
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox::after {
|
||||
opacity: 1;
|
||||
transform: rotate(45deg) scale(1);
|
||||
}
|
||||
|
||||
.window-close-dialog-remember-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.window-close-dialog-cancel {
|
||||
min-width: 112px;
|
||||
padding: 12px 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
border-color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 24%, var(--border-color));
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-close {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 84%, transparent);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.window-close-dialog-overlay {
|
||||
padding: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.window-close-dialog {
|
||||
border-radius: 24px 24px 18px 18px;
|
||||
}
|
||||
|
||||
.window-close-dialog-header {
|
||||
padding: 24px 22px 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.window-close-dialog-body {
|
||||
padding: 18px 18px 10px;
|
||||
}
|
||||
|
||||
.window-close-dialog-actions {
|
||||
padding: 8px 18px 18px;
|
||||
}
|
||||
|
||||
.window-close-dialog-cancel {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes windowCloseDialogFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes windowCloseDialogSlideUp {
|
||||
from {
|
||||
transform: translateY(24px) scale(0.98);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
115
src/components/WindowCloseDialog.tsx
Normal file
115
src/components/WindowCloseDialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Minimize2, Power, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import './WindowCloseDialog.scss'
|
||||
|
||||
interface WindowCloseDialogProps {
|
||||
open: boolean
|
||||
canMinimizeToTray: boolean
|
||||
onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function WindowCloseDialog({
|
||||
open,
|
||||
canMinimizeToTray,
|
||||
onSelect,
|
||||
onCancel
|
||||
}: WindowCloseDialogProps) {
|
||||
const [rememberChoice, setRememberChoice] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setRememberChoice(false)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, onCancel])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="window-close-dialog-overlay" onClick={onCancel}>
|
||||
<div
|
||||
className="window-close-dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="window-close-dialog-title"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="window-close-dialog-close"
|
||||
onClick={onCancel}
|
||||
aria-label="关闭提示"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="window-close-dialog-header">
|
||||
<span className="window-close-dialog-kicker">退出行为</span>
|
||||
<h2 id="window-close-dialog-title">关闭 WeFlow</h2>
|
||||
<p>
|
||||
{canMinimizeToTray
|
||||
? '你可以保留后台进程与本地 API,或者直接完全退出应用。'
|
||||
: '当前系统托盘不可用,本次只能完全退出应用。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="window-close-dialog-body">
|
||||
{canMinimizeToTray && (
|
||||
<button
|
||||
type="button"
|
||||
className="window-close-dialog-option"
|
||||
onClick={() => onSelect('tray', rememberChoice)}
|
||||
>
|
||||
<span className="window-close-dialog-option-icon">
|
||||
<Minimize2 size={18} />
|
||||
</span>
|
||||
<span className="window-close-dialog-option-text">
|
||||
<strong>最小化到系统托盘</strong>
|
||||
<span>继续保留后台进程和本地 API,稍后可从托盘恢复。</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="window-close-dialog-option is-danger"
|
||||
onClick={() => onSelect('quit', rememberChoice)}
|
||||
>
|
||||
<span className="window-close-dialog-option-icon">
|
||||
<Power size={18} />
|
||||
</span>
|
||||
<span className="window-close-dialog-option-text">
|
||||
<strong>完全关闭</strong>
|
||||
<span>结束 WeFlow 进程,并停止当前保留的本地 API。</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="window-close-dialog-remember">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberChoice}
|
||||
onChange={(event) => setRememberChoice(event.target.checked)}
|
||||
/>
|
||||
<span className="window-close-dialog-checkbox" aria-hidden="true" />
|
||||
<span className="window-close-dialog-remember-text">下次不再提示,直接按本次选择处理</span>
|
||||
</label>
|
||||
|
||||
<div className="window-close-dialog-actions">
|
||||
<button type="button" className="window-close-dialog-cancel" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,15 +28,18 @@ function AnalyticsWelcomePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="analytics-welcome-container">
|
||||
<div className="analytics-entry-page">
|
||||
<ChatAnalysisHeader currentMode="private" />
|
||||
|
||||
<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 />
|
||||
你可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
|
||||
WeFlow 可以分析你的好友聊天记录,生成详细的统计报表。<br />
|
||||
你可以选择加载上次的分析结果,或者重新开始一次新的私聊分析。
|
||||
</p>
|
||||
|
||||
<div className="action-cards">
|
||||
@@ -57,6 +61,7 @@ function AnalyticsWelcomePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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'
|
||||
@@ -49,8 +55,17 @@ function AnnualReportPage() {
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let taskId = ''
|
||||
let uiTaskId = ''
|
||||
|
||||
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)
|
||||
@@ -91,6 +106,14 @@ function AnnualReportPage() {
|
||||
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)
|
||||
@@ -105,6 +128,18 @@ function AnnualReportPage() {
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -120,6 +155,9 @@ function AnnualReportPage() {
|
||||
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)
|
||||
@@ -131,6 +169,9 @@ function AnnualReportPage() {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
finishBackgroundTask(uiTaskId, 'failed', {
|
||||
detail: String(e)
|
||||
})
|
||||
setLoadError(String(e))
|
||||
setIsLoading(false)
|
||||
setIsLoadingMoreYears(false)
|
||||
@@ -168,16 +209,7 @@ function AnnualReportPage() {
|
||||
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>
|
||||
<div className="load-telemetry compact">
|
||||
<p><span className="label">加载方式:</span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
|
||||
<p><span className="label">状态:</span>{loadStatusText || '正在加载年份数据...'}</p>
|
||||
<p>
|
||||
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
|
||||
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
|
||||
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在准备年度报告...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -223,30 +255,6 @@ function AnnualReportPage() {
|
||||
<Sparkles size={32} className="header-icon" />
|
||||
<h1 className="page-title">年度报告</h1>
|
||||
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||||
{loadedYearCount > 0 && (
|
||||
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||
{isYearStatusComplete ? (
|
||||
<>已显示 {loadedYearCount} 个年份,年份数据已全部加载完毕。总耗时 {formatLoadElapsed(totalElapsedMs)}</>
|
||||
) : (
|
||||
<>
|
||||
已显示 {loadedYearCount} 个年份,正在补充更多年份<span className="dot-ellipsis" aria-hidden="true">...</span>
|
||||
(已耗时 {formatLoadElapsed(totalElapsedMs)})
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||
<p><span className="label">加载方式:</span>{strategyLabel}</p>
|
||||
<p>
|
||||
<span className="label">状态:</span>
|
||||
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
|
||||
</p>
|
||||
<p>
|
||||
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
|
||||
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
|
||||
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="report-sections">
|
||||
<section className="report-section">
|
||||
@@ -270,7 +278,6 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{renderYearLoadStatus()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -317,7 +324,6 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{renderYearLoadStatus()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -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
|
||||
@@ -2,15 +2,16 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 96%, white) 0%, var(--bg-primary) 100%);
|
||||
|
||||
.history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
padding: 18px 18px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 0;
|
||||
|
||||
.status-msg {
|
||||
text-align: center;
|
||||
@@ -30,68 +31,84 @@
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 14px 0 0;
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
&.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-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.history-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
|
||||
.avatar-component.avatar-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
|
||||
img.avatar-image {
|
||||
// Forwarded record head images may include a light matte edge.
|
||||
// Slightly zoom in to crop that edge and align with normal chat avatars.
|
||||
transform: scale(1.12);
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.sender {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: color-mix(in srgb, var(--text-secondary) 82%, transparent);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
color: color-mix(in srgb, var(--text-tertiary) 92%, transparent);
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: var(--bg-secondary);
|
||||
padding: 10px 14px;
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
display: inline-block;
|
||||
display: block;
|
||||
|
||||
&.image-bubble {
|
||||
padding: 0;
|
||||
@@ -99,8 +116,8 @@
|
||||
}
|
||||
|
||||
.text-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -108,23 +125,84 @@
|
||||
|
||||
.media-content {
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
border-radius: 8px;
|
||||
max-width: min(100%, 420px);
|
||||
max-height: 320px;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
}
|
||||
|
||||
.media-tip {
|
||||
padding: 8px 12px;
|
||||
padding: 6px 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.media-placeholder {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 0;
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
|
||||
.nested-chat-record-card {
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 97%, #f5f7fb);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
border-color: color-mix(in srgb, var(--primary) 28%, var(--border-color));
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
border: 1px solid var(--border-color);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.nested-chat-record-title {
|
||||
padding: 13px 15px 9px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nested-chat-record-list {
|
||||
padding: 0 15px 11px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nested-chat-record-line {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nested-chat-record-footer {
|
||||
padding: 8px 15px 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ 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 { Avatar } from '../components/Avatar'
|
||||
import './ChatHistoryPage.scss'
|
||||
|
||||
const forwardedImageCache = new Map<string, string>()
|
||||
|
||||
export default function ChatHistoryPage() {
|
||||
const params = useParams<{ sessionId: string; messageId: string }>()
|
||||
const params = useParams<{ sessionId: string; messageId: string; payloadId: string }>()
|
||||
const location = useLocation()
|
||||
const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -29,64 +33,212 @@ export default function ChatHistoryPage() {
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
// 前端兜底解析合并转发聊天记录
|
||||
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
|
||||
try {
|
||||
const type = extractXmlValue(content, 'type')
|
||||
if (type !== '19') return undefined
|
||||
const extractTopLevelXmlElements = (source: string, tagName: string): Array<{ attrs: string; inner: string }> => {
|
||||
const xml = source || ''
|
||||
if (!xml) return []
|
||||
|
||||
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
|
||||
if (!match) return undefined
|
||||
const pattern = new RegExp(`<(/?)${tagName}\\b([^>]*)>`, 'gi')
|
||||
const result: Array<{ attrs: string; inner: string }> = []
|
||||
let match: RegExpExecArray | null
|
||||
let depth = 0
|
||||
let openEnd = -1
|
||||
let openStart = -1
|
||||
let openAttrs = ''
|
||||
|
||||
const innerXml = match[1]
|
||||
const items: ChatRecordItem[] = []
|
||||
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
|
||||
let itemMatch: RegExpExecArray | null
|
||||
while ((match = pattern.exec(xml)) !== null) {
|
||||
const isClosing = match[1] === '/'
|
||||
const attrs = match[2] || ''
|
||||
const rawTag = match[0] || ''
|
||||
const selfClosing = !isClosing && /\/\s*>$/.test(rawTag)
|
||||
|
||||
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
|
||||
const attrs = itemMatch[1]
|
||||
const body = itemMatch[2]
|
||||
if (!isClosing) {
|
||||
if (depth === 0) {
|
||||
openStart = match.index
|
||||
openEnd = pattern.lastIndex
|
||||
openAttrs = attrs
|
||||
}
|
||||
if (!selfClosing) {
|
||||
depth += 1
|
||||
} else if (depth === 0 && openEnd >= 0) {
|
||||
result.push({ attrs: openAttrs, inner: '' })
|
||||
openStart = -1
|
||||
openEnd = -1
|
||||
openAttrs = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
|
||||
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
|
||||
if (depth <= 0) continue
|
||||
depth -= 1
|
||||
if (depth === 0 && openEnd >= 0 && openStart >= 0) {
|
||||
result.push({
|
||||
attrs: openAttrs,
|
||||
inner: xml.slice(openEnd, match.index)
|
||||
})
|
||||
openStart = -1
|
||||
openEnd = -1
|
||||
openAttrs = ''
|
||||
}
|
||||
}
|
||||
|
||||
const sourcename = extractXmlValue(body, 'sourcename')
|
||||
const sourcetime = extractXmlValue(body, 'sourcetime')
|
||||
const sourceheadurl = extractXmlValue(body, 'sourceheadurl')
|
||||
const datadesc = extractXmlValue(body, 'datadesc')
|
||||
const datatitle = extractXmlValue(body, 'datatitle')
|
||||
const fileext = extractXmlValue(body, 'fileext')
|
||||
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0')
|
||||
const messageuuid = extractXmlValue(body, 'messageuuid')
|
||||
return result
|
||||
}
|
||||
|
||||
const dataurl = extractXmlValue(body, 'dataurl')
|
||||
const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl')
|
||||
const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl')
|
||||
const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')
|
||||
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5')
|
||||
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0')
|
||||
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0')
|
||||
const duration = parseInt(extractXmlValue(body, 'duration') || '0')
|
||||
const parseChatRecordDataItem = (body: string, attrs = ''): ChatRecordItem | null => {
|
||||
const datatypeMatch = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '')
|
||||
const datatype = datatypeMatch ? parseInt(datatypeMatch[1], 10) : parseInt(extractXmlValue(body, 'datatype') || '0', 10)
|
||||
|
||||
items.push({
|
||||
datatype,
|
||||
const sourcename = decodeHtmlEntities(extractXmlValue(body, 'sourcename')) || ''
|
||||
const sourcetime = extractXmlValue(body, 'sourcetime') || ''
|
||||
const sourceheadurl = extractXmlValue(body, 'sourceheadurl') || undefined
|
||||
const datadesc = decodeHtmlEntities(extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content')) || undefined
|
||||
const datatitle = decodeHtmlEntities(extractXmlValue(body, 'datatitle')) || undefined
|
||||
const fileext = extractXmlValue(body, 'fileext') || undefined
|
||||
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0', 10) || undefined
|
||||
const messageuuid = extractXmlValue(body, 'messageuuid') || undefined
|
||||
|
||||
const dataurl = decodeHtmlEntities(extractXmlValue(body, 'dataurl')) || undefined
|
||||
const datathumburl = decodeHtmlEntities(
|
||||
extractXmlValue(body, 'datathumburl') ||
|
||||
extractXmlValue(body, 'thumburl') ||
|
||||
extractXmlValue(body, 'cdnthumburl')
|
||||
) || undefined
|
||||
const datacdnurl = decodeHtmlEntities(
|
||||
extractXmlValue(body, 'datacdnurl') ||
|
||||
extractXmlValue(body, 'cdnurl') ||
|
||||
extractXmlValue(body, 'cdndataurl')
|
||||
) || undefined
|
||||
const cdndatakey = decodeHtmlEntities(extractXmlValue(body, 'cdndatakey')) || undefined
|
||||
const cdnthumbkey = decodeHtmlEntities(extractXmlValue(body, 'cdnthumbkey')) || undefined
|
||||
const aeskey = decodeHtmlEntities(extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')) || undefined
|
||||
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5') || undefined
|
||||
const fullmd5 = extractXmlValue(body, 'fullmd5') || undefined
|
||||
const thumbfullmd5 = extractXmlValue(body, 'thumbfullmd5') || undefined
|
||||
const srcMsgLocalid = parseInt(extractXmlValue(body, 'srcMsgLocalid') || '0', 10) || undefined
|
||||
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0', 10) || undefined
|
||||
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0', 10) || undefined
|
||||
const duration = parseInt(extractXmlValue(body, 'duration') || '0', 10) || undefined
|
||||
const nestedRecordXml = extractXmlValue(body, 'recordxml') || undefined
|
||||
const chatRecordTitle = decodeHtmlEntities(
|
||||
(nestedRecordXml && extractXmlValue(nestedRecordXml, 'title')) ||
|
||||
datatitle ||
|
||||
''
|
||||
) || undefined
|
||||
const chatRecordDesc = decodeHtmlEntities(
|
||||
(nestedRecordXml && extractXmlValue(nestedRecordXml, 'desc')) ||
|
||||
datadesc ||
|
||||
''
|
||||
) || undefined
|
||||
const chatRecordList =
|
||||
datatype === 17 && nestedRecordXml
|
||||
? parseChatRecordContainer(nestedRecordXml)
|
||||
: undefined
|
||||
|
||||
if (!(datatype || sourcename || datadesc || datatitle || messageuuid || srcMsgLocalid)) return null
|
||||
|
||||
return {
|
||||
datatype: Number.isFinite(datatype) ? datatype : 0,
|
||||
sourcename,
|
||||
sourcetime,
|
||||
sourceheadurl,
|
||||
datadesc: decodeHtmlEntities(datadesc),
|
||||
datatitle: decodeHtmlEntities(datatitle),
|
||||
datadesc,
|
||||
datatitle,
|
||||
fileext,
|
||||
datasize,
|
||||
messageuuid,
|
||||
dataurl: decodeHtmlEntities(dataurl),
|
||||
datathumburl: decodeHtmlEntities(datathumburl),
|
||||
datacdnurl: decodeHtmlEntities(datacdnurl),
|
||||
aeskey: decodeHtmlEntities(aeskey),
|
||||
dataurl,
|
||||
datathumburl,
|
||||
datacdnurl,
|
||||
cdndatakey,
|
||||
cdnthumbkey,
|
||||
aeskey,
|
||||
md5,
|
||||
fullmd5,
|
||||
thumbfullmd5,
|
||||
srcMsgLocalid,
|
||||
imgheight,
|
||||
imgwidth,
|
||||
duration
|
||||
})
|
||||
duration,
|
||||
chatRecordTitle,
|
||||
chatRecordDesc,
|
||||
chatRecordList
|
||||
}
|
||||
}
|
||||
|
||||
const parseChatRecordContainer = (containerXml: string): ChatRecordItem[] => {
|
||||
const source = containerXml || ''
|
||||
if (!source) return []
|
||||
|
||||
const segments: string[] = [source]
|
||||
const decodedContainer = decodeHtmlEntities(source)
|
||||
if (decodedContainer && decodedContainer !== source) {
|
||||
segments.push(decodedContainer)
|
||||
}
|
||||
|
||||
const cdataRegex = /<!\[CDATA\[([\s\S]*?)\]\]>/g
|
||||
let cdataMatch: RegExpExecArray | null
|
||||
while ((cdataMatch = cdataRegex.exec(source)) !== null) {
|
||||
const cdataInner = cdataMatch[1] || ''
|
||||
if (!cdataInner) continue
|
||||
segments.push(cdataInner)
|
||||
const decodedInner = decodeHtmlEntities(cdataInner)
|
||||
if (decodedInner && decodedInner !== cdataInner) {
|
||||
segments.push(decodedInner)
|
||||
}
|
||||
}
|
||||
|
||||
const items: ChatRecordItem[] = []
|
||||
const dedupe = new Set<string>()
|
||||
for (const segment of segments) {
|
||||
if (!segment) continue
|
||||
const dataItems = extractTopLevelXmlElements(segment, 'dataitem')
|
||||
for (const dataItem of dataItems) {
|
||||
const item = parseChatRecordDataItem(dataItem.inner || '', dataItem.attrs || '')
|
||||
if (!item) continue
|
||||
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
|
||||
if (!dedupe.has(key)) {
|
||||
dedupe.add(key)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) return items
|
||||
const fallback = parseChatRecordDataItem(source, '')
|
||||
return fallback ? [fallback] : []
|
||||
}
|
||||
|
||||
// 前端兜底解析合并转发聊天记录
|
||||
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
|
||||
try {
|
||||
const decodedContent = decodeHtmlEntities(content) || content
|
||||
const type = extractXmlValue(decodedContent, 'type')
|
||||
if (type !== '19' && !decodedContent.includes('<recorditem')) return undefined
|
||||
|
||||
const items: ChatRecordItem[] = []
|
||||
const dedupe = new Set<string>()
|
||||
const recordItemRegex = /<recorditem>([\s\S]*?)<\/recorditem>/gi
|
||||
let recordItemMatch: RegExpExecArray | null
|
||||
while ((recordItemMatch = recordItemRegex.exec(decodedContent)) !== null) {
|
||||
const parsedItems = parseChatRecordContainer(recordItemMatch[1] || '')
|
||||
for (const item of parsedItems) {
|
||||
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
|
||||
if (!dedupe.has(key)) {
|
||||
dedupe.add(key)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0 && decodedContent.includes('<dataitem')) {
|
||||
const parsedItems = parseChatRecordContainer(decodedContent)
|
||||
for (const item of parsedItems) {
|
||||
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
|
||||
if (!dedupe.has(key)) {
|
||||
dedupe.add(key)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined
|
||||
@@ -114,9 +266,34 @@ export default function ChatHistoryPage() {
|
||||
return { sid: '', mid: '' }
|
||||
}
|
||||
|
||||
const ids = getIds()
|
||||
const payloadId = params.payloadId || (() => {
|
||||
const match = /^\/chat-history-inline\/([^/]+)/.exec(location.pathname)
|
||||
return match ? match[1] : ''
|
||||
})()
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const { sid, mid } = getIds()
|
||||
if (payloadId) {
|
||||
try {
|
||||
const result = await window.electronAPI.window.getChatHistoryPayload(payloadId)
|
||||
if (result.success && result.payload) {
|
||||
setRecordList(Array.isArray(result.payload.recordList) ? result.payload.recordList : [])
|
||||
setTitle(result.payload.title || '聊天记录')
|
||||
setError('')
|
||||
} else {
|
||||
setError(result.error || '聊天记录载荷不存在')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setError('加载详情失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const { sid, mid } = ids
|
||||
if (!sid || !mid) {
|
||||
setError('无效的聊天记录链接')
|
||||
setLoading(false)
|
||||
@@ -152,7 +329,7 @@ export default function ChatHistoryPage() {
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [params.sessionId, params.messageId, location.pathname])
|
||||
}, [ids.mid, ids.sid, location.pathname, payloadId])
|
||||
|
||||
return (
|
||||
<div className="chat-history-page">
|
||||
@@ -166,7 +343,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} sessionId={ids.sid} />
|
||||
</ErrorBoundary>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -174,7 +353,198 @@ export default function ChatHistoryPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
function detectImageMimeFromBase64(base64: string): string {
|
||||
try {
|
||||
const head = window.atob(base64.slice(0, 48))
|
||||
const bytes = new Uint8Array(head.length)
|
||||
for (let i = 0; i < head.length; i++) {
|
||||
bytes[i] = head.charCodeAt(i)
|
||||
}
|
||||
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif'
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png'
|
||||
if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg'
|
||||
if (
|
||||
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
||||
) {
|
||||
return 'image/webp'
|
||||
}
|
||||
} catch { }
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
function normalizeChatRecordText(value?: string): string {
|
||||
return String(value || '')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getChatRecordPreviewText(item: ChatRecordItem): string {
|
||||
const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle)
|
||||
if (item.datatype === 17) {
|
||||
return normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录'
|
||||
}
|
||||
if (item.datatype === 2 || item.datatype === 3) return '[图片]'
|
||||
if (item.datatype === 43) return '[视频]'
|
||||
if (item.datatype === 34) return '[语音]'
|
||||
if (item.datatype === 47) return '[表情]'
|
||||
return text || '[媒体消息]'
|
||||
}
|
||||
|
||||
function ForwardedImage({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) {
|
||||
const cacheKey =
|
||||
item.thumbfullmd5 ||
|
||||
item.fullmd5 ||
|
||||
item.md5 ||
|
||||
item.messageuuid ||
|
||||
item.datathumburl ||
|
||||
item.datacdnurl ||
|
||||
item.dataurl ||
|
||||
`local:${item.srcMsgLocalid || 0}`
|
||||
const [localPath, setLocalPath] = useState<string | undefined>(() => forwardedImageCache.get(cacheKey))
|
||||
const [loading, setLoading] = useState(!forwardedImageCache.has(cacheKey))
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (localPath || error) return
|
||||
|
||||
let cancelled = false
|
||||
const candidateMd5s = Array.from(new Set([
|
||||
item.thumbfullmd5,
|
||||
item.fullmd5,
|
||||
item.md5
|
||||
].filter(Boolean) as string[]))
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
|
||||
for (const imageMd5 of candidateMd5s) {
|
||||
const cached = await window.electronAPI.image.resolveCache({ imageMd5 })
|
||||
if (cached.success && cached.localPath) {
|
||||
if (!cancelled) {
|
||||
forwardedImageCache.set(cacheKey, cached.localPath)
|
||||
setLocalPath(cached.localPath)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for (const imageMd5 of candidateMd5s) {
|
||||
const decrypted = await window.electronAPI.image.decrypt({ imageMd5 })
|
||||
if (decrypted.success && decrypted.localPath) {
|
||||
if (!cancelled) {
|
||||
forwardedImageCache.set(cacheKey, decrypted.localPath)
|
||||
setLocalPath(decrypted.localPath)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId && item.srcMsgLocalid) {
|
||||
const fallback = await window.electronAPI.chat.getImageData(sessionId, String(item.srcMsgLocalid))
|
||||
if (fallback.success && fallback.data) {
|
||||
const dataUrl = `data:${detectImageMimeFromBase64(fallback.data)};base64,${fallback.data}`
|
||||
if (!cancelled) {
|
||||
forwardedImageCache.set(cacheKey, dataUrl)
|
||||
setLocalPath(dataUrl)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const remoteSrc = item.dataurl || item.datathumburl || item.datacdnurl
|
||||
if (remoteSrc && /^https?:\/\//i.test(remoteSrc)) {
|
||||
if (!cancelled) {
|
||||
setLocalPath(remoteSrc)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setError(true)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load().catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [cacheKey, error, item.dataurl, item.datacdnurl, item.datathumburl, item.fullmd5, item.md5, item.messageuuid, item.srcMsgLocalid, item.thumbfullmd5, localPath, sessionId])
|
||||
|
||||
if (localPath) {
|
||||
return (
|
||||
<div className="media-content">
|
||||
<img src={localPath} alt="图片" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="media-tip">图片加载中...</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="media-tip">图片未索引到本地缓存</div>
|
||||
}
|
||||
|
||||
return <div className="media-placeholder">[图片]</div>
|
||||
}
|
||||
|
||||
function NestedChatRecordCard({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) {
|
||||
const previewItems = (item.chatRecordList || []).slice(0, 3)
|
||||
const title = normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录'
|
||||
const description = normalizeChatRecordText(item.chatRecordDesc) || normalizeChatRecordText(item.datadesc)
|
||||
const canOpen = Boolean(sessionId && item.chatRecordList && item.chatRecordList.length > 0)
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!canOpen) return
|
||||
window.electronAPI.window.openChatHistoryPayloadWindow({
|
||||
sessionId,
|
||||
title,
|
||||
recordList: item.chatRecordList || []
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`nested-chat-record-card${canOpen ? ' clickable' : ''}`}
|
||||
onClick={handleOpen}
|
||||
disabled={!canOpen}
|
||||
title={canOpen ? '点击打开聊天记录' : undefined}
|
||||
>
|
||||
<div className="nested-chat-record-title">{title}</div>
|
||||
{previewItems.length > 0 ? (
|
||||
<div className="nested-chat-record-list">
|
||||
{previewItems.map((previewItem, index) => (
|
||||
<div key={`${previewItem.messageuuid || previewItem.srcMsgLocalid || index}`} className="nested-chat-record-line">
|
||||
{getChatRecordPreviewText(previewItem)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : description ? (
|
||||
<div className="nested-chat-record-list">
|
||||
<div className="nested-chat-record-line">{description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="nested-chat-record-footer">聊天记录</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryItem({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) {
|
||||
// sourcetime 在合并转发里有两种格式:
|
||||
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
||||
let time = ''
|
||||
@@ -186,34 +556,18 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
}
|
||||
}
|
||||
|
||||
const senderDisplayName = item.sourcename ?? '未知发送者'
|
||||
|
||||
const renderContent = () => {
|
||||
if (item.datatype === 1) {
|
||||
// 文本消息
|
||||
return <div className="text-content">{item.datadesc || ''}</div>
|
||||
}
|
||||
if (item.datatype === 3) {
|
||||
// 图片
|
||||
const src = item.datathumburl || item.datacdnurl
|
||||
if (src) {
|
||||
return (
|
||||
<div className="media-content">
|
||||
<img
|
||||
src={src}
|
||||
alt="图片"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const placeholder = document.createElement('div')
|
||||
placeholder.className = 'media-tip'
|
||||
placeholder.textContent = '图片无法加载'
|
||||
target.parentElement?.appendChild(placeholder)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
if (item.datatype === 2 || item.datatype === 3) {
|
||||
return <ForwardedImage item={item} sessionId={sessionId} />
|
||||
}
|
||||
return <div className="media-placeholder">[图片]</div>
|
||||
if (item.datatype === 17) {
|
||||
return <NestedChatRecordCard item={item} sessionId={sessionId} />
|
||||
}
|
||||
if (item.datatype === 43) {
|
||||
return <div className="media-placeholder">[视频] {item.datatitle}</div>
|
||||
@@ -227,21 +581,20 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
|
||||
return (
|
||||
<div className="history-item">
|
||||
<div className="avatar">
|
||||
{item.sourceheadurl ? (
|
||||
<img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{item.sourcename?.slice(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
<div className="history-avatar">
|
||||
<Avatar
|
||||
src={item.sourceheadurl}
|
||||
name={senderDisplayName}
|
||||
size={36}
|
||||
className="avatar-inner"
|
||||
/>
|
||||
</div>
|
||||
<div className="content-wrapper">
|
||||
<div className="header">
|
||||
<span className="sender">{item.sourcename || '未知发送者'}</span>
|
||||
<span className="sender">{senderDisplayName}</span>
|
||||
<span className="time">{time}</span>
|
||||
</div>
|
||||
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
|
||||
<div className={`bubble ${(item.datatype === 2 || item.datatype === 3) ? 'image-bubble' : ''}`}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -566,7 +566,8 @@
|
||||
flex: 1;
|
||||
background: var(--chat-pattern);
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 20px 24px;
|
||||
padding: 20px 24px 112px;
|
||||
padding-bottom: calc(112px + env(safe-area-inset-bottom));
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -600,7 +601,8 @@
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
@@ -1129,8 +1131,12 @@
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
|
||||
.highlight {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
@@ -1605,6 +1611,7 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
-webkit-app-region: drag;
|
||||
|
||||
.session-avatar {
|
||||
width: 40px;
|
||||
@@ -1638,6 +1645,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.jump-calendar-anchor {
|
||||
position: relative;
|
||||
@@ -1742,7 +1750,8 @@
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
padding: 20px 24px;
|
||||
padding: 20px 24px 112px;
|
||||
padding-bottom: calc(112px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -1771,6 +1780,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.message-virtuoso {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading-messages.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1783,6 +1796,30 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.standalone-phase-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-chat-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1804,9 +1841,9 @@
|
||||
|
||||
// 回到底部按钮
|
||||
.scroll-to-bottom {
|
||||
position: sticky;
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
align-self: center;
|
||||
left: 50%;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg-secondary);
|
||||
@@ -1821,13 +1858,13 @@
|
||||
font-size: 13px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transform: translate(-50%, 20px);
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translate(-50%, 0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -1864,6 +1901,8 @@
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 16px;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&.sent {
|
||||
@@ -2030,6 +2069,10 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.emoji-message-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.emoji-loading {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
@@ -2735,7 +2778,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
@@ -3019,13 +3062,15 @@
|
||||
}
|
||||
|
||||
.member-flag {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 9999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.owner {
|
||||
color: #f59e0b;
|
||||
@@ -3262,13 +3307,89 @@
|
||||
|
||||
// 聊天记录消息 (合并转发)
|
||||
.chat-record-message {
|
||||
background: var(--card-inner-bg) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
transition: opacity 0.2s ease;
|
||||
width: 300px;
|
||||
min-width: 240px;
|
||||
max-width: 336px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 97%, #f5f7fb);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
border-color: color-mix(in srgb, var(--primary) 28%, var(--border-color));
|
||||
}
|
||||
|
||||
.chat-record-title {
|
||||
padding: 13px 16px 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-record-meta-line {
|
||||
padding: 0 16px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-record-list {
|
||||
padding: 0 16px 11px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 92px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.chat-record-item {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
color: currentColor;
|
||||
opacity: 0.92;
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.chat-record-more {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.chat-record-desc {
|
||||
padding: 0 16px 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.chat-record-footer {
|
||||
padding: 8px 16px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3342,75 +3463,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天记录消息 - 复用 link-message 基础样式
|
||||
.chat-record-message {
|
||||
cursor: pointer;
|
||||
|
||||
.link-header {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-record-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-record-meta-line {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 70px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-record-item {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.chat-record-more {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.chat-record-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chat-record-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: var(--primary-gradient);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 小程序消息
|
||||
.miniapp-message {
|
||||
display: flex;
|
||||
@@ -3507,23 +3559,18 @@
|
||||
.message-bubble.sent {
|
||||
|
||||
.card-message,
|
||||
.chat-record-message,
|
||||
.miniapp-message,
|
||||
.appmsg-rich-card {
|
||||
background: var(--sent-card-bg);
|
||||
|
||||
.card-name,
|
||||
.miniapp-title,
|
||||
.source-name,
|
||||
.link-title {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-label,
|
||||
.miniapp-label,
|
||||
.chat-record-item,
|
||||
.chat-record-meta-line,
|
||||
.chat-record-desc,
|
||||
.link-desc,
|
||||
.appmsg-url-line {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
@@ -3531,14 +3578,10 @@
|
||||
|
||||
.card-icon,
|
||||
.miniapp-icon,
|
||||
.chat-record-icon {
|
||||
.link-thumb-placeholder {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chat-record-more {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.appmsg-meta-badge {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
@@ -3619,11 +3662,11 @@
|
||||
// 批量转写按钮
|
||||
.batch-transcribe-btn {
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary-color);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.transcribing {
|
||||
color: var(--primary-color);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
@@ -3647,7 +3690,7 @@
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
svg {
|
||||
color: var(--primary-color);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -3668,6 +3711,36 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.batch-task-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.batch-task-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 50%, var(--border-color));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.batch-dates-list-wrap {
|
||||
margin-bottom: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
@@ -3685,7 +3758,7 @@
|
||||
.batch-dates-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
color: var(--primary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
@@ -3694,7 +3767,7 @@
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary-color);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3727,9 +3800,14 @@
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: var(--primary-color);
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 45%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.batch-date-label {
|
||||
@@ -3772,7 +3850,7 @@
|
||||
.value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.batch-concurrency-field {
|
||||
@@ -3898,7 +3976,7 @@
|
||||
|
||||
&.btn-primary,
|
||||
&.batch-transcribe-start-btn {
|
||||
background: var(--primary-color);
|
||||
background: var(--primary);
|
||||
color: #000;
|
||||
|
||||
&:hover {
|
||||
@@ -4145,43 +4223,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天记录消息外观
|
||||
.chat-record-message {
|
||||
background: var(--card-inner-bg) !important;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
}
|
||||
|
||||
.chat-record-list {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
.chat-record-item {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.source-name {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-record-more {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 公众号文章图文消息外观 (大图模式)
|
||||
.official-message {
|
||||
display: flex;
|
||||
@@ -4416,18 +4457,23 @@
|
||||
|
||||
// 折叠群入口样式
|
||||
.session-item.fold-entry {
|
||||
background: var(--card-inner-bg, rgba(0,0,0,0.03));
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg, rgba(0,0,0,0.05));
|
||||
}
|
||||
|
||||
.fold-entry-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: var(--primary-color, #07c160);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
color: #fa9d3b;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
@@ -4507,7 +4553,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
@@ -4599,3 +4645,260 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 会话内搜索栏
|
||||
// 会话内搜索浮窗
|
||||
.in-session-search-popup {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 16px;
|
||||
width: 360px;
|
||||
max-height: 500px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.in-session-search-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
.search-icon {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
&::placeholder { color: var(--text-tertiary); }
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-result-header {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.in-session-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
transition: background 0.15s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.result-header {
|
||||
flex-shrink: 0;
|
||||
|
||||
.result-info {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
.result-sender {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.result-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-tertiary);
|
||||
gap: 12px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索分类标题
|
||||
.search-section-header {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.search-phase-hint {
|
||||
color: var(--primary);
|
||||
font-weight: 400;
|
||||
|
||||
&.done {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局消息搜索结果面板
|
||||
.global-msg-search-results {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.search-loading,
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-results-list {
|
||||
.session-item {
|
||||
display: flex;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
gap: 12px;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.session-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.session-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.session-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.session-preview {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.highlight {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.search-count {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.msg-search-toggle-btn.active {
|
||||
color: var(--accent-color, #07c160);
|
||||
}
|
||||
.in-session-search-btn.active {
|
||||
color: var(--accent-color, #07c160);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -535,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,20 +1,14 @@
|
||||
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, AlertTriangle, ClipboardList } 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
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
|
||||
interface ContactEnrichInfo {
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
@@ -62,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()
|
||||
|
||||
@@ -509,6 +506,41 @@ function ContactsPage() {
|
||||
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
|
||||
@@ -579,6 +611,38 @@ function ContactsPage() {
|
||||
}, [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 }
|
||||
@@ -827,28 +891,6 @@ function ContactsPage() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="contacts-count">
|
||||
共 {filteredContacts.length} / {contacts.length} 个联系人
|
||||
{contactsUpdatedAt && (
|
||||
<span className="contacts-cache-meta">
|
||||
{contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel}
|
||||
</span>
|
||||
)}
|
||||
{contacts.length > 0 && (
|
||||
<span className="contacts-cache-meta">
|
||||
头像缓存 {avatarCachedCount}/{contacts.length}
|
||||
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{isLoading && contacts.length > 0 && (
|
||||
<span className="contacts-cache-meta syncing">后台同步中...</span>
|
||||
)}
|
||||
{avatarEnrichProgress.running && (
|
||||
<span className="avatar-enrich-progress">
|
||||
头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{exportMode && (
|
||||
<div className="selection-toolbar">
|
||||
@@ -1069,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
|
||||
@@ -1091,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>
|
||||
)
|
||||
}
|
||||
|
||||
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