mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-28 07:25:53 +00:00
Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816770d407 | ||
|
|
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 | ||
|
|
11c7277878 |
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(", ")}`);
|
||||||
102
.github/workflows/release.yml
vendored
102
.github/workflows/release.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
|||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm install
|
||||||
|
|
||||||
- name: Sync version with tag
|
- name: Sync version with tag
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -49,20 +49,41 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npx electron-builder --mac dmg --arm64 --publish always
|
npx electron-builder --mac dmg --arm64 --publish always
|
||||||
|
|
||||||
- name: Update Release Notes
|
release-linux:
|
||||||
env:
|
runs-on: ubuntu-latest
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cat <<EOF > release_notes.md
|
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
|
||||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
run: |
|
||||||
EOF
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
- name: Package and Publish Linux
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
npx electron-builder --linux --publish always
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
@@ -80,7 +101,7 @@ jobs:
|
|||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm install
|
||||||
|
|
||||||
- name: Sync version with tag
|
- name: Sync version with tag
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -100,17 +121,66 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npx electron-builder --publish always
|
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:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
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$")"
|
||||||
|
LINUX_PACMAN_ASSET="$(pick_asset "\\.pacman$")"
|
||||||
|
|
||||||
|
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")"
|
||||||
|
LINUX_PACMAN_URL="$(build_link "$LINUX_PACMAN_ASSET")"
|
||||||
|
|
||||||
|
cat > release_notes.md <<EOF
|
||||||
## 更新日志
|
## 更新日志
|
||||||
修复了一些已知问题
|
修复了一些已知问题
|
||||||
|
|
||||||
## 查看更多日志/获取最新动态
|
## 查看更多日志/获取最新动态
|
||||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
[点击加入 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}
|
||||||
|
- Linux (pacman): ${LINUX_PACMAN_URL:-$RELEASE_PAGE}
|
||||||
|
|
||||||
|
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -62,9 +62,11 @@ server/
|
|||||||
chatlab-format.md
|
chatlab-format.md
|
||||||
*.bak
|
*.bak
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
AGENT.md
|
||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.agents/
|
.agents/
|
||||||
resources/wx_send
|
resources/wx_send
|
||||||
概述.md
|
概述.md
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
/pnpm-workspace.yaml
|
||||||
|
|||||||
4
.npmrc
4
.npmrc
@@ -1,3 +1,3 @@
|
|||||||
registry=https://registry.npmmirror.com
|
registry=https://registry.npmmirror.com
|
||||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
electron-mirror=https://npmmirror.com/mirrors/electron/
|
||||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
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`
|
- 默认监听地址:`127.0.0.1`
|
||||||
|
- 默认端口:`5031`
|
||||||
## 基础地址
|
- 基础地址:`http://127.0.0.1:5031`
|
||||||
|
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||||
```
|
|
||||||
http://127.0.0.1:5031
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 接口列表
|
## 接口列表
|
||||||
|
|
||||||
### 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
|
GET /health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
或
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
**响应**
|
**响应**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok"
|
"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
|
GET /api/v1/messages
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --- | --- | --- | --- |
|
||||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` |
|
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||||
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
|
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||||
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 |
|
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||||
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) |
|
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||||
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) |
|
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||||
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
|
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||||
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
|
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||||
|
|
||||||
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
|
### 示例
|
||||||
|
|
||||||
**示例请求**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 获取消息(原始格式)
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20"
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
|
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"
|
||||||
# 获取消息(ChatLab 格式)
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0"
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
|
|
||||||
|
|
||||||
# 带时间范围查询
|
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
|
||||||
|
|
||||||
# 开启媒体导出(只导出图片和语音)
|
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0
|
|
||||||
|
|
||||||
# 关键词过滤
|
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应(原始格式)**
|
### JSON 响应字段
|
||||||
|
|
||||||
|
顶层字段:
|
||||||
|
|
||||||
|
- `success`
|
||||||
|
- `talker`
|
||||||
|
- `count`
|
||||||
|
- `hasMore`
|
||||||
|
- `media.enabled`
|
||||||
|
- `media.exportPath`
|
||||||
|
- `media.count`
|
||||||
|
- `messages`
|
||||||
|
|
||||||
|
单条消息字段:
|
||||||
|
|
||||||
|
- `localId`
|
||||||
|
- `serverId`
|
||||||
|
- `localType`
|
||||||
|
- `createTime`
|
||||||
|
- `isSend`
|
||||||
|
- `senderUsername`
|
||||||
|
- `content`
|
||||||
|
- `rawContent`
|
||||||
|
- `parsedContent`
|
||||||
|
- `mediaType`
|
||||||
|
- `mediaFileName`
|
||||||
|
- `mediaUrl`
|
||||||
|
- `mediaLocalPath`
|
||||||
|
|
||||||
|
**示例响应**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"talker": "wxid_xxx",
|
"talker": "xxx@chatroom",
|
||||||
"count": 50,
|
"count": 2,
|
||||||
"hasMore": true,
|
"hasMore": true,
|
||||||
"media": {
|
"media": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||||
"count": 12
|
"count": 1
|
||||||
},
|
},
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"localId": 123,
|
"localId": 123,
|
||||||
|
"serverId": "456",
|
||||||
|
"localType": 1,
|
||||||
|
"createTime": 1738713600,
|
||||||
|
"isSend": 0,
|
||||||
|
"senderUsername": "wxid_member",
|
||||||
|
"content": "你好",
|
||||||
|
"rawContent": "你好",
|
||||||
|
"parsedContent": "你好"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"localId": 124,
|
||||||
"localType": 3,
|
"localType": 3,
|
||||||
|
"createTime": 1738713660,
|
||||||
|
"isSend": 0,
|
||||||
|
"senderUsername": "wxid_member",
|
||||||
"content": "[图片]",
|
"content": "[图片]",
|
||||||
"createTime": 1738713600000,
|
|
||||||
"senderUsername": "wxid_sender",
|
|
||||||
"mediaType": "image",
|
"mediaType": "image",
|
||||||
"mediaFileName": "image_123.jpg",
|
"mediaFileName": "abc123.jpg",
|
||||||
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
|
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg",
|
||||||
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应(ChatLab 格式)**
|
### ChatLab 响应
|
||||||
```json
|
|
||||||
{
|
当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构:
|
||||||
"chatlab": {
|
|
||||||
"version": "0.0.2",
|
- `chatlab.version`
|
||||||
"exportedAt": 1738713600000,
|
- `chatlab.exportedAt`
|
||||||
"generator": "WeFlow",
|
- `chatlab.generator`
|
||||||
"description": "Exported from WeFlow"
|
- `meta.name`
|
||||||
},
|
- `meta.platform`
|
||||||
"meta": {
|
- `meta.type`
|
||||||
"name": "会话名称",
|
- `meta.groupId`
|
||||||
"platform": "wechat",
|
- `meta.groupAvatar`
|
||||||
"type": "private",
|
- `meta.ownerId`
|
||||||
"ownerId": "wxid_me"
|
- `members[].platformId`
|
||||||
},
|
- `members[].accountName`
|
||||||
"members": [
|
- `members[].groupNickname`
|
||||||
{
|
- `members[].avatar`
|
||||||
"platformId": "wxid_xxx",
|
- `messages[].sender`
|
||||||
"accountName": "用户名",
|
- `messages[].accountName`
|
||||||
"groupNickname": "群昵称"
|
- `messages[].groupNickname`
|
||||||
}
|
- `messages[].timestamp`
|
||||||
],
|
- `messages[].type`
|
||||||
"messages": [
|
- `messages[].content`
|
||||||
{
|
- `messages[].platformMessageId`
|
||||||
"sender": "wxid_xxx",
|
- `messages[].mediaPath`
|
||||||
"accountName": "用户名",
|
|
||||||
"timestamp": 1738713600000,
|
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. 访问导出媒体文件
|
## 4. 获取会话列表
|
||||||
|
|
||||||
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
|
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
```
|
|
||||||
GET /api/v1/media/{relativePath}
|
|
||||||
```
|
|
||||||
|
|
||||||
**路径参数**
|
```http
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
|--------|------|------|------|
|
|
||||||
| `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. 获取会话列表
|
|
||||||
|
|
||||||
获取所有会话列表。
|
|
||||||
|
|
||||||
**请求**
|
|
||||||
```
|
|
||||||
GET /api/v1/sessions
|
GET /api/v1/sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --- | --- | --- | --- |
|
||||||
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
|
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
| `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
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"count": 50,
|
"count": 1,
|
||||||
"total": 100,
|
|
||||||
"sessions": [
|
"sessions": [
|
||||||
{
|
{
|
||||||
"username": "wxid_xxx",
|
"username": "xxx@chatroom",
|
||||||
"displayName": "用户名",
|
"displayName": "项目群",
|
||||||
"lastMessage": "最后一条消息",
|
"type": 2,
|
||||||
"lastTime": 1738713600000,
|
"lastTimestamp": 1738713600,
|
||||||
"unreadCount": 0
|
"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
|
GET /api/v1/contacts
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --- | --- | --- | --- |
|
||||||
| `keyword` | string | ❌ | 搜索关键词 |
|
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
| `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
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"count": 50,
|
"count": 1,
|
||||||
"contacts": [
|
"contacts": [
|
||||||
{
|
{
|
||||||
"userName": "wxid_xxx",
|
"username": "wxid_xxx",
|
||||||
"alias": "微信号",
|
"displayName": "张三",
|
||||||
"nickName": "昵称",
|
"remark": "客户张三",
|
||||||
"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 | 值 | 说明 |
|
```http
|
||||||
|--------------|-----|------|
|
GET /api/v1/group-members
|
||||||
| TEXT | 0 | 文本消息 |
|
```
|
||||||
| IMAGE | 1 | 图片 |
|
|
||||||
| VOICE | 2 | 语音 |
|
### 参数
|
||||||
| VIDEO | 3 | 视频 |
|
|
||||||
| FILE | 4 | 文件 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
| EMOJI | 5 | 表情 |
|
| --- | --- | --- | --- |
|
||||||
| LINK | 7 | 链接 |
|
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||||
| LOCATION | 8 | 位置 |
|
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||||
| RED_PACKET | 20 | 红包 |
|
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||||
| TRANSFER | 21 | 转账 |
|
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||||
| CALL | 23 | 通话 |
|
|
||||||
| SYSTEM | 80 | 系统消息 |
|
### 响应字段
|
||||||
| RECALL | 81 | 撤回消息 |
|
|
||||||
| OTHER | 99 | 其他 |
|
- `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
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 健康检查
|
|
||||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
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/sessions
|
||||||
|
|
||||||
# 获取消息
|
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
|
||||||
# 获取 ChatLab 格式
|
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### cURL
|
### cURL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 健康检查
|
|
||||||
curl http://127.0.0.1:5031/health
|
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/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
|
### Python
|
||||||
@@ -353,39 +483,26 @@ import requests
|
|||||||
|
|
||||||
BASE_URL = "http://127.0.0.1:5031"
|
BASE_URL = "http://127.0.0.1:5031"
|
||||||
|
|
||||||
# 获取会话列表
|
messages = requests.get(
|
||||||
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
|
f"{BASE_URL}/api/v1/messages",
|
||||||
print(sessions)
|
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)
|
print(messages)
|
||||||
```
|
print(members)
|
||||||
|
|
||||||
### 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);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 注意事项
|
## 9. 注意事项
|
||||||
|
|
||||||
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
|
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||||
2. 需要先连接数据库才能查询数据
|
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||||
3. 时间参数格式为 `YYYYMMDD`(如 20260205)
|
3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。
|
||||||
4. 支持 CORS,可从浏览器前端直接调用
|
4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。
|
||||||
|
5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
304
electron/main.ts
304
electron/main.ts
@@ -1,6 +1,7 @@
|
|||||||
import './preload-env'
|
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 { Worker } from 'worker_threads'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { autoUpdater } from 'electron-updater'
|
import { autoUpdater } from 'electron-updater'
|
||||||
import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises'
|
import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises'
|
||||||
@@ -16,6 +17,7 @@ import { groupAnalyticsService } from './services/groupAnalyticsService'
|
|||||||
import { annualReportService } from './services/annualReportService'
|
import { annualReportService } from './services/annualReportService'
|
||||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
|
import { KeyServiceLinux } from './services/keyServiceLinux'
|
||||||
import { KeyServiceMac } from './services/keyServiceMac'
|
import { KeyServiceMac } from './services/keyServiceMac'
|
||||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||||
import { videoService } from './services/videoService'
|
import { videoService } from './services/videoService'
|
||||||
@@ -27,6 +29,7 @@ import { cloudControlService } from './services/cloudControlService'
|
|||||||
|
|
||||||
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||||
import { httpService } from './services/httpService'
|
import { httpService } from './services/httpService'
|
||||||
|
import { messagePushService } from './services/messagePushService'
|
||||||
|
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
@@ -89,13 +92,30 @@ let onboardingWindow: BrowserWindow | null = null
|
|||||||
let splashWindow: BrowserWindow | null = null
|
let splashWindow: BrowserWindow | null = null
|
||||||
const sessionChatWindows = new Map<string, BrowserWindow>()
|
const sessionChatWindows = new Map<string, BrowserWindow>()
|
||||||
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
||||||
const keyService = process.platform === 'darwin'
|
|
||||||
? new KeyServiceMac() as any
|
let keyService: any
|
||||||
: new KeyService()
|
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 mainWindowReady = false
|
||||||
let shouldShowMain = true
|
let shouldShowMain = true
|
||||||
let isAppQuitting = false
|
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 修复)
|
// 更新下载状态管理(Issue #294 修复)
|
||||||
let isDownloadInProgress = false
|
let isDownloadInProgress = false
|
||||||
@@ -252,15 +272,34 @@ const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
|
|||||||
win.webContents.on('did-finish-load', 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 } = {}) {
|
function createWindow(options: { autoShow?: boolean } = {}) {
|
||||||
// 获取图标路径 - 打包后在 resources 目录
|
// 获取图标路径 - 打包后在 resources 目录
|
||||||
const { autoShow = true } = options
|
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 isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
|
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, `../public/${iconName}`)
|
||||||
: (process.platform === 'darwin'
|
: join(process.resourcesPath, iconName);
|
||||||
? join(process.resourcesPath, 'icon.icns')
|
|
||||||
: join(process.resourcesPath, 'icon.ico'))
|
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
@@ -352,14 +391,33 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
callback(false)
|
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', () => {
|
win.on('closed', () => {
|
||||||
if (mainWindow !== win) return
|
if (mainWindow !== win) return
|
||||||
|
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
mainWindowReady = false
|
mainWindowReady = false
|
||||||
|
isClosePromptVisible = false
|
||||||
|
|
||||||
if (process.platform !== 'darwin' && !isAppQuitting) {
|
if (process.platform !== 'darwin' && !isAppQuitting) {
|
||||||
// 隐藏通知窗也是 BrowserWindow,必须销毁,否则会阻止应用退出。
|
|
||||||
destroyNotificationWindow()
|
destroyNotificationWindow()
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
app.quit()
|
app.quit()
|
||||||
@@ -713,6 +771,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
|
|||||||
* 创建独立的聊天记录窗口
|
* 创建独立的聊天记录窗口
|
||||||
*/
|
*/
|
||||||
function createChatHistoryWindow(sessionId: string, messageId: number) {
|
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 isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, '../public/icon.ico')
|
||||||
@@ -747,7 +813,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (process.env.VITE_DEV_SERVER_URL) {
|
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) => {
|
win.webContents.on('before-input-event', (event, input) => {
|
||||||
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||||
@@ -761,7 +827,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||||
hash: `/chat-history/${sessionId}/${messageId}`
|
hash: route
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,11 +995,14 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('config:set', async (_, key: string, value: any) => {
|
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 () => {
|
ipcMain.handle('config:clear', async () => {
|
||||||
configService?.clear()
|
configService?.clear()
|
||||||
|
messagePushService.handleConfigCleared()
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1147,6 +1216,33 @@ function registerIpcHandlers() {
|
|||||||
BrowserWindow.fromWebContents(event.sender)?.close()
|
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 }) => {
|
ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => {
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
@@ -1174,6 +1270,23 @@ function registerIpcHandlers() {
|
|||||||
return true
|
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, options?: OpenSessionChatWindowOptions) => {
|
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => {
|
||||||
const win = createSessionChatWindow(sessionId, options)
|
const win = createSessionChatWindow(sessionId, options)
|
||||||
@@ -1548,7 +1661,7 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
|
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
|
||||||
return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
|
return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
|
||||||
event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
|
event.sender.send('chat:voiceTranscriptPartial', { sessionId, msgId, createTime, text })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1556,8 +1669,8 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getMessageById(sessionId, localId)
|
return chatService.getMessageById(sessionId, localId)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
|
ipcMain.handle('chat:searchMessages', async (_, keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => {
|
||||||
return chatService.execQuery(kind, path, sql)
|
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) => {
|
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
|
||||||
@@ -1771,7 +1884,83 @@ function registerIpcHandlers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
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) => {
|
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||||
@@ -1882,6 +2071,18 @@ function registerIpcHandlers() {
|
|||||||
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
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) => {
|
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
|
||||||
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
||||||
})
|
})
|
||||||
@@ -2429,6 +2630,10 @@ app.whenReady().then(async () => {
|
|||||||
// 注册 IPC 处理器
|
// 注册 IPC 处理器
|
||||||
updateSplashProgress(25, '正在初始化...')
|
updateSplashProgress(25, '正在初始化...')
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
|
chatService.addDbMonitorListener((type, json) => {
|
||||||
|
messagePushService.handleDbMonitorChange(type, json)
|
||||||
|
})
|
||||||
|
messagePushService.start()
|
||||||
await delay(200)
|
await delay(200)
|
||||||
|
|
||||||
// 检查配置状态
|
// 检查配置状态
|
||||||
@@ -2439,6 +2644,63 @@ app.whenReady().then(async () => {
|
|||||||
updateSplashProgress(30, '正在加载界面...')
|
updateSplashProgress(30, '正在加载界面...')
|
||||||
mainWindow = createWindow({ autoShow: false })
|
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(
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
{
|
{
|
||||||
@@ -2486,12 +2748,20 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
app.on('before-quit', async () => {
|
app.on('before-quit', async () => {
|
||||||
isAppQuitting = true
|
isAppQuitting = true
|
||||||
|
// 销毁 tray 图标
|
||||||
|
if (tray) { try { tray.destroy() } catch {} tray = null }
|
||||||
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
||||||
destroyNotificationWindow()
|
destroyNotificationWindow()
|
||||||
|
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
|
||||||
|
const forceExitTimer = setTimeout(() => {
|
||||||
|
console.warn('[App] Force exit after timeout')
|
||||||
|
app.exit(0)
|
||||||
|
}, 5000)
|
||||||
|
forceExitTimer.unref()
|
||||||
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
||||||
try { await httpService.stop() } catch {}
|
try { await httpService.stop() } catch {}
|
||||||
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
||||||
try { wcdbService.shutdown() } catch {}
|
try { await wcdbService.shutdown() } catch {}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
||||||
},
|
},
|
||||||
close: () => ipcRenderer.send('window:close'),
|
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'),
|
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||||
@@ -106,6 +113,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
||||||
|
openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: any[] }) =>
|
||||||
|
ipcRenderer.invoke('window:openChatHistoryPayloadWindow', payload),
|
||||||
|
getChatHistoryPayload: (payloadId: string) =>
|
||||||
|
ipcRenderer.invoke('window:getChatHistoryPayload', payloadId),
|
||||||
openSessionChatWindow: (
|
openSessionChatWindow: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -208,16 +219,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
|
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
|
||||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
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),
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
|
||||||
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload)
|
||||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||||
return () => ipcRenderer.removeListener('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'),
|
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
||||||
getMessage: (sessionId: string, localId: number) =>
|
getMessage: (sessionId: string, localId: number) =>
|
||||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
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) => {
|
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
||||||
ipcRenderer.on('wcdb-change', callback)
|
ipcRenderer.on('wcdb-change', callback)
|
||||||
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
||||||
@@ -235,12 +246,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
||||||
ipcRenderer.invoke('image:preload', payloads),
|
ipcRenderer.invoke('image:preload', payloads),
|
||||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||||
ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload))
|
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||||
return () => ipcRenderer.removeAllListeners('image:updateAvailable')
|
ipcRenderer.on('image:updateAvailable', listener)
|
||||||
|
return () => ipcRenderer.removeListener('image:updateAvailable', listener)
|
||||||
},
|
},
|
||||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
|
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
|
||||||
ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload))
|
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
|
||||||
return () => ipcRenderer.removeAllListeners('image:cacheResolved')
|
ipcRenderer.on('image:cacheResolved', listener)
|
||||||
|
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -283,6 +296,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
|
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),
|
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||||
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||||
@@ -338,7 +356,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||||
exportContacts: (outputDir: string, options: any) =>
|
exportContacts: (outputDir: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
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))
|
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,29 +68,14 @@ class AnalyticsService {
|
|||||||
return new Set(this.getExcludedUsernamesList())
|
return new Set(this.getExcludedUsernamesList())
|
||||||
}
|
}
|
||||||
|
|
||||||
private escapeSqlValue(value: string): string {
|
|
||||||
return value.replace(/'/g, "''")
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
if (usernames.length === 0) return map
|
if (usernames.length === 0) return map
|
||||||
|
|
||||||
// C++ 层不支持参数绑定,直接内联转义后的字符串值
|
const result = await wcdbService.getContactAliasMap(usernames)
|
||||||
const chunkSize = 200
|
if (!result.success || !result.map) return map
|
||||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
for (const [username, alias] of Object.entries(result.map)) {
|
||||||
const chunk = usernames.slice(i, i + chunkSize)
|
if (username && alias) map[username] = alias
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return map
|
return map
|
||||||
|
|||||||
@@ -278,16 +278,16 @@ class AnnualReportService {
|
|||||||
return cached || null
|
return cached || null
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
|
const result = await wcdbService.getMessageTableColumns(dbPath, tableName)
|
||||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
|
if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) {
|
||||||
this.availableYearsColumnCache.set(cacheKey, '')
|
this.availableYearsColumnCache.set(cacheKey, '')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
|
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
|
||||||
const columns = new Set<string>()
|
const columns = new Set<string>()
|
||||||
for (const row of result.rows as Record<string, any>[]) {
|
for (const columnName of result.columns) {
|
||||||
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
|
const name = String(columnName || '').trim().toLowerCase()
|
||||||
if (name) columns.add(name)
|
if (name) columns.add(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,10 +309,11 @@ class AnnualReportService {
|
|||||||
const tried = new Set<string>()
|
const tried = new Set<string>()
|
||||||
|
|
||||||
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
|
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.getMessageTableTimeRange(dbPath, tableName)
|
||||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
if (!result.success || !result.data) return null
|
||||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
|
const row = result.data as Record<string, any>
|
||||||
const row = result.rows[0] 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 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)
|
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
|
||||||
return { first, last }
|
return { first, last }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ class CloudControlService {
|
|||||||
private deviceId: string = ''
|
private deviceId: string = ''
|
||||||
private timer: NodeJS.Timeout | null = null
|
private timer: NodeJS.Timeout | null = null
|
||||||
private pages: Set<string> = new Set()
|
private pages: Set<string> = new Set()
|
||||||
|
private platformVersionCache: string | null = null
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.deviceId = this.getDeviceId()
|
this.deviceId = this.getDeviceId()
|
||||||
@@ -47,7 +48,12 @@ class CloudControlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getPlatformVersion(): string {
|
private getPlatformVersion(): string {
|
||||||
|
if (this.platformVersionCache) {
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
const os = require('os')
|
const os = require('os')
|
||||||
|
const fs = require('fs')
|
||||||
const platform = process.platform
|
const platform = process.platform
|
||||||
|
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
@@ -59,21 +65,79 @@ class CloudControlService {
|
|||||||
|
|
||||||
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
|
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
|
||||||
if (major === 10 && minor === 0 && build >= 22000) {
|
if (major === 10 && minor === 0 && build >= 22000) {
|
||||||
return 'Windows 11'
|
this.platformVersionCache = 'Windows 11'
|
||||||
|
return this.platformVersionCache
|
||||||
} else if (major === 10) {
|
} else if (major === 10) {
|
||||||
return 'Windows 10'
|
this.platformVersionCache = 'Windows 10'
|
||||||
|
return this.platformVersionCache
|
||||||
}
|
}
|
||||||
return `Windows ${release}`
|
this.platformVersionCache = `Windows ${release}`
|
||||||
|
return this.platformVersionCache
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
if (platform === 'darwin') {
|
||||||
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
|
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
|
||||||
// while cloud reporting expects the macOS product version (e.g. 26.3).
|
// while cloud reporting expects the macOS product version (e.g. 26.3).
|
||||||
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
|
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
|
||||||
return `macOS ${macVersion}`
|
this.platformVersionCache = `macOS ${macVersion}`
|
||||||
|
return this.platformVersionCache
|
||||||
}
|
}
|
||||||
|
|
||||||
return platform
|
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) {
|
recordPage(pageName: string) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface ConfigSchema {
|
|||||||
imageXorKey: number
|
imageXorKey: number
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||||||
|
exportPath?: string;
|
||||||
// 缓存相关
|
// 缓存相关
|
||||||
cachePath: string
|
cachePath: string
|
||||||
lastOpenedDb: string
|
lastOpenedDb: string
|
||||||
@@ -47,9 +47,11 @@ interface ConfigSchema {
|
|||||||
|
|
||||||
// 通知
|
// 通知
|
||||||
notificationEnabled: boolean
|
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'
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
notificationFilterList: string[]
|
notificationFilterList: string[]
|
||||||
|
messagePushEnabled: boolean
|
||||||
|
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||||
wordCloudExcludeWords: string[]
|
wordCloudExcludeWords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,43 +84,71 @@ export class ConfigService {
|
|||||||
return ConfigService.instance
|
return ConfigService.instance
|
||||||
}
|
}
|
||||||
ConfigService.instance = this
|
ConfigService.instance = this
|
||||||
this.store = new Store<ConfigSchema>({
|
const defaults: ConfigSchema = {
|
||||||
|
dbPath: '',
|
||||||
|
decryptKey: '',
|
||||||
|
myWxid: '',
|
||||||
|
onboardingDone: false,
|
||||||
|
imageXorKey: 0,
|
||||||
|
imageAesKey: '',
|
||||||
|
wxidConfigs: {},
|
||||||
|
cachePath: '',
|
||||||
|
lastOpenedDb: '',
|
||||||
|
lastSession: '',
|
||||||
|
theme: 'system',
|
||||||
|
themeId: 'cloud-dancer',
|
||||||
|
language: 'zh-CN',
|
||||||
|
logEnabled: false,
|
||||||
|
llmModelPath: '',
|
||||||
|
whisperModelName: 'base',
|
||||||
|
whisperModelDir: '',
|
||||||
|
whisperDownloadSource: 'tsinghua',
|
||||||
|
autoTranscribeVoice: false,
|
||||||
|
transcribeLanguages: ['zh'],
|
||||||
|
exportDefaultConcurrency: 4,
|
||||||
|
analyticsExcludedUsernames: [],
|
||||||
|
authEnabled: false,
|
||||||
|
authPassword: '',
|
||||||
|
authUseHello: false,
|
||||||
|
authHelloSecret: '',
|
||||||
|
ignoredUpdateVersion: '',
|
||||||
|
notificationEnabled: true,
|
||||||
|
notificationPosition: 'top-right',
|
||||||
|
notificationFilterMode: 'all',
|
||||||
|
notificationFilterList: [],
|
||||||
|
messagePushEnabled: false,
|
||||||
|
windowCloseBehavior: 'ask',
|
||||||
|
wordCloudExcludeWords: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeOptions: any = {
|
||||||
name: 'WeFlow-config',
|
name: 'WeFlow-config',
|
||||||
defaults: {
|
defaults,
|
||||||
dbPath: '',
|
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
|
||||||
decryptKey: '',
|
}
|
||||||
myWxid: '',
|
const runningInWorker = process.env.WEFLOW_WORKER === '1'
|
||||||
onboardingDone: false,
|
if (runningInWorker) {
|
||||||
imageXorKey: 0,
|
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
|
||||||
imageAesKey: '',
|
if (cwd) {
|
||||||
wxidConfigs: {},
|
storeOptions.cwd = cwd
|
||||||
cachePath: '',
|
|
||||||
lastOpenedDb: '',
|
|
||||||
lastSession: '',
|
|
||||||
theme: 'system',
|
|
||||||
themeId: 'cloud-dancer',
|
|
||||||
language: 'zh-CN',
|
|
||||||
logEnabled: false,
|
|
||||||
llmModelPath: '',
|
|
||||||
whisperModelName: 'base',
|
|
||||||
whisperModelDir: '',
|
|
||||||
whisperDownloadSource: 'tsinghua',
|
|
||||||
autoTranscribeVoice: false,
|
|
||||||
transcribeLanguages: ['zh'],
|
|
||||||
exportDefaultConcurrency: 4,
|
|
||||||
analyticsExcludedUsernames: [],
|
|
||||||
authEnabled: false,
|
|
||||||
authPassword: '',
|
|
||||||
authUseHello: false,
|
|
||||||
authHelloSecret: '',
|
|
||||||
ignoredUpdateVersion: '',
|
|
||||||
notificationEnabled: true,
|
|
||||||
notificationPosition: 'top-right',
|
|
||||||
notificationFilterMode: 'all',
|
|
||||||
notificationFilterList: [],
|
|
||||||
wordCloudExcludeWords: []
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
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()
|
this.migrateAuthFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,90 @@
|
|||||||
import { join, basename } from 'path'
|
import { join, basename } from 'path'
|
||||||
import { existsSync, readdirSync, statSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
import { createDecipheriv } from 'crypto'
|
||||||
|
|
||||||
export interface WxidInfo {
|
export interface WxidInfo {
|
||||||
wxid: string
|
wxid: string
|
||||||
modifiedTime: number
|
modifiedTime: number
|
||||||
|
nickname?: string
|
||||||
|
avatarUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DbPathService {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动检测微信数据库根目录
|
* 自动检测微信数据库根目录
|
||||||
*/
|
*/
|
||||||
@@ -135,21 +212,16 @@ export class DbPathService {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(rootPath, entry)
|
const entryPath = join(rootPath, entry)
|
||||||
let stat: ReturnType<typeof statSync>
|
let stat: ReturnType<typeof statSync>
|
||||||
try {
|
try { stat = statSync(entryPath) } catch { continue }
|
||||||
stat = statSync(entryPath)
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stat.isDirectory()) continue
|
if (!stat.isDirectory()) continue
|
||||||
const lower = entry.toLowerCase()
|
const lower = entry.toLowerCase()
|
||||||
if (lower === 'all_users') continue
|
if (lower === 'all_users') continue
|
||||||
if (!entry.includes('_')) continue
|
if (!entry.includes('_')) continue
|
||||||
|
|
||||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (wxids.length === 0) {
|
if (wxids.length === 0) {
|
||||||
const rootName = basename(rootPath)
|
const rootName = basename(rootPath)
|
||||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||||
@@ -159,12 +231,25 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
return wxids.sort((a, b) => {
|
const sorted = wxids.sort((a, b) => {
|
||||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
})
|
});
|
||||||
|
|
||||||
|
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 列表
|
* 扫描 wxid 列表
|
||||||
*/
|
*/
|
||||||
@@ -187,10 +272,21 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
return wxids.sort((a, b) => {
|
const sorted = wxids.sort((a, b) => {
|
||||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
})
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,12 @@ export interface GroupMediaStats {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberMessagesPage {
|
||||||
|
messages: Message[]
|
||||||
|
hasMore: boolean
|
||||||
|
nextCursor: number
|
||||||
|
}
|
||||||
|
|
||||||
interface GroupMemberContactInfo {
|
interface GroupMemberContactInfo {
|
||||||
remark: string
|
remark: string
|
||||||
nickName: string
|
nickName: string
|
||||||
@@ -224,10 +230,9 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
||||||
const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`)
|
if (roomExt.success && roomExt.extBuffer) {
|
||||||
if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) {
|
const owner = tryResolve({ ext_buffer: roomExt.extBuffer })
|
||||||
const owner = tryResolve(roomResult.rows[0])
|
|
||||||
if (owner) return owner
|
if (owner) return owner
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -255,20 +260,46 @@ class GroupAnalyticsService {
|
|||||||
* 从 DLL 获取群成员的群昵称
|
* 从 DLL 获取群成员的群昵称
|
||||||
*/
|
*/
|
||||||
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||||
|
const nicknameMap = new Map<string, string>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
|
if (dllResult.success && dllResult.nicknames) {
|
||||||
const result = await wcdbService.execQuery('contact', null, sql)
|
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
|
||||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
}
|
||||||
return new Map<string, string>()
|
} catch (e) {
|
||||||
|
console.error('getGroupNicknamesForRoom dll error:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
||||||
|
if (!result.success || !result.extBuffer) {
|
||||||
|
return nicknameMap
|
||||||
}
|
}
|
||||||
|
|
||||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
const extBuffer = this.decodeExtBuffer(result.extBuffer)
|
||||||
if (!extBuffer) return new Map<string, string>()
|
if (!extBuffer) return nicknameMap
|
||||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
|
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
|
||||||
|
return nicknameMap
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGroupNicknamesForRoom error:', 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)
|
const batch = candidates.slice(i, i + batchSize)
|
||||||
if (batch.length === 0) continue
|
if (batch.length === 0) continue
|
||||||
|
|
||||||
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
const result = await wcdbService.getContactsCompact(batch)
|
||||||
const lightweightSql = `
|
if (!result.success || !result.contacts) continue
|
||||||
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type
|
appendContactsToLookup(result.contacts as Record<string, unknown>[])
|
||||||
FROM contact
|
|
||||||
WHERE username IN (${inList})
|
|
||||||
`
|
|
||||||
let result = await wcdbService.execQuery('contact', null, lightweightSql)
|
|
||||||
if (!result.success || !result.rows) {
|
|
||||||
// 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失
|
|
||||||
result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`)
|
|
||||||
}
|
|
||||||
if (!result.success || !result.rows) continue
|
|
||||||
appendContactsToLookup(result.rows as Record<string, unknown>[])
|
|
||||||
}
|
}
|
||||||
return lookup
|
return lookup
|
||||||
}
|
}
|
||||||
@@ -741,36 +762,246 @@ class GroupAnalyticsService {
|
|||||||
return ''
|
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(
|
private async collectMessagesByMember(
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number
|
endTime: number
|
||||||
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
|
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
|
||||||
const batchSize = 500
|
const batchSize = 800
|
||||||
const matchedMessages: Message[] = []
|
const matchedMessages: Message[] = []
|
||||||
let offset = 0
|
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
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
const cursorResult = await this.openMemberMessageCursor(chatroomId, batchSize, true, startTime, endTime)
|
||||||
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true)
|
if (!cursorResult.success || !cursorResult.cursor) {
|
||||||
if (!batch.success || !batch.messages) {
|
return { success: false, error: cursorResult.error || '创建群消息游标失败' }
|
||||||
return { success: false, error: batch.error || '获取群消息失败' }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const message of batch.messages) {
|
const cursor = cursorResult.cursor
|
||||||
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) {
|
try {
|
||||||
matchedMessages.push(message)
|
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
|
||||||
|
|
||||||
const fetchedCount = batch.messages.length
|
for (const row of rows) {
|
||||||
if (fetchedCount <= 0 || !batch.hasMore) break
|
const senderFromRow = this.extractRowSenderUsername(row)
|
||||||
offset += fetchedCount
|
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const message = this.parseSingleMessageRow(row)
|
||||||
|
if (!message) continue
|
||||||
|
if (matchesTargetSender(message.senderUsername)) {
|
||||||
|
matchedMessages.push(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!batch.hasMore) break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await wcdbService.closeMessageCursor(cursor)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: matchedMessages }
|
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 }> {
|
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { wcdbService } from './wcdbService'
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { videoService } from './videoService'
|
import { videoService } from './videoService'
|
||||||
import { imageDecryptService } from './imageDecryptService'
|
import { imageDecryptService } from './imageDecryptService'
|
||||||
|
import { groupAnalyticsService } from './groupAnalyticsService'
|
||||||
|
|
||||||
// ChatLab 格式定义
|
// ChatLab 格式定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -102,6 +103,8 @@ class HttpService {
|
|||||||
private port: number = 5031
|
private port: number = 5031
|
||||||
private running: boolean = false
|
private running: boolean = false
|
||||||
private connections: Set<import('net').Socket> = new Set()
|
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
|
private connectionMutex: boolean = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -152,6 +155,7 @@ class HttpService {
|
|||||||
|
|
||||||
this.server.listen(this.port, '127.0.0.1', () => {
|
this.server.listen(this.port, '127.0.0.1', () => {
|
||||||
this.running = true
|
this.running = true
|
||||||
|
this.startMessagePushHeartbeat()
|
||||||
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
||||||
resolve({ success: true, port: this.port })
|
resolve({ success: true, port: this.port })
|
||||||
})
|
})
|
||||||
@@ -164,6 +168,16 @@ class HttpService {
|
|||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (this.server) {
|
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
|
this.connectionMutex = true
|
||||||
const socketsToClose = Array.from(this.connections)
|
const socketsToClose = Array.from(this.connections)
|
||||||
@@ -210,6 +224,28 @@ class HttpService {
|
|||||||
return this.getApiMediaExportPath()
|
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 请求
|
* 处理 HTTP 请求
|
||||||
*/
|
*/
|
||||||
@@ -232,12 +268,16 @@ class HttpService {
|
|||||||
// 路由处理
|
// 路由处理
|
||||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||||
this.sendJson(res, { status: 'ok' })
|
this.sendJson(res, { status: 'ok' })
|
||||||
|
} else if (pathname === '/api/v1/push/messages') {
|
||||||
|
this.handleMessagePushStream(req, res)
|
||||||
} else if (pathname === '/api/v1/messages') {
|
} else if (pathname === '/api/v1/messages') {
|
||||||
await this.handleMessages(url, res)
|
await this.handleMessages(url, res)
|
||||||
} else if (pathname === '/api/v1/sessions') {
|
} else if (pathname === '/api/v1/sessions') {
|
||||||
await this.handleSessions(url, res)
|
await this.handleSessions(url, res)
|
||||||
} else if (pathname === '/api/v1/contacts') {
|
} else if (pathname === '/api/v1/contacts') {
|
||||||
await this.handleContacts(url, res)
|
await this.handleContacts(url, res)
|
||||||
|
} else if (pathname === '/api/v1/group-members') {
|
||||||
|
await this.handleGroupMembers(url, res)
|
||||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||||
this.handleMediaRequest(pathname, res)
|
this.handleMediaRequest(pathname, res)
|
||||||
} else {
|
} 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 {
|
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
||||||
const mediaBasePath = this.getApiMediaExportPath()
|
const mediaBasePath = this.getApiMediaExportPath()
|
||||||
const relativePath = pathname.replace('/api/v1/media/', '')
|
const relativePath = pathname.replace('/api/v1/media/', '')
|
||||||
@@ -589,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 {
|
private getApiMediaExportPath(): string {
|
||||||
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||||
}
|
}
|
||||||
@@ -798,6 +930,20 @@ class HttpService {
|
|||||||
return 0
|
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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取显示名称
|
* 获取显示名称
|
||||||
*/
|
*/
|
||||||
@@ -814,9 +960,70 @@ class HttpService {
|
|||||||
return {}
|
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 {
|
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
|
||||||
if (!sender) return ''
|
if (!sender) return ''
|
||||||
return groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || ''
|
const cleaned = this.normalizeAccountId(sender)
|
||||||
|
return groupNicknamesMap.get(sender)
|
||||||
|
|| groupNicknamesMap.get(sender.toLowerCase())
|
||||||
|
|| groupNicknamesMap.get(cleaned)
|
||||||
|
|| groupNicknamesMap.get(cleaned.toLowerCase())
|
||||||
|
|| ''
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveChatLabSenderInfo(
|
private resolveChatLabSenderInfo(
|
||||||
@@ -868,6 +1075,7 @@ class HttpService {
|
|||||||
): Promise<ChatLabData> {
|
): Promise<ChatLabData> {
|
||||||
const isGroup = talkerId.endsWith('@chatroom')
|
const isGroup = talkerId.endsWith('@chatroom')
|
||||||
const myWxid = this.configService.get('myWxid') || ''
|
const myWxid = this.configService.get('myWxid') || ''
|
||||||
|
const normalizedMyWxid = this.normalizeAccountId(myWxid).toLowerCase()
|
||||||
|
|
||||||
// 收集所有发送者
|
// 收集所有发送者
|
||||||
const senderSet = new Set<string>()
|
const senderSet = new Set<string>()
|
||||||
@@ -886,7 +1094,21 @@ class HttpService {
|
|||||||
try {
|
try {
|
||||||
const result = await wcdbService.getGroupNicknames(talkerId)
|
const result = await wcdbService.getGroupNicknames(talkerId)
|
||||||
if (result.success && result.nicknames) {
|
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) {
|
} catch (e) {
|
||||||
console.error('[HttpService] Failed to get group nicknames:', e)
|
console.error('[HttpService] Failed to get group nicknames:', e)
|
||||||
@@ -906,6 +1128,27 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 chatLabMessages: ChatLabMessage[] = messages.map(msg => {
|
||||||
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
|
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
|
||||||
@@ -933,6 +1176,7 @@ class HttpService {
|
|||||||
platform: 'wechat',
|
platform: 'wechat',
|
||||||
type: isGroup ? 'group' : 'private',
|
type: isGroup ? 'group' : 'private',
|
||||||
groupId: isGroup ? talkerId : undefined,
|
groupId: isGroup ? talkerId : undefined,
|
||||||
|
groupAvatar: isGroup ? sessionAvatarInfo?.avatarUrl : undefined,
|
||||||
ownerId: myWxid || undefined
|
ownerId: myWxid || undefined
|
||||||
},
|
},
|
||||||
members: Array.from(memberMap.values()),
|
members: Array.from(memberMap.values()),
|
||||||
|
|||||||
@@ -55,14 +55,19 @@ type DecryptResult = {
|
|||||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||||
}
|
}
|
||||||
|
|
||||||
type HardlinkState = {
|
type CachedImagePayload = {
|
||||||
imageTable?: string
|
sessionId?: string
|
||||||
dirTable?: string
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
preferFilePath?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecryptImagePayload = CachedImagePayload & {
|
||||||
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImageDecryptService {
|
export class ImageDecryptService {
|
||||||
private configService = new ConfigService()
|
private configService = new ConfigService()
|
||||||
private hardlinkCache = new Map<string, HardlinkState>()
|
|
||||||
private resolvedCache = new Map<string, string>()
|
private resolvedCache = new Map<string, string>()
|
||||||
private pending = new Map<string, Promise<DecryptResult>>()
|
private pending = new Map<string, Promise<DecryptResult>>()
|
||||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
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()
|
await this.ensureCacheIndexed()
|
||||||
const cacheKeys = this.getCacheKeys(payload)
|
const cacheKeys = this.getCacheKeys(payload)
|
||||||
const cacheKey = cacheKeys[0]
|
const cacheKey = cacheKeys[0]
|
||||||
@@ -116,7 +121,7 @@ export class ImageDecryptService {
|
|||||||
for (const key of cacheKeys) {
|
for (const key of cacheKeys) {
|
||||||
const cached = this.resolvedCache.get(key)
|
const cached = this.resolvedCache.get(key)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||||
const isThumb = this.isThumbnailPath(cached)
|
const isThumb = this.isThumbnailPath(cached)
|
||||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||||
if (isThumb) {
|
if (isThumb) {
|
||||||
@@ -124,8 +129,8 @@ export class ImageDecryptService {
|
|||||||
} else {
|
} else {
|
||||||
this.updateFlags.delete(key)
|
this.updateFlags.delete(key)
|
||||||
}
|
}
|
||||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
|
this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||||
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
|
return { success: true, localPath, hasUpdate }
|
||||||
}
|
}
|
||||||
if (cached && !this.isImageFile(cached)) {
|
if (cached && !this.isImageFile(cached)) {
|
||||||
this.resolvedCache.delete(key)
|
this.resolvedCache.delete(key)
|
||||||
@@ -136,7 +141,7 @@ export class ImageDecryptService {
|
|||||||
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, 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 isThumb = this.isThumbnailPath(existing)
|
||||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||||
if (isThumb) {
|
if (isThumb) {
|
||||||
@@ -144,27 +149,53 @@ export class ImageDecryptService {
|
|||||||
} else {
|
} else {
|
||||||
this.updateFlags.delete(key)
|
this.updateFlags.delete(key)
|
||||||
}
|
}
|
||||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
|
this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
|
return { success: true, localPath, hasUpdate }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||||
return { success: false, error: '未找到缓存图片' }
|
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()
|
await this.ensureCacheIndexed()
|
||||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
const cacheKeys = this.getCacheKeys(payload)
|
||||||
|
const cacheKey = cacheKeys[0]
|
||||||
if (!cacheKey) {
|
if (!cacheKey) {
|
||||||
return { success: false, error: '缺少图片标识' }
|
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) {
|
if (!payload.force) {
|
||||||
const cached = this.resolvedCache.get(cacheKey)
|
const cached = this.resolvedCache.get(cacheKey)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||||
const localPath = dataUrl || this.filePathToUrl(cached)
|
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
|
||||||
return { success: true, localPath }
|
return { success: true, localPath }
|
||||||
}
|
}
|
||||||
if (cached && !this.isImageFile(cached)) {
|
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(
|
private async decryptImageInternal(
|
||||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
|
payload: DecryptImagePayload,
|
||||||
cacheKey: string
|
cacheKey: string
|
||||||
): Promise<DecryptResult> {
|
): Promise<DecryptResult> {
|
||||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
|
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
|
||||||
@@ -225,10 +292,9 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
|
||||||
const dataUrl = this.fileToDataUrl(datPath)
|
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
|
||||||
const localPath = dataUrl || this.filePathToUrl(datPath)
|
|
||||||
const isThumb = this.isThumbnailPath(datPath)
|
const isThumb = this.isThumbnailPath(datPath)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
|
||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,10 +306,9 @@ export class ImageDecryptService {
|
|||||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||||
if (!(payload.force && !isHd)) {
|
if (!(payload.force && !isHd)) {
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
||||||
const dataUrl = this.fileToDataUrl(existing)
|
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||||
const localPath = dataUrl || this.filePathToUrl(existing)
|
|
||||||
const isThumb = this.isThumbnailPath(existing)
|
const isThumb = this.isThumbnailPath(existing)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,9 +368,11 @@ export class ImageDecryptService {
|
|||||||
if (!isThumb) {
|
if (!isThumb) {
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||||
}
|
}
|
||||||
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
const localPath = payload.preferFilePath
|
||||||
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
? outputPath
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
: (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath))
|
||||||
|
const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath)
|
||||||
|
this.emitCacheResolved(payload, cacheKey, emitPath)
|
||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||||
@@ -436,6 +503,10 @@ export class ImageDecryptService {
|
|||||||
if (imageMd5) {
|
if (imageMd5) {
|
||||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
||||||
if (res) return res
|
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,也尝试快速定位
|
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||||
@@ -650,45 +721,19 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
|
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const hardlinkPath = this.resolveHardlinkDbPath(accountDir)
|
|
||||||
if (!hardlinkPath) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const ready = await this.ensureWcdbReady()
|
const ready = await this.ensureWcdbReady()
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
this.logInfo('[ImageDecrypt] hardlink db not ready')
|
this.logInfo('[ImageDecrypt] hardlink db not ready')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = await this.getHardlinkState(accountDir, hardlinkPath)
|
const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir)
|
||||||
if (!state.imageTable) {
|
if (!resolveResult.success || !resolveResult.data) return null
|
||||||
this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath })
|
const fileName = String(resolveResult.data.file_name || '').trim()
|
||||||
return null
|
const fullPath = String(resolveResult.data.full_path || '').trim()
|
||||||
}
|
if (!fileName) return null
|
||||||
|
|
||||||
const escapedMd5 = this.escapeSqlString(md5)
|
const lowerFileName = String(fileName).toLowerCase()
|
||||||
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()
|
|
||||||
if (lowerFileName.endsWith('.dat')) {
|
if (lowerFileName.endsWith('.dat')) {
|
||||||
const baseLower = lowerFileName.slice(0, -4)
|
const baseLower = lowerFileName.slice(0, -4)
|
||||||
if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) {
|
if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) {
|
||||||
@@ -697,57 +742,11 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名
|
if (fullPath && existsSync(fullPath)) {
|
||||||
let dir1Name: string | null = null
|
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
|
||||||
let dir2Name: string | null = null
|
return fullPath
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 })
|
||||||
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)) {
|
|
||||||
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
|
|
||||||
return fullPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths })
|
|
||||||
return null
|
return null
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -755,35 +754,6 @@ export class ImageDecryptService {
|
|||||||
return null
|
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> {
|
private async ensureWcdbReady(): Promise<boolean> {
|
||||||
if (wcdbService.isReady()) return true
|
if (wcdbService.isReady()) return true
|
||||||
const dbPath = this.configService.get('dbPath')
|
const dbPath = this.configService.get('dbPath')
|
||||||
@@ -889,7 +859,8 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const months: string[] = []
|
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 d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
months.push(mStr)
|
months.push(mStr)
|
||||||
@@ -1567,6 +1538,16 @@ export class ImageDecryptService {
|
|||||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
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 {
|
private fileToDataUrl(filePath: string): string | null {
|
||||||
try {
|
try {
|
||||||
const ext = extname(filePath).toLowerCase()
|
const ext = extname(filePath).toLowerCase()
|
||||||
@@ -1958,7 +1939,6 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||||
this.resolvedCache.clear()
|
this.resolvedCache.clear()
|
||||||
this.hardlinkCache.clear()
|
|
||||||
this.pending.clear()
|
this.pending.clear()
|
||||||
this.updateFlags.clear()
|
this.updateFlags.clear()
|
||||||
this.cacheIndexed = false
|
this.cacheIndexed = false
|
||||||
|
|||||||
@@ -606,34 +606,14 @@ export class KeyService {
|
|||||||
|
|
||||||
const logs: string[] = []
|
const logs: string[] = []
|
||||||
|
|
||||||
onStatus?.('正在定位微信安装路径...', 0)
|
onStatus?.('正在查找微信进程...', 0)
|
||||||
let wechatPath = await this.findWeChatInstallPath()
|
const pid = await this.findWeChatPid()
|
||||||
if (!wechatPath) {
|
if (!pid) {
|
||||||
const err = '未找到微信安装路径,请确认已安装PC微信'
|
const err = '未找到微信进程,请先启动微信'
|
||||||
onStatus?.(err, 2)
|
onStatus?.(err, 2)
|
||||||
return { success: false, error: err }
|
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?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
|
||||||
onStatus?.('正在检测微信界面组件...', 0)
|
onStatus?.('正在检测微信界面组件...', 0)
|
||||||
await this.waitForWeChatWindowComponents(pid, 15000)
|
await this.waitForWeChatWindowComponents(pid, 15000)
|
||||||
|
|||||||
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,11 +116,30 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async checkSipStatus(): Promise<{ enabled: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
|
||||||
|
const enabled = stdout.toLowerCase().includes('enabled')
|
||||||
|
return { enabled }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { enabled: false, error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async autoGetDbKey(
|
async autoGetDbKey(
|
||||||
timeoutMs = 60_000,
|
timeoutMs = 60_000,
|
||||||
onStatus?: (message: string, level: number) => void
|
onStatus?: (message: string, level: number) => void
|
||||||
): Promise<DbKeyResult> {
|
): Promise<DbKeyResult> {
|
||||||
try {
|
try {
|
||||||
|
// 检测 SIP 状态
|
||||||
|
const sipStatus = await this.checkSipStatus()
|
||||||
|
if (sipStatus.enabled) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片(M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onStatus?.('正在获取数据库密钥...', 0)
|
onStatus?.('正在获取数据库密钥...', 0)
|
||||||
onStatus?.('正在请求管理员授权并执行 helper...', 0)
|
onStatus?.('正在请求管理员授权并执行 helper...', 0)
|
||||||
let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string }
|
let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string }
|
||||||
@@ -363,7 +382,7 @@ export class KeyServiceMac {
|
|||||||
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
||||||
const scriptLines = [
|
const scriptLines = [
|
||||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
`set cmd to quoted form of helperPath & " ${pid} ${waitMs} 2>&1"`,
|
||||||
'do shell script cmd with administrator privileges'
|
'do shell script cmd with administrator privileges'
|
||||||
]
|
]
|
||||||
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
|
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
|
||||||
@@ -380,18 +399,27 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean)
|
const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean)
|
||||||
const last = lines[lines.length - 1]
|
if (!lines.length) throw new Error('elevated helper returned empty output')
|
||||||
if (!last) throw new Error('elevated helper returned empty output')
|
|
||||||
|
|
||||||
let payload: any
|
// 从所有行里提取所有 JSON 对象(同一行可能有多个拼接),找含 key/result 的那个
|
||||||
try {
|
const extractJsonObjects = (s: string): any[] => {
|
||||||
payload = JSON.parse(last)
|
const results: any[] = []
|
||||||
} catch {
|
const re = /\{[^{}]*\}/g
|
||||||
throw new Error('elevated helper returned invalid json: ' + last)
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = re.exec(s)) !== null) {
|
||||||
|
try { results.push(JSON.parse(m[0])) } catch { }
|
||||||
|
}
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
if (payload?.success === true && typeof payload?.key === 'string') return payload.key
|
const fullOutput = lines.join('\n')
|
||||||
if (typeof payload?.result === 'string') return payload.result
|
const allJson = extractJsonObjects(fullOutput)
|
||||||
throw new Error('elevated helper json missing key/result')
|
// 优先找 success=true && key 字段
|
||||||
|
const successPayload = allJson.find(p => p?.success === true && typeof p?.key === 'string')
|
||||||
|
if (successPayload) return successPayload.key
|
||||||
|
// 其次找 result 字段
|
||||||
|
const resultPayload = allJson.find(p => typeof p?.result === 'string')
|
||||||
|
if (resultPayload) return resultPayload.result
|
||||||
|
throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1])
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
|
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
|
||||||
@@ -479,26 +507,39 @@ export class KeyServiceMac {
|
|||||||
|
|
||||||
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
|
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
|
||||||
if (wxidCandidates.length === 0) {
|
if (wxidCandidates.length === 0) {
|
||||||
return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' }
|
return { success: false, error: '未找到可用的账号候选,请先选择正确的账号目录' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountPathCandidates = this.collectAccountPathCandidates(accountPath)
|
||||||
|
|
||||||
// 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错
|
// 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错
|
||||||
let verifyCiphertext: Buffer | null = null
|
if (accountPathCandidates.length > 0) {
|
||||||
if (accountPath && existsSync(accountPath)) {
|
|
||||||
const template = await this._findTemplateData(accountPath, 32)
|
|
||||||
verifyCiphertext = template.ciphertext
|
|
||||||
}
|
|
||||||
if (verifyCiphertext) {
|
|
||||||
onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`)
|
onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`)
|
||||||
for (const candidateWxid of wxidCandidates) {
|
for (const candidateAccountPath of accountPathCandidates) {
|
||||||
for (const code of codes) {
|
if (!existsSync(candidateAccountPath)) continue
|
||||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
const template = await this._findTemplateData(candidateAccountPath, 32)
|
||||||
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
if (!template.ciphertext) continue
|
||||||
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
|
||||||
return { success: true, xorKey, aesKey }
|
const accountDirWxid = basename(candidateAccountPath)
|
||||||
|
const orderedWxids: string[] = []
|
||||||
|
this.pushAccountIdCandidates(orderedWxids, accountDirWxid)
|
||||||
|
for (const candidate of wxidCandidates) {
|
||||||
|
this.pushAccountIdCandidates(orderedWxids, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidateWxid of orderedWxids) {
|
||||||
|
for (const code of codes) {
|
||||||
|
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||||
|
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
|
||||||
|
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||||
|
return { success: true, xorKey, aesKey }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '缓存 code 与当前账号 wxid 未匹配。若数据库密钥获取后微信刚刚崩溃并重启,可能当前选中的账号目录已经不是最新会话;请先重新扫描 wxid,或直接使用内存扫描。'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code)
|
// 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code)
|
||||||
@@ -533,16 +574,21 @@ export class KeyServiceMac {
|
|||||||
|
|
||||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||||
|
|
||||||
// 2. 找微信 PID
|
// 2. 持续轮询微信 PID 与内存扫描,兼容微信崩溃后重启 PID 变化
|
||||||
const pid = await this.findWeChatPid()
|
|
||||||
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
|
|
||||||
|
|
||||||
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
|
|
||||||
|
|
||||||
// 3. 持续轮询内存扫描
|
|
||||||
const deadline = Date.now() + 60_000
|
const deadline = Date.now() + 60_000
|
||||||
let scanCount = 0
|
let scanCount = 0
|
||||||
|
let lastPid: number | null = null
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
|
const pid = await this.findWeChatPid()
|
||||||
|
if (!pid) {
|
||||||
|
onProgress?.('暂未检测到微信主进程,请确认微信已经重新打开...')
|
||||||
|
await new Promise(r => setTimeout(r, 2000))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (lastPid !== pid) {
|
||||||
|
lastPid = pid
|
||||||
|
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
|
||||||
|
}
|
||||||
scanCount++
|
scanCount++
|
||||||
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
|
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
|
||||||
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
||||||
@@ -755,7 +801,7 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = chunk.subarray(0, bytesRead)
|
const current = chunk.subarray(0, bytesRead)
|
||||||
const data = trailing ? Buffer.concat([trailing, current]) : current
|
const data: Buffer = trailing ? Buffer.concat([trailing, current]) : current
|
||||||
const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext)
|
const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext)
|
||||||
if (key) return key
|
if (key) return key
|
||||||
// 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中)
|
// 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中)
|
||||||
@@ -784,8 +830,8 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]'
|
const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]'
|
||||||
let stdout = '', stderr = ''
|
let stdout = '', stderr = ''
|
||||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
||||||
child.stderr.on('data', (chunk: Buffer) => {
|
child.stderr?.on('data', (chunk: Buffer) => {
|
||||||
stderr += chunk.toString()
|
stderr += chunk.toString()
|
||||||
console.log(tag, chunk.toString().trim())
|
console.log(tag, chunk.toString().trim())
|
||||||
})
|
})
|
||||||
@@ -810,11 +856,8 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async findWeChatPid(): Promise<number | null> {
|
private async findWeChatPid(): Promise<number | null> {
|
||||||
const { execSync } = await import('child_process')
|
|
||||||
try {
|
try {
|
||||||
const output = execSync('pgrep -x WeChat', { encoding: 'utf8' })
|
return await this.getWeChatPid()
|
||||||
const pid = parseInt(output.trim())
|
|
||||||
return isNaN(pid) ? null : pid
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -831,12 +874,70 @@ export class KeyServiceMac {
|
|||||||
this.machPortDeallocate = null
|
this.machPortDeallocate = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeAccountId(value: string): string {
|
||||||
|
const trimmed = String(value || '').trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
|
return match?.[1] || trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
|
return suffixMatch ? suffixMatch[1] : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private isIgnoredAccountName(value: string): boolean {
|
||||||
|
const lowered = String(value || '').trim().toLowerCase()
|
||||||
|
if (!lowered) return true
|
||||||
|
return lowered === 'xwechat_files' ||
|
||||||
|
lowered === 'all_users' ||
|
||||||
|
lowered === 'backup' ||
|
||||||
|
lowered === 'wmpf' ||
|
||||||
|
lowered === 'app_data'
|
||||||
|
}
|
||||||
|
|
||||||
|
private isReasonableAccountId(value: string): boolean {
|
||||||
|
const trimmed = String(value || '').trim()
|
||||||
|
if (!trimmed) return false
|
||||||
|
if (trimmed.includes('/') || trimmed.includes('\\')) return false
|
||||||
|
return !this.isIgnoredAccountName(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAccountDirPath(entryPath: string): boolean {
|
||||||
|
return existsSync(join(entryPath, 'db_storage')) ||
|
||||||
|
existsSync(join(entryPath, 'msg')) ||
|
||||||
|
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
|
||||||
|
existsSync(join(entryPath, 'FileStorage', 'Image2'))
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveXwechatRootFromPath(accountPath?: string): string | null {
|
||||||
|
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
|
if (!normalized) return null
|
||||||
|
const marker = '/xwechat_files'
|
||||||
|
const markerIdx = normalized.indexOf(marker)
|
||||||
|
if (markerIdx < 0) return null
|
||||||
|
return normalized.slice(0, markerIdx + marker.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushAccountIdCandidates(candidates: string[], value?: string): void {
|
||||||
|
const pushUnique = (item: string) => {
|
||||||
|
const trimmed = String(item || '').trim()
|
||||||
|
if (!trimmed || candidates.includes(trimmed)) return
|
||||||
|
candidates.push(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!this.isReasonableAccountId(raw)) return
|
||||||
|
pushUnique(raw)
|
||||||
|
const normalized = this.normalizeAccountId(raw)
|
||||||
|
if (normalized && normalized !== raw && this.isReasonableAccountId(normalized)) {
|
||||||
|
pushUnique(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private cleanWxid(wxid: string): string {
|
private cleanWxid(wxid: string): string {
|
||||||
const first = wxid.indexOf('_')
|
return this.normalizeAccountId(wxid)
|
||||||
if (first === -1) return wxid
|
|
||||||
const second = wxid.indexOf('_', first + 1)
|
|
||||||
if (second === -1) return wxid
|
|
||||||
return wxid.substring(0, second)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||||
@@ -849,32 +950,59 @@ export class KeyServiceMac {
|
|||||||
|
|
||||||
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
|
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
const pushUnique = (value: string) => {
|
|
||||||
const v = String(value || '').trim()
|
|
||||||
if (!v || candidates.includes(v)) return
|
|
||||||
candidates.push(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) 显式传参优先
|
// 1) 显式传参优先
|
||||||
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
|
this.pushAccountIdCandidates(candidates, wxidParam)
|
||||||
|
|
||||||
if (accountPath) {
|
if (accountPath) {
|
||||||
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
|
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
const dirName = basename(normalized)
|
const dirName = basename(normalized)
|
||||||
// 2) 当前目录名为 wxid_*
|
// 2) 当前目录名本身就是账号目录
|
||||||
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
this.pushAccountIdCandidates(candidates, dirName)
|
||||||
|
|
||||||
// 3) 从 xwechat_files 根目录枚举全部 wxid_* 目录
|
// 3) 从 xwechat_files 根目录枚举全部账号目录
|
||||||
const marker = '/xwechat_files'
|
const root = this.resolveXwechatRootFromPath(accountPath)
|
||||||
const markerIdx = normalized.indexOf(marker)
|
if (root) {
|
||||||
if (markerIdx >= 0) {
|
|
||||||
const root = normalized.slice(0, markerIdx + marker.length)
|
|
||||||
if (existsSync(root)) {
|
if (existsSync(root)) {
|
||||||
try {
|
try {
|
||||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory()) continue
|
if (!entry.isDirectory()) continue
|
||||||
if (!entry.name.startsWith('wxid_')) continue
|
const entryPath = join(root, entry.name)
|
||||||
pushUnique(entry.name)
|
if (!this.isAccountDirPath(entryPath)) continue
|
||||||
|
this.pushAccountIdCandidates(candidates, entry.name)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) candidates.push('unknown')
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectAccountPathCandidates(accountPath?: string): string[] {
|
||||||
|
const candidates: string[] = []
|
||||||
|
const pushUnique = (value?: string) => {
|
||||||
|
const v = String(value || '').trim()
|
||||||
|
if (!v || candidates.includes(v)) return
|
||||||
|
candidates.push(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountPath) pushUnique(accountPath)
|
||||||
|
|
||||||
|
if (accountPath) {
|
||||||
|
const root = this.resolveXwechatRootFromPath(accountPath)
|
||||||
|
if (root) {
|
||||||
|
if (existsSync(root)) {
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
const entryPath = join(root, entry.name)
|
||||||
|
if (!this.isAccountDirPath(entryPath)) continue
|
||||||
|
if (!this.isReasonableAccountId(entry.name)) continue
|
||||||
|
pushUnique(entryPath)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -883,7 +1011,6 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pushUnique('unknown')
|
|
||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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()
|
||||||
@@ -663,100 +663,24 @@ class SnsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||||
const collect = (rows?: any[]): string[] => {
|
const result = await wcdbService.getSnsUsernames()
|
||||||
if (!Array.isArray(rows)) return []
|
if (!result.success) {
|
||||||
const usernames: string[] = []
|
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
||||||
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)
|
|
||||||
}
|
|
||||||
return usernames
|
|
||||||
}
|
}
|
||||||
|
return { success: true, usernames: result.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 || '获取朋友圈联系人失败' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
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)
|
const normalizedMyWxid = this.toOptionalString(myWxid)
|
||||||
if (normalizedMyWxid) {
|
const result = await wcdbService.getSnsExportStats(normalizedMyWxid || undefined)
|
||||||
const myPostPrimary = await wcdbService.execQuery(
|
if (!result.success || !result.data) {
|
||||||
'sns',
|
return { totalPosts: 0, totalFriends: 0, myPosts: normalizedMyWxid ? 0 : null }
|
||||||
null,
|
}
|
||||||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?",
|
return {
|
||||||
[normalizedMyWxid]
|
totalPosts: Number(result.data.totalPosts || 0),
|
||||||
)
|
totalFriends: Number(result.data.totalFriends || 0),
|
||||||
if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) {
|
myPosts: result.data.myPosts === null || result.data.myPosts === undefined ? null : Number(result.data.myPosts || 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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { totalPosts, totalFriends, myPosts }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getExportStats(options?: {
|
async getExportStats(options?: {
|
||||||
|
|||||||
@@ -5,316 +5,553 @@ import { ConfigService } from './config'
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
export interface VideoInfo {
|
export interface VideoInfo {
|
||||||
videoUrl?: string // 视频文件路径(用于 readFile)
|
videoUrl?: string // 视频文件路径(用于 readFile)
|
||||||
coverUrl?: string // 封面 data URL
|
coverUrl?: string // 封面 data URL
|
||||||
thumbUrl?: string // 缩略图 data URL
|
thumbUrl?: string // 缩略图 data URL
|
||||||
exists: boolean
|
exists: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimedCacheEntry<T> {
|
||||||
|
value: T
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoIndexEntry {
|
||||||
|
videoPath?: string
|
||||||
|
coverPath?: string
|
||||||
|
thumbPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
class VideoService {
|
class VideoService {
|
||||||
private configService: ConfigService
|
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() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(message: string, meta?: Record<string, unknown>): void {
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
|
const logDir = join(app.getPath('userData'), 'logs')
|
||||||
|
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
||||||
|
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private log(message: string, meta?: Record<string, unknown>): void {
|
while (cache.size > maxEntries) {
|
||||||
try {
|
const oldestKey = cache.keys().next().value as string | undefined
|
||||||
const timestamp = new Date().toISOString()
|
if (!oldestKey) break
|
||||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
cache.delete(oldestKey)
|
||||||
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 {}
|
/**
|
||||||
|
* 获取数据库根目录
|
||||||
|
*/
|
||||||
|
private getDbPath(): string {
|
||||||
|
return this.configService.get('dbPath') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户的wxid
|
||||||
|
*/
|
||||||
|
private getMyWxid(): string {
|
||||||
|
return this.configService.get('myWxid') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 wxid 目录名(去掉后缀)
|
||||||
|
*/
|
||||||
|
private cleanWxid(wxid: string): string {
|
||||||
|
const trimmed = wxid.trim()
|
||||||
|
if (!trimmed) return trimmed
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
|
if (match) return match[1]
|
||||||
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
* 获取数据库根目录
|
if (suffixMatch) return suffixMatch[1]
|
||||||
*/
|
|
||||||
private getDbPath(): string {
|
return trimmed
|
||||||
return this.configService.get('dbPath') || ''
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if (dbPathContainsWxid) {
|
||||||
|
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return [
|
||||||
* 获取当前用户的wxid
|
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||||
*/
|
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
||||||
private getMyWxid(): string {
|
]
|
||||||
return this.configService.get('myWxid') || ''
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 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
|
||||||
* 获取缓存目录(解密后的数据库存放位置)
|
|
||||||
*/
|
|
||||||
private getCachePath(): string {
|
|
||||||
return this.configService.getCacheBasePath()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
|
||||||
* 清理 wxid 目录名(去掉后缀)
|
for (const p of encryptedDbPaths) {
|
||||||
*/
|
if (!existsSync(p) || unresolvedSet.size === 0) continue
|
||||||
private cleanWxid(wxid: string): string {
|
const unresolved = Array.from(unresolvedSet)
|
||||||
const trimmed = wxid.trim()
|
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
|
||||||
if (!trimmed) return trimmed
|
try {
|
||||||
|
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
|
||||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
if (batchResult.success && Array.isArray(batchResult.rows)) {
|
||||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
for (const row of batchResult.rows) {
|
||||||
if (match) return match[1]
|
const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1
|
||||||
return trimmed
|
const inputMd5 = index >= 0 && index < requests.length
|
||||||
}
|
? requests[index].md5
|
||||||
|
: String(row?.md5 || '').trim().toLowerCase()
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
if (!inputMd5) continue
|
||||||
if (suffixMatch) return suffixMatch[1]
|
const resolvedMd5 = row?.success && row?.data?.resolved_md5
|
||||||
|
? String(row.data.resolved_md5).trim().toLowerCase()
|
||||||
return trimmed
|
: ''
|
||||||
}
|
if (!resolvedMd5) continue
|
||||||
|
const cacheKey = `${scopeKey}|${inputMd5}`
|
||||||
/**
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
resolvedMap.set(inputMd5, resolvedMd5)
|
||||||
* 使用 wcdbService.execQuery 查询加密的 hardlink.db
|
unresolvedSet.delete(inputMd5)
|
||||||
*/
|
}
|
||||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
|
||||||
const dbPath = this.getDbPath()
|
|
||||||
const wxid = this.getMyWxid()
|
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
|
||||||
|
|
||||||
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath })
|
|
||||||
|
|
||||||
if (!wxid) {
|
|
||||||
this.log('queryVideoFileName: wxid 为空')
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 wcdbService.execQuery 查询加密的 hardlink.db
|
|
||||||
if (dbPath) {
|
|
||||||
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'))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const p of encryptedDbPaths) {
|
|
||||||
if (existsSync(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) })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.log('加密 hardlink.db 不存在', { path: p })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将文件转换为 data URL
|
|
||||||
*/
|
|
||||||
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
|
|
||||||
try {
|
|
||||||
if (!existsSync(filePath)) return undefined
|
|
||||||
const buffer = readFileSync(filePath)
|
|
||||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据视频MD5获取视频文件信息
|
|
||||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
|
||||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
|
||||||
*/
|
|
||||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
|
||||||
const dbPath = this.getDbPath()
|
|
||||||
const wxid = this.getMyWxid()
|
|
||||||
|
|
||||||
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
|
|
||||||
|
|
||||||
if (!dbPath || !wxid || !videoMd5) {
|
|
||||||
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
|
|
||||||
return { exists: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先尝试从数据库查询真正的视频文件名
|
|
||||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
|
||||||
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
|
|
||||||
|
|
||||||
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
|
||||||
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 {
|
} else {
|
||||||
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
// 兼容不支持批量接口的版本,回退单条请求。
|
||||||
|
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('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
|
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
|
||||||
|
}
|
||||||
if (!existsSync(videoBaseDir)) {
|
|
||||||
this.log('getVideoInfo: videoBaseDir 不存在')
|
|
||||||
return { exists: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 遍历年月目录查找视频文件
|
|
||||||
try {
|
|
||||||
const allDirs = readdirSync(videoBaseDir)
|
|
||||||
const yearMonthDirs = allDirs
|
|
||||||
.filter(dir => {
|
|
||||||
const dirPath = join(videoBaseDir, dir)
|
|
||||||
return statSync(dirPath).isDirectory()
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.localeCompare(a))
|
|
||||||
|
|
||||||
this.log('扫描目录', { dirs: yearMonthDirs })
|
|
||||||
|
|
||||||
for (const yearMonth of yearMonthDirs) {
|
|
||||||
const dirPath = join(videoBaseDir, yearMonth)
|
|
||||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
|
||||||
|
|
||||||
if (existsSync(videoPath)) {
|
|
||||||
// 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw,但封面不带)
|
|
||||||
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
|
|
||||||
const coverPath = join(dirPath, `${baseMd5}.jpg`)
|
|
||||||
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
|
||||||
|
|
||||||
// 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名
|
|
||||||
const allFiles = readdirSync(dirPath)
|
|
||||||
const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase()))
|
|
||||||
this.log('找到视频,相关文件列表', {
|
|
||||||
videoPath,
|
|
||||||
coverExists: existsSync(coverPath),
|
|
||||||
thumbExists: existsSync(thumbPath),
|
|
||||||
relatedFiles,
|
|
||||||
coverPath,
|
|
||||||
thumbPath
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
videoUrl: videoPath,
|
|
||||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
|
||||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
|
||||||
exists: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个)
|
|
||||||
this.log('未找到视频,开始全目录扫描', {
|
|
||||||
lookingForOriginal: `${videoMd5}.mp4`,
|
|
||||||
lookingForResolved: `${realVideoMd5}.mp4`,
|
|
||||||
hardlinkResolved: realVideoMd5 !== videoMd5
|
|
||||||
})
|
|
||||||
for (const yearMonth of yearMonthDirs) {
|
|
||||||
const dirPath = join(videoBaseDir, yearMonth)
|
|
||||||
try {
|
|
||||||
const allFiles = readdirSync(dirPath)
|
|
||||||
const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10)
|
|
||||||
// 检查原始 md5 是否部分匹配(前8位)
|
|
||||||
const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase()))
|
|
||||||
this.log(`目录 ${yearMonth} 扫描结果`, {
|
|
||||||
totalFiles: allFiles.length,
|
|
||||||
mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length,
|
|
||||||
sampleMp4: mp4Files,
|
|
||||||
partialMatchByOriginalMd5: partialMatch
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.log('getVideoInfo 遍历出错', { error: String(e) })
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
|
|
||||||
return { exists: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
for (const md5 of unresolvedSet) {
|
||||||
* 根据消息内容解析视频MD5
|
const cacheKey = `${scopeKey}|${md5}`
|
||||||
*/
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
parseVideoMd5(content: string): string | undefined {
|
}
|
||||||
if (!content) return undefined
|
|
||||||
|
|
||||||
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
|
return resolvedMap
|
||||||
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
|
}
|
||||||
|
|
||||||
|
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 | undefined, mimeType: string): string | undefined {
|
||||||
|
try {
|
||||||
|
if (!filePath || !existsSync(filePath)) return undefined
|
||||||
|
const buffer = readFileSync(filePath)
|
||||||
|
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrBuildVideoIndex(videoBaseDir: string): Map<string, VideoIndexEntry> {
|
||||||
|
const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
let files: string[] = []
|
||||||
try {
|
try {
|
||||||
// 收集所有 md5 相关属性,方便对比
|
files = readdirSync(dirPath)
|
||||||
const allMd5Attrs: string[] = []
|
} catch {
|
||||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
|
continue
|
||||||
let match
|
|
||||||
while ((match = md5Regex.exec(content)) !== null) {
|
|
||||||
allMd5Attrs.push(match[0])
|
|
||||||
}
|
|
||||||
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
|
|
||||||
|
|
||||||
// 方法1:从 <videomsg md5="..."> 提取(收到的视频)
|
|
||||||
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (videoMsgMd5Match) {
|
|
||||||
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
|
|
||||||
return videoMsgMd5Match[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
|
||||||
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (rawMd5Match) {
|
|
||||||
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
|
||||||
return rawMd5Match[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
|
|
||||||
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (attrMatch) {
|
|
||||||
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
|
||||||
return attrMatch[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法4:<md5>...</md5> 标签
|
|
||||||
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
|
||||||
if (md5TagMatch) {
|
|
||||||
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
|
|
||||||
return md5TagMatch[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法5:兜底取 rawmd5 属性(任意位置)
|
|
||||||
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (rawMd5Fallback) {
|
|
||||||
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
|
|
||||||
return rawMd5Fallback[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
|
|
||||||
} catch (e) {
|
|
||||||
this.log('parseVideoMd5 异常', { error: String(e) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
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)) 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`)
|
||||||
|
return {
|
||||||
|
videoUrl: videoPath,
|
||||||
|
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||||
|
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.log('fallback 扫描视频目录失败', { error: String(e) })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据视频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: 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
|
||||||
|
*/
|
||||||
|
parseVideoMd5(content: string): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
|
||||||
|
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
|
||||||
|
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 收集所有 md5 相关属性,方便对比
|
||||||
|
const allMd5Attrs: string[] = []
|
||||||
|
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
|
||||||
|
let match
|
||||||
|
while ((match = md5Regex.exec(content)) !== null) {
|
||||||
|
allMd5Attrs.push(match[0])
|
||||||
|
}
|
||||||
|
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
|
||||||
|
|
||||||
|
// 方法1:从 <videomsg md5="..."> 提取(收到的视频)
|
||||||
|
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (videoMsgMd5Match) {
|
||||||
|
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
|
||||||
|
return videoMsgMd5Match[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
||||||
|
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (rawMd5Match) {
|
||||||
|
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
||||||
|
return rawMd5Match[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
|
||||||
|
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (attrMatch) {
|
||||||
|
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
||||||
|
return attrMatch[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法4:<md5>...</md5> 标签
|
||||||
|
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||||
|
if (md5TagMatch) {
|
||||||
|
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
|
||||||
|
return md5TagMatch[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法5:兜底取 rawmd5 属性(任意位置)
|
||||||
|
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (rawMd5Fallback) {
|
||||||
|
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
|
||||||
|
return rawMd5Fallback[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
|
||||||
|
} catch (e) {
|
||||||
|
this.log('parseVideoMd5 异常', { error: String(e) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const videoService = new VideoService()
|
export const videoService = new VideoService()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -174,10 +174,10 @@ export class WcdbService {
|
|||||||
/**
|
/**
|
||||||
* 关闭服务
|
* 关闭服务
|
||||||
*/
|
*/
|
||||||
shutdown(): void {
|
async shutdown(): Promise<void> {
|
||||||
this.close()
|
try { await this.close() } catch {}
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
this.worker.terminate()
|
try { await this.worker.terminate() } catch {}
|
||||||
this.worker = null
|
this.worker = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +222,48 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageCounts', { sessionIds })
|
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 })
|
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 })
|
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 }> {
|
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 })
|
return this.callWorker('execQuery', { kind, path, sql, params })
|
||||||
@@ -406,6 +476,10 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageById', { sessionId, localId })
|
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 })
|
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 })
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 安装朋友圈删除拦截
|
* 安装朋友圈删除拦截
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ if (parentPort) {
|
|||||||
case 'getMessageCounts':
|
case 'getMessageCounts':
|
||||||
result = await core.getMessageCounts(payload.sessionIds)
|
result = await core.getMessageCounts(payload.sessionIds)
|
||||||
break
|
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':
|
case 'getDisplayNames':
|
||||||
result = await core.getDisplayNames(payload.usernames)
|
result = await core.getDisplayNames(payload.usernames)
|
||||||
break
|
break
|
||||||
@@ -89,12 +107,33 @@ if (parentPort) {
|
|||||||
case 'getMessageMeta':
|
case 'getMessageMeta':
|
||||||
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
||||||
break
|
break
|
||||||
|
case 'getMessageTableColumns':
|
||||||
|
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
|
||||||
|
break
|
||||||
|
case 'getMessageTableTimeRange':
|
||||||
|
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
|
||||||
|
break
|
||||||
case 'getContact':
|
case 'getContact':
|
||||||
result = await core.getContact(payload.username)
|
result = await core.getContact(payload.username)
|
||||||
break
|
break
|
||||||
case 'getContactStatus':
|
case 'getContactStatus':
|
||||||
result = await core.getContactStatus(payload.usernames)
|
result = await core.getContactStatus(payload.usernames)
|
||||||
break
|
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':
|
case 'getAggregateStats':
|
||||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
@@ -140,18 +179,48 @@ if (parentPort) {
|
|||||||
case 'getMessageById':
|
case 'getMessageById':
|
||||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||||
break
|
break
|
||||||
|
case 'searchMessages':
|
||||||
|
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
case 'getVoiceData':
|
case 'getVoiceData':
|
||||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||||
}
|
}
|
||||||
break
|
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':
|
case 'getSnsTimeline':
|
||||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||||
break
|
break
|
||||||
case 'getSnsAnnualStats':
|
case 'getSnsAnnualStats':
|
||||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
|
case 'getSnsUsernames':
|
||||||
|
result = await core.getSnsUsernames()
|
||||||
|
break
|
||||||
|
case 'getSnsExportStats':
|
||||||
|
result = await core.getSnsExportStats(payload.myWxid)
|
||||||
|
break
|
||||||
case 'installSnsBlockDeleteTrigger':
|
case 'installSnsBlockDeleteTrigger':
|
||||||
result = await core.installSnsBlockDeleteTrigger()
|
result = await core.installSnsBlockDeleteTrigger()
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
|||||||
|
|
||||||
// 更新位置
|
// 更新位置
|
||||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||||
const winWidth = 344
|
const winWidth = position === 'top-center' ? 280 : 344
|
||||||
const winHeight = 114
|
const winHeight = 114
|
||||||
const padding = 20
|
const padding = 20
|
||||||
|
|
||||||
@@ -140,6 +140,10 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
|||||||
let y = 0
|
let y = 0
|
||||||
|
|
||||||
switch (position) {
|
switch (position) {
|
||||||
|
case 'top-center':
|
||||||
|
x = (screenWidth - winWidth) / 2
|
||||||
|
y = padding
|
||||||
|
break
|
||||||
case 'top-right':
|
case 'top-right':
|
||||||
x = screenWidth - winWidth - padding
|
x = screenWidth - winWidth - padding
|
||||||
y = padding
|
y = padding
|
||||||
@@ -166,7 +170,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
|||||||
win.showInactive() // 显示但不聚焦
|
win.showInactive() // 显示但不聚焦
|
||||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||||
|
|
||||||
win.webContents.send('notification:show', data)
|
win.webContents.send('notification:show', { ...data, position })
|
||||||
|
|
||||||
// 自动关闭计时器通常由渲染进程管理
|
// 自动关闭计时器通常由渲染进程管理
|
||||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -29,6 +29,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sherpa-onnx-node": "^1.10.38",
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
|
"sudo-prompt": "^9.2.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
@@ -9998,6 +9999,13 @@
|
|||||||
"inline-style-parser": "0.2.7"
|
"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": {
|
"node_modules/sumchecker": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||||
|
|||||||
22
package.json
22
package.json
@@ -3,7 +3,10 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": {
|
||||||
|
"name": "cc",
|
||||||
|
"email": "yccccccy@proton.me"
|
||||||
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/hicccc77/WeFlow"
|
"url": "https://github.com/hicccc77/WeFlow"
|
||||||
@@ -40,6 +43,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sherpa-onnx-node": "^1.10.38",
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
|
"sudo-prompt": "^9.2.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
@@ -88,6 +92,16 @@
|
|||||||
],
|
],
|
||||||
"icon": "public/icon.ico"
|
"icon": "public/icon.ico"
|
||||||
},
|
},
|
||||||
|
"linux": {
|
||||||
|
"icon": "public/icon.png",
|
||||||
|
"target": [
|
||||||
|
"deb",
|
||||||
|
"tar.gz"
|
||||||
|
],
|
||||||
|
"category": "Utility",
|
||||||
|
"executableName": "weflow",
|
||||||
|
"synopsis": "WeFlow for Linux"
|
||||||
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"differentialPackage": false,
|
"differentialPackage": false,
|
||||||
@@ -118,6 +132,10 @@
|
|||||||
"from": "public/icon.ico",
|
"from": "public/icon.ico",
|
||||||
"to": "icon.ico"
|
"to": "icon.ico"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "public/icon.png",
|
||||||
|
"to": "icon.png"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "electron/assets/wasm/",
|
"from": "electron/assets/wasm/",
|
||||||
"to": "assets/wasm/"
|
"to": "assets/wasm/"
|
||||||
@@ -154,4 +172,4 @@
|
|||||||
],
|
],
|
||||||
"icon": "resources/icon.icns"
|
"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/libwcdb_api.dylib
Executable file
BIN
resources/libwcdb_api.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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/xkey_helper_linux
Executable file
BIN
resources/xkey_helper_linux
Executable file
Binary file not shown.
42
src/App.tsx
42
src/App.tsx
@@ -37,6 +37,7 @@ import LockScreen from './components/LockScreen'
|
|||||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||||
|
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||||
|
|
||||||
function RouteStateRedirect({ to }: { to: string }) {
|
function RouteStateRedirect({ to }: { to: string }) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -74,7 +75,7 @@ function App() {
|
|||||||
const isAgreementWindow = location.pathname === '/agreement-window'
|
const isAgreementWindow = location.pathname === '/agreement-window'
|
||||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||||
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
const 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 isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||||
const isNotificationWindow = location.pathname === '/notification-window'
|
const isNotificationWindow = location.pathname === '/notification-window'
|
||||||
const isSettingsRoute = location.pathname === '/settings'
|
const isSettingsRoute = location.pathname === '/settings'
|
||||||
@@ -85,6 +86,8 @@ function App() {
|
|||||||
const isExportRoute = routeLocation.pathname === '/export'
|
const isExportRoute = routeLocation.pathname === '/export'
|
||||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = 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
|
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||||
@@ -107,6 +110,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [location])
|
}, [location])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => {
|
||||||
|
setCanMinimizeToTray(Boolean(payload.canMinimizeToTray))
|
||||||
|
setShowCloseDialog(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => removeCloseConfirmListener()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
const body = document.body
|
const body = document.body
|
||||||
@@ -315,6 +327,26 @@ function App() {
|
|||||||
setUpdateInfo(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (isAgreementWindow || isOnboardingWindow) return
|
if (isAgreementWindow || isOnboardingWindow) return
|
||||||
@@ -593,6 +625,13 @@ function App() {
|
|||||||
progress={downloadProgress}
|
progress={downloadProgress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WindowCloseDialog
|
||||||
|
open={showCloseDialog}
|
||||||
|
canMinimizeToTray={canMinimizeToTray}
|
||||||
|
onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
|
||||||
|
onCancel={() => handleWindowCloseAction('cancel')}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="main-layout">
|
<div className="main-layout">
|
||||||
<Sidebar collapsed={sidebarCollapsed} />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
<main className="content">
|
<main className="content">
|
||||||
@@ -621,6 +660,7 @@ function App() {
|
|||||||
<Route path="/sns" element={<SnsPage />} />
|
<Route path="/sns" element={<SnsPage />} />
|
||||||
<Route path="/contacts" element={<ContactsPage />} />
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||||
|
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</RouteGuard>
|
</RouteGuard>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -50,6 +50,21 @@
|
|||||||
border-radius: inherit;
|
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 */
|
/* Loading Skeleton */
|
||||||
.avatar-skeleton {
|
.avatar-skeleton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -76,4 +91,14 @@
|
|||||||
background-position: -200% 0;
|
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 React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { User } from 'lucide-react'
|
import { Loader2, User } from 'lucide-react'
|
||||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||||
import './Avatar.scss'
|
import './Avatar.scss'
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ interface AvatarProps {
|
|||||||
shape?: 'circle' | 'square' | 'rounded'
|
shape?: 'circle' | 'square' | 'rounded'
|
||||||
className?: string
|
className?: string
|
||||||
lazy?: boolean
|
lazy?: boolean
|
||||||
|
loading?: boolean
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,12 +24,14 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
shape = 'rounded',
|
shape = 'rounded',
|
||||||
className = '',
|
className = '',
|
||||||
lazy = true,
|
lazy = true,
|
||||||
|
loading = false,
|
||||||
onClick
|
onClick
|
||||||
}: AvatarProps) {
|
}: AvatarProps) {
|
||||||
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
||||||
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
||||||
|
const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src])
|
||||||
const [imageLoaded, setImageLoaded] = useState(isCached)
|
const [imageLoaded, setImageLoaded] = useState(isCached)
|
||||||
const [imageError, setImageError] = useState(false)
|
const [imageError, setImageError] = useState(isFailed)
|
||||||
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
||||||
const [isInQueue, setIsInQueue] = useState(false)
|
const [isInQueue, setIsInQueue] = useState(false)
|
||||||
const imgRef = useRef<HTMLImageElement>(null)
|
const imgRef = useRef<HTMLImageElement>(null)
|
||||||
@@ -42,7 +45,7 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
|
|
||||||
// Intersection Observer for lazy loading
|
// Intersection Observer for lazy loading
|
||||||
useEffect(() => {
|
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(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
@@ -50,10 +53,11 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
if (entry.isIntersecting && !isInQueue) {
|
if (entry.isIntersecting && !isInQueue) {
|
||||||
setIsInQueue(true)
|
setIsInQueue(true)
|
||||||
avatarLoadQueue.enqueue(src).then(() => {
|
avatarLoadQueue.enqueue(src).then(() => {
|
||||||
|
setImageError(false)
|
||||||
setShouldLoad(true)
|
setShouldLoad(true)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// 加载失败不要立刻显示错误,让浏览器渲染去报错
|
setImageError(true)
|
||||||
setShouldLoad(true)
|
setShouldLoad(false)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
setIsInQueue(false)
|
setIsInQueue(false)
|
||||||
})
|
})
|
||||||
@@ -67,14 +71,18 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
observer.observe(containerRef.current)
|
observer.observe(containerRef.current)
|
||||||
|
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [src, lazy, shouldLoad, isInQueue, isCached])
|
}, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed])
|
||||||
|
|
||||||
// Reset state when src changes
|
// Reset state when src changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cached = src ? loadedAvatarCache.has(src) : false
|
const cached = src ? loadedAvatarCache.has(src) : false
|
||||||
|
const failed = src ? avatarLoadQueue.hasFailed(src) : false
|
||||||
setImageLoaded(cached)
|
setImageLoaded(cached)
|
||||||
setImageError(false)
|
setImageError(failed)
|
||||||
if (lazy && !cached) {
|
if (failed) {
|
||||||
|
setShouldLoad(false)
|
||||||
|
setIsInQueue(false)
|
||||||
|
} else if (lazy && !cached) {
|
||||||
setShouldLoad(false)
|
setShouldLoad(false)
|
||||||
setIsInQueue(false)
|
setIsInQueue(false)
|
||||||
} else {
|
} else {
|
||||||
@@ -95,6 +103,7 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasValidUrl = !!src && !imageError && shouldLoad
|
const hasValidUrl = !!src && !imageError && shouldLoad
|
||||||
|
const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -112,13 +121,30 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
alt={name || 'avatar'}
|
alt={name || 'avatar'}
|
||||||
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
if (src) loadedAvatarCache.add(src)
|
if (src) {
|
||||||
|
avatarLoadQueue.clearFailed(src)
|
||||||
|
loadedAvatarCache.add(src)
|
||||||
|
}
|
||||||
setImageLoaded(true)
|
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"}
|
loading={lazy ? "lazy" : "eager"}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : shouldShowLoadingPlaceholder ? (
|
||||||
|
<div className="avatar-loading">
|
||||||
|
<Loader2 size="50%" className="avatar-loading-icon" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="avatar-placeholder">
|
<div className="avatar-placeholder">
|
||||||
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
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 { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||||
import '../styles/batchTranscribe.scss'
|
import '../styles/batchTranscribe.scss'
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
result,
|
result,
|
||||||
sessionName,
|
sessionName,
|
||||||
startTime,
|
startTime,
|
||||||
|
taskType,
|
||||||
setShowToast,
|
setShowToast,
|
||||||
setShowResult
|
setShowResult
|
||||||
} = useBatchTranscribeStore()
|
} = useBatchTranscribeStore()
|
||||||
@@ -64,7 +65,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
<div className="batch-progress-toast-header">
|
<div className="batch-progress-toast-header">
|
||||||
<div className="batch-progress-toast-title">
|
<div className="batch-progress-toast-title">
|
||||||
<Loader2 size={14} className="spin" />
|
<Loader2 size={14} className="spin" />
|
||||||
<span>批量转写中{sessionName ? `(${sessionName})` : ''}</span>
|
<span>{taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `(${sessionName})` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
@@ -108,8 +109,8 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
|
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
|
||||||
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="batch-modal-header">
|
<div className="batch-modal-header">
|
||||||
<CheckCircle size={20} />
|
{taskType === 'decrypt' ? <Mic size={20} /> : <CheckCircle size={20} />}
|
||||||
<h3>转写完成</h3>
|
<h3>{taskType === 'decrypt' ? '语音解密完成' : '转写完成'}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-modal-body">
|
<div className="batch-modal-body">
|
||||||
<div className="result-summary">
|
<div className="result-summary">
|
||||||
@@ -129,7 +130,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
{result.fail > 0 && (
|
{result.fail > 0 && (
|
||||||
<div className="result-tip">
|
<div className="result-tip">
|
||||||
<AlertCircle size={16} />
|
<AlertCircle size={16} />
|
||||||
<span>部分语音转写失败,可能是语音文件损坏或网络问题</span>
|
<span>{taskType === 'decrypt' ? '部分语音解密失败,可能是语音未缓存或文件损坏' : '部分语音转写失败,可能是语音文件损坏或网络问题'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import type { ChatSession } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
export function GlobalSessionMonitor() {
|
export function GlobalSessionMonitor() {
|
||||||
@@ -20,9 +20,9 @@ export function GlobalSessionMonitor() {
|
|||||||
}, [sessions])
|
}, [sessions])
|
||||||
|
|
||||||
// 去重辅助函数:获取消息 key
|
// 去重辅助函数:获取消息 key
|
||||||
const getMessageKey = (msg: any) => {
|
const getMessageKey = (msg: Message) => {
|
||||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
if (msg.messageKey) return msg.messageKey
|
||||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理数据库变更
|
// 处理数据库变更
|
||||||
@@ -267,7 +267,12 @@ export function GlobalSessionMonitor() {
|
|||||||
try {
|
try {
|
||||||
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
||||||
if (result.success && result.messages && result.messages.length > 0) {
|
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) {
|
} catch (e) {
|
||||||
console.warn('后台活跃会话刷新失败:', e)
|
console.warn('后台活跃会话刷新失败:', e)
|
||||||
|
|||||||
@@ -137,18 +137,22 @@
|
|||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #16a34a;
|
color: var(--primary, #07c160);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jump-date-popover .day-cell.selected .day-count {
|
.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 {
|
.jump-date-popover .day-count-loading {
|
||||||
position: static;
|
position: static;
|
||||||
margin-top: 1px;
|
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 {
|
.jump-date-popover .spin {
|
||||||
|
|||||||
@@ -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 {
|
&:hover {
|
||||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface NotificationToastProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onClick: (sessionId: string) => void
|
onClick: (sessionId: string) => void
|
||||||
duration?: number
|
duration?: number
|
||||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||||
isStatic?: boolean
|
isStatic?: boolean
|
||||||
initialVisible?: boolean
|
initialVisible?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useChatStore } from '../stores/chatStore'
|
|||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||||
|
import { UserRound } from 'lucide-react'
|
||||||
|
|
||||||
import './Sidebar.scss'
|
import './Sidebar.scss'
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ interface AccountProfilesCache {
|
|||||||
interface WxidOption {
|
interface WxidOption {
|
||||||
wxid: string
|
wxid: string
|
||||||
modifiedTime: number
|
modifiedTime: number
|
||||||
|
nickname?: string
|
||||||
displayName?: string
|
displayName?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
}
|
}
|
||||||
@@ -280,26 +282,28 @@ function Sidebar({ collapsed }: SidebarProps) {
|
|||||||
const accountsCache = readAccountProfilesCache()
|
const accountsCache = readAccountProfilesCache()
|
||||||
console.log('[切换账号] 账号缓存:', accountsCache)
|
console.log('[切换账号] 账号缓存:', accountsCache)
|
||||||
|
|
||||||
const enrichedWxids = wxids.map(option => {
|
const enrichedWxids = wxids.map((option: WxidOption) => {
|
||||||
const normalizedWxid = normalizeAccountId(option.wxid)
|
const normalizedWxid = normalizeAccountId(option.wxid)
|
||||||
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
|
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) {
|
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
|
||||||
return {
|
displayName = userProfile.displayName || displayName
|
||||||
...option,
|
avatarUrl = userProfile.avatarUrl || avatarUrl
|
||||||
displayName: userProfile.displayName,
|
|
||||||
avatarUrl: userProfile.avatarUrl
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (cached) {
|
|
||||||
console.log('[切换账号] 使用缓存:', option.wxid, cached)
|
else if (cached) {
|
||||||
return {
|
displayName = cached.displayName || displayName
|
||||||
...option,
|
avatarUrl = cached.avatarUrl || avatarUrl
|
||||||
displayName: cached.displayName,
|
}
|
||||||
avatarUrl: cached.avatarUrl
|
|
||||||
}
|
return {
|
||||||
|
...option,
|
||||||
|
displayName,
|
||||||
|
avatarUrl
|
||||||
}
|
}
|
||||||
return { ...option, displayName: option.wxid }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setWxidOptions(enrichedWxids)
|
setWxidOptions(enrichedWxids)
|
||||||
@@ -553,11 +557,17 @@ function Sidebar({ collapsed }: SidebarProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="wxid-avatar">
|
<div className="wxid-avatar">
|
||||||
{option.avatarUrl ? <img src={option.avatarUrl} alt="" /> : <span>{getAvatarLetter(option.displayName || option.wxid)}</span>}
|
{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>
|
||||||
<div className="wxid-info">
|
<div className="wxid-info">
|
||||||
<div className="wxid-name">{option.displayName || option.wxid}</div>
|
<div className="wxid-name">{option.displayName}</div>
|
||||||
<div className="wxid-id">{option.wxid}</div>
|
{option.displayName !== option.wxid && <div className="wxid-id">{option.wxid}</div>}
|
||||||
</div>
|
</div>
|
||||||
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,15 +2,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
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 {
|
.history-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px;
|
padding: 18px 18px 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 0;
|
||||||
|
|
||||||
.status-msg {
|
.status-msg {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -30,8 +31,9 @@
|
|||||||
|
|
||||||
.history-item {
|
.history-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
padding: 14px 0 0;
|
||||||
|
|
||||||
&.error-item {
|
&.error-item {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -43,65 +45,70 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.history-avatar {
|
||||||
width: 40px;
|
width: 36px;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg-tertiary);
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
img {
|
.avatar-component.avatar-inner {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
border-radius: inherit;
|
||||||
}
|
background: transparent;
|
||||||
|
|
||||||
.avatar-placeholder {
|
img.avatar-image {
|
||||||
width: 100%;
|
// Forwarded record head images may include a light matte edge.
|
||||||
height: 100%;
|
// Slightly zoom in to crop that edge and align with normal chat avatars.
|
||||||
display: flex;
|
transform: scale(1.12);
|
||||||
align-items: center;
|
transform-origin: center;
|
||||||
justify-content: center;
|
}
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-wrapper {
|
.content-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
margin-bottom: 6px;
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
.sender {
|
.sender {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
color: var(--text-primary);
|
color: color-mix(in srgb, var(--text-secondary) 82%, transparent);
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: color-mix(in srgb, var(--text-tertiary) 92%, transparent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
background: var(--bg-secondary);
|
background: transparent;
|
||||||
padding: 10px 14px;
|
padding: 0;
|
||||||
border-radius: 18px 18px 18px 4px;
|
border-radius: 0;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
display: inline-block;
|
display: block;
|
||||||
|
|
||||||
&.image-bubble {
|
&.image-bubble {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -109,8 +116,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-content {
|
.text-content {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -118,23 +125,84 @@
|
|||||||
|
|
||||||
.media-content {
|
.media-content {
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: min(100%, 420px);
|
||||||
max-height: 300px;
|
max-height: 320px;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
display: block;
|
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 {
|
.media-tip {
|
||||||
padding: 8px 12px;
|
padding: 6px 0;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-placeholder {
|
.media-placeholder {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { useParams, useLocation } from 'react-router-dom'
|
|||||||
import { ChatRecordItem } from '../types/models'
|
import { ChatRecordItem } from '../types/models'
|
||||||
import TitleBar from '../components/TitleBar'
|
import TitleBar from '../components/TitleBar'
|
||||||
import { ErrorBoundary } from '../components/ErrorBoundary'
|
import { ErrorBoundary } from '../components/ErrorBoundary'
|
||||||
|
import { Avatar } from '../components/Avatar'
|
||||||
import './ChatHistoryPage.scss'
|
import './ChatHistoryPage.scss'
|
||||||
|
|
||||||
|
const forwardedImageCache = new Map<string, string>()
|
||||||
|
|
||||||
export default function ChatHistoryPage() {
|
export default function ChatHistoryPage() {
|
||||||
const params = useParams<{ sessionId: string; messageId: string }>()
|
const params = useParams<{ sessionId: string; messageId: string; payloadId: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
|
const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -30,64 +33,212 @@ export default function ChatHistoryPage() {
|
|||||||
.replace(/'/g, "'")
|
.replace(/'/g, "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extractTopLevelXmlElements = (source: string, tagName: string): Array<{ attrs: string; inner: string }> => {
|
||||||
|
const xml = source || ''
|
||||||
|
if (!xml) return []
|
||||||
|
|
||||||
|
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 = ''
|
||||||
|
|
||||||
|
while ((match = pattern.exec(xml)) !== null) {
|
||||||
|
const isClosing = match[1] === '/'
|
||||||
|
const attrs = match[2] || ''
|
||||||
|
const rawTag = match[0] || ''
|
||||||
|
const selfClosing = !isClosing && /\/\s*>$/.test(rawTag)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
datatitle,
|
||||||
|
fileext,
|
||||||
|
datasize,
|
||||||
|
messageuuid,
|
||||||
|
dataurl,
|
||||||
|
datathumburl,
|
||||||
|
datacdnurl,
|
||||||
|
cdndatakey,
|
||||||
|
cdnthumbkey,
|
||||||
|
aeskey,
|
||||||
|
md5,
|
||||||
|
fullmd5,
|
||||||
|
thumbfullmd5,
|
||||||
|
srcMsgLocalid,
|
||||||
|
imgheight,
|
||||||
|
imgwidth,
|
||||||
|
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 => {
|
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
|
||||||
try {
|
try {
|
||||||
const type = extractXmlValue(content, 'type')
|
const decodedContent = decodeHtmlEntities(content) || content
|
||||||
if (type !== '19') return undefined
|
const type = extractXmlValue(decodedContent, 'type')
|
||||||
|
if (type !== '19' && !decodedContent.includes('<recorditem')) return undefined
|
||||||
|
|
||||||
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
|
|
||||||
if (!match) return undefined
|
|
||||||
|
|
||||||
const innerXml = match[1]
|
|
||||||
const items: ChatRecordItem[] = []
|
const items: ChatRecordItem[] = []
|
||||||
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
|
const dedupe = new Set<string>()
|
||||||
let itemMatch: RegExpExecArray | null
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
|
if (items.length === 0 && decodedContent.includes('<dataitem')) {
|
||||||
const attrs = itemMatch[1]
|
const parsedItems = parseChatRecordContainer(decodedContent)
|
||||||
const body = itemMatch[2]
|
for (const item of parsedItems) {
|
||||||
|
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
|
||||||
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
|
if (!dedupe.has(key)) {
|
||||||
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
|
dedupe.add(key)
|
||||||
|
items.push(item)
|
||||||
const sourcename = extractXmlValue(body, 'sourcename')
|
}
|
||||||
const sourcetime = extractXmlValue(body, 'sourcetime')
|
}
|
||||||
const sourceheadurl = extractXmlValue(body, 'sourceheadurl')
|
|
||||||
const datadesc = extractXmlValue(body, 'datadesc')
|
|
||||||
const datatitle = extractXmlValue(body, 'datatitle')
|
|
||||||
const fileext = extractXmlValue(body, 'fileext')
|
|
||||||
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0')
|
|
||||||
const messageuuid = extractXmlValue(body, 'messageuuid')
|
|
||||||
|
|
||||||
const dataurl = extractXmlValue(body, 'dataurl')
|
|
||||||
const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl')
|
|
||||||
const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl')
|
|
||||||
const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')
|
|
||||||
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5')
|
|
||||||
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0')
|
|
||||||
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0')
|
|
||||||
const duration = parseInt(extractXmlValue(body, 'duration') || '0')
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
datatype,
|
|
||||||
sourcename,
|
|
||||||
sourcetime,
|
|
||||||
sourceheadurl,
|
|
||||||
datadesc: decodeHtmlEntities(datadesc),
|
|
||||||
datatitle: decodeHtmlEntities(datatitle),
|
|
||||||
fileext,
|
|
||||||
datasize,
|
|
||||||
messageuuid,
|
|
||||||
dataurl: decodeHtmlEntities(dataurl),
|
|
||||||
datathumburl: decodeHtmlEntities(datathumburl),
|
|
||||||
datacdnurl: decodeHtmlEntities(datacdnurl),
|
|
||||||
aeskey: decodeHtmlEntities(aeskey),
|
|
||||||
md5,
|
|
||||||
imgheight,
|
|
||||||
imgwidth,
|
|
||||||
duration
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return items.length > 0 ? items : undefined
|
return items.length > 0 ? items : undefined
|
||||||
@@ -115,9 +266,34 @@ export default function ChatHistoryPage() {
|
|||||||
return { sid: '', mid: '' }
|
return { sid: '', mid: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ids = getIds()
|
||||||
|
const payloadId = params.payloadId || (() => {
|
||||||
|
const match = /^\/chat-history-inline\/([^/]+)/.exec(location.pathname)
|
||||||
|
return match ? match[1] : ''
|
||||||
|
})()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
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) {
|
if (!sid || !mid) {
|
||||||
setError('无效的聊天记录链接')
|
setError('无效的聊天记录链接')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -153,7 +329,7 @@ export default function ChatHistoryPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadData()
|
loadData()
|
||||||
}, [params.sessionId, params.messageId, location.pathname])
|
}, [ids.mid, ids.sid, location.pathname, payloadId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-history-page">
|
<div className="chat-history-page">
|
||||||
@@ -168,7 +344,7 @@ export default function ChatHistoryPage() {
|
|||||||
) : (
|
) : (
|
||||||
recordList.map((item, i) => (
|
recordList.map((item, i) => (
|
||||||
<ErrorBoundary key={i} fallback={<div className="history-item error-item">消息解析失败</div>}>
|
<ErrorBoundary key={i} fallback={<div className="history-item error-item">消息解析失败</div>}>
|
||||||
<HistoryItem item={item} />
|
<HistoryItem item={item} sessionId={ids.sid} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -177,9 +353,198 @@ export default function ChatHistoryPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryItem({ item }: { item: ChatRecordItem }) {
|
function detectImageMimeFromBase64(base64: string): string {
|
||||||
const [imageError, setImageError] = useState(false)
|
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 在合并转发里有两种格式:
|
// sourcetime 在合并转发里有两种格式:
|
||||||
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
||||||
let time = ''
|
let time = ''
|
||||||
@@ -191,31 +556,18 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const senderDisplayName = item.sourcename ?? '未知发送者'
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (item.datatype === 1) {
|
if (item.datatype === 1) {
|
||||||
// 文本消息
|
// 文本消息
|
||||||
return <div className="text-content">{item.datadesc || ''}</div>
|
return <div className="text-content">{item.datadesc || ''}</div>
|
||||||
}
|
}
|
||||||
if (item.datatype === 3) {
|
if (item.datatype === 2 || item.datatype === 3) {
|
||||||
// 图片
|
return <ForwardedImage item={item} sessionId={sessionId} />
|
||||||
const src = item.datathumburl || item.datacdnurl
|
}
|
||||||
if (src) {
|
if (item.datatype === 17) {
|
||||||
return (
|
return <NestedChatRecordCard item={item} sessionId={sessionId} />
|
||||||
<div className="media-content">
|
|
||||||
{imageError ? (
|
|
||||||
<div className="media-tip">图片无法加载</div>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt="图片"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
onError={() => setImageError(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return <div className="media-placeholder">[图片]</div>
|
|
||||||
}
|
}
|
||||||
if (item.datatype === 43) {
|
if (item.datatype === 43) {
|
||||||
return <div className="media-placeholder">[视频] {item.datatitle}</div>
|
return <div className="media-placeholder">[视频] {item.datatitle}</div>
|
||||||
@@ -229,21 +581,20 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="history-item">
|
<div className="history-item">
|
||||||
<div className="avatar">
|
<div className="history-avatar">
|
||||||
{item.sourceheadurl ? (
|
<Avatar
|
||||||
<img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" />
|
src={item.sourceheadurl}
|
||||||
) : (
|
name={senderDisplayName}
|
||||||
<div className="avatar-placeholder">
|
size={36}
|
||||||
{item.sourcename?.slice(0, 1)}
|
className="avatar-inner"
|
||||||
</div>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="content-wrapper">
|
<div className="content-wrapper">
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<span className="sender">{item.sourcename || '未知发送者'}</span>
|
<span className="sender">{senderDisplayName}</span>
|
||||||
<span className="time">{time}</span>
|
<span className="time">{time}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
|
<div className={`bubble ${(item.datatype === 2 || item.datatype === 3) ? 'image-bubble' : ''}`}>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -566,7 +566,8 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--chat-pattern);
|
background: var(--chat-pattern);
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
padding: 20px 24px;
|
padding: 20px 24px 112px;
|
||||||
|
padding-bottom: calc(112px + env(safe-area-inset-bottom));
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -600,7 +601,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-wrapper {
|
.message-wrapper {
|
||||||
margin-bottom: 16px;
|
box-sizing: border-box;
|
||||||
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
@@ -1129,8 +1131,12 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.unread-badge {
|
.unread-badge {
|
||||||
@@ -1744,7 +1750,8 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px 112px;
|
||||||
|
padding-bottom: calc(112px + env(safe-area-inset-bottom));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -1773,6 +1780,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-virtuoso {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-messages.loading-overlay {
|
.loading-messages.loading-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1830,9 +1841,9 @@
|
|||||||
|
|
||||||
// 回到底部按钮
|
// 回到底部按钮
|
||||||
.scroll-to-bottom {
|
.scroll-to-bottom {
|
||||||
position: sticky;
|
position: absolute;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
align-self: center;
|
left: 50%;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -1847,13 +1858,13 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translate(-50%, 20px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
&.show {
|
&.show {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translate(-50%, 0);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1890,6 +1901,8 @@
|
|||||||
.message-wrapper {
|
.message-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-bottom: 16px;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
&.sent {
|
&.sent {
|
||||||
@@ -2056,6 +2069,10 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-message-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
.emoji-loading {
|
.emoji-loading {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
height: 90px;
|
height: 90px;
|
||||||
@@ -2761,7 +2778,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@@ -3045,13 +3062,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-flag {
|
.member-flag {
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&.owner {
|
&.owner {
|
||||||
color: #f59e0b;
|
color: #f59e0b;
|
||||||
@@ -3288,13 +3307,89 @@
|
|||||||
|
|
||||||
// 聊天记录消息 (合并转发)
|
// 聊天记录消息 (合并转发)
|
||||||
.chat-record-message {
|
.chat-record-message {
|
||||||
background: var(--card-inner-bg) !important;
|
width: 300px;
|
||||||
border: 1px solid var(--border-color) !important;
|
min-width: 240px;
|
||||||
transition: opacity 0.2s ease;
|
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;
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
&:hover {
|
&: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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3368,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 {
|
.miniapp-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3533,23 +3559,18 @@
|
|||||||
.message-bubble.sent {
|
.message-bubble.sent {
|
||||||
|
|
||||||
.card-message,
|
.card-message,
|
||||||
.chat-record-message,
|
|
||||||
.miniapp-message,
|
.miniapp-message,
|
||||||
.appmsg-rich-card {
|
.appmsg-rich-card {
|
||||||
background: var(--sent-card-bg);
|
background: var(--sent-card-bg);
|
||||||
|
|
||||||
.card-name,
|
.card-name,
|
||||||
.miniapp-title,
|
.miniapp-title,
|
||||||
.source-name,
|
|
||||||
.link-title {
|
.link-title {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-label,
|
.card-label,
|
||||||
.miniapp-label,
|
.miniapp-label,
|
||||||
.chat-record-item,
|
|
||||||
.chat-record-meta-line,
|
|
||||||
.chat-record-desc,
|
|
||||||
.link-desc,
|
.link-desc,
|
||||||
.appmsg-url-line {
|
.appmsg-url-line {
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
@@ -3557,14 +3578,10 @@
|
|||||||
|
|
||||||
.card-icon,
|
.card-icon,
|
||||||
.miniapp-icon,
|
.miniapp-icon,
|
||||||
.chat-record-icon {
|
.link-thumb-placeholder {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-record-more {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.appmsg-meta-badge {
|
.appmsg-meta-badge {
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
@@ -3645,11 +3662,11 @@
|
|||||||
// 批量转写按钮
|
// 批量转写按钮
|
||||||
.batch-transcribe-btn {
|
.batch-transcribe-btn {
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
color: var(--primary-color);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.transcribing {
|
&.transcribing {
|
||||||
color: var(--primary-color);
|
color: var(--primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
@@ -3673,7 +3690,7 @@
|
|||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: var(--primary-color);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -3694,6 +3711,36 @@
|
|||||||
line-height: 1.6;
|
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 {
|
.batch-dates-list-wrap {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
@@ -3711,7 +3758,7 @@
|
|||||||
.batch-dates-btn {
|
.batch-dates-btn {
|
||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--primary-color);
|
color: var(--primary);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -3720,7 +3767,7 @@
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3753,9 +3800,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
accent-color: var(--primary-color);
|
accent-color: var(--primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--primary) 45%, transparent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-date-label {
|
.batch-date-label {
|
||||||
@@ -3798,7 +3850,7 @@
|
|||||||
.value {
|
.value {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary-color);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-concurrency-field {
|
.batch-concurrency-field {
|
||||||
@@ -3924,7 +3976,7 @@
|
|||||||
|
|
||||||
&.btn-primary,
|
&.btn-primary,
|
||||||
&.batch-transcribe-start-btn {
|
&.batch-transcribe-start-btn {
|
||||||
background: var(--primary-color);
|
background: var(--primary);
|
||||||
color: #000;
|
color: #000;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -4171,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 {
|
.official-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4538,7 +4553,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@@ -4630,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
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-analytics-page {
|
.group-analytics-page {
|
||||||
@@ -10,6 +12,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&.standalone {
|
&.standalone {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -197,6 +200,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
max-width: 450px;
|
max-width: 450px;
|
||||||
|
min-height: 0;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -207,6 +211,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
.search-row {
|
.search-row {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -296,6 +301,7 @@
|
|||||||
|
|
||||||
.group-list {
|
.group-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
@@ -468,11 +474,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-drag-region {
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
@@ -495,22 +508,30 @@
|
|||||||
|
|
||||||
.function-menu {
|
.function-menu {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 20px;
|
||||||
justify-content: center;
|
padding: 24px;
|
||||||
padding: 32px;
|
overflow-y: auto;
|
||||||
|
|
||||||
.selected-group-info {
|
.selected-group-info {
|
||||||
text-align: center;
|
display: flex;
|
||||||
margin-bottom: 40px;
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
.group-avatar.large {
|
.group-avatar.large {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 auto 16px;
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -529,45 +550,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected-group-meta {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-summary-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-grid {
|
.function-grid {
|
||||||
display: flex;
|
width: 100%;
|
||||||
flex-wrap: wrap;
|
display: grid;
|
||||||
gap: 20px;
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
justify-content: center;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-card {
|
.function-card {
|
||||||
width: 140px;
|
min-height: 148px;
|
||||||
padding: 24px 16px;
|
padding: 20px 18px;
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: color-mix(in srgb, var(--card-bg) 92%, var(--bg-secondary));
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
box-shadow: var(--shadow-sm);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid var(--border-color);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
background: rgba(255, 255, 255, 0.25);
|
background: color-mix(in srgb, var(--card-bg) 100%, var(--bg-hover));
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@@ -575,15 +615,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.function-content {
|
.function-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -694,6 +741,7 @@
|
|||||||
|
|
||||||
.content-body {
|
.content-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -785,7 +833,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-export-panel {
|
.member-export-panel,
|
||||||
|
.member-messages-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -1121,6 +1170,153 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-message-empty {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-toolbar {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(240px, 360px) minmax(160px, 1fr);
|
||||||
|
align-items: end;
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-select-trigger {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-summary-text {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-summary-card {
|
||||||
|
min-height: 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 88%, var(--bg-secondary));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-item {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 92%, var(--bg-secondary));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-type {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-content {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-load-more {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 132px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-message-end {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rankings-list {
|
.rankings-list {
|
||||||
@@ -1405,6 +1601,16 @@
|
|||||||
background: rgba(30, 30, 30, 0.95);
|
background: rgba(30, 30, 30, 0.95);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-export-modal {
|
||||||
|
background: rgba(30, 30, 30, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-result-modal {
|
||||||
|
background: rgba(30, 30, 30, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成员详情弹框
|
// 成员详情弹框
|
||||||
@@ -1496,6 +1702,34 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-modal-actions {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-modal-primary-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.detail-row {
|
.detail-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1537,3 +1771,141 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-export-modal {
|
||||||
|
background: rgba(255, 255, 255, 0.97);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 28px;
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
max-height: min(760px, calc(100vh - 32px));
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-modal-header {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-right: 40px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-panel {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-result-modal {
|
||||||
|
background: rgba(255, 255, 255, 0.97);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 28px;
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
position: relative;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
border: 1px solid color-mix(in srgb, #ef4444 38%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-result-modal-body {
|
||||||
|
padding-right: 40px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-result-modal-actions {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-result-modal-btn {
|
||||||
|
min-width: 96px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes noti-enter-center {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50px) scale(0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes noti-exit {
|
@keyframes noti-exit {
|
||||||
0% {
|
0% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -24,6 +36,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes noti-exit-center {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50px) scale(0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
// Ensure the body background is transparent to let the rounded corners show
|
// Ensure the body background is transparent to let the rounded corners show
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -41,6 +65,10 @@ body {
|
|||||||
// New notification slides in
|
// New notification slides in
|
||||||
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
|
|
||||||
|
&.anim-center {
|
||||||
|
animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#notification-prev {
|
#notification-prev {
|
||||||
@@ -51,4 +79,8 @@ body {
|
|||||||
|
|
||||||
// Ensure it stays behind
|
// Ensure it stays behind
|
||||||
z-index: 0 !important;
|
z-index: 0 !important;
|
||||||
|
|
||||||
|
&.anim-center {
|
||||||
|
animation: noti-exit-center 0.5s cubic-bezier(0.33, 1, 0.68, 1) forwards;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,9 @@ import './NotificationWindow.scss'
|
|||||||
export default function NotificationWindow() {
|
export default function NotificationWindow() {
|
||||||
const [notification, setNotification] = useState<NotificationData | null>(null)
|
const [notification, setNotification] = useState<NotificationData | null>(null)
|
||||||
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
||||||
|
const [position, setPosition] = useState<string>('top-right')
|
||||||
|
|
||||||
// We need a ref to access the current notification inside the callback
|
// We need a ref to access the current notification inside the callback
|
||||||
// without satisfying the dependency array which would recreate the listener
|
// without satisfying the dependency array which would recreate the listener
|
||||||
// Actually, setNotification(prev => ...) pattern is better, but we need the VALUE of current to set as prev.
|
// Actually, setNotification(prev => ...) pattern is better, but we need the VALUE of current to set as prev.
|
||||||
// So we use setNotification callback: setNotification(current => { ... return newNode })
|
// So we use setNotification callback: setNotification(current => { ... return newNode })
|
||||||
@@ -34,6 +35,11 @@ export default function NotificationWindow() {
|
|||||||
avatarUrl: data.avatarUrl
|
avatarUrl: data.avatarUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取位置配置
|
||||||
|
if (data.position) {
|
||||||
|
setPosition(data.position)
|
||||||
|
}
|
||||||
|
|
||||||
// Set previous to current (ref)
|
// Set previous to current (ref)
|
||||||
if (notificationRef.current) {
|
if (notificationRef.current) {
|
||||||
setPrevNotification(notificationRef.current)
|
setPrevNotification(notificationRef.current)
|
||||||
@@ -117,6 +123,7 @@ export default function NotificationWindow() {
|
|||||||
<div
|
<div
|
||||||
id="notification-prev"
|
id="notification-prev"
|
||||||
key={prevNotification.id}
|
key={prevNotification.id}
|
||||||
|
className={position === 'top-center' ? 'anim-center' : ''}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 2, // Match padding
|
top: 2, // Match padding
|
||||||
@@ -131,7 +138,7 @@ export default function NotificationWindow() {
|
|||||||
data={prevNotification}
|
data={prevNotification}
|
||||||
onClose={() => { }} // No-op for background item
|
onClose={() => { }} // No-op for background item
|
||||||
onClick={() => { }}
|
onClick={() => { }}
|
||||||
position="top-right"
|
position={position as any}
|
||||||
isStatic={true}
|
isStatic={true}
|
||||||
initialVisible={true}
|
initialVisible={true}
|
||||||
/>
|
/>
|
||||||
@@ -143,6 +150,7 @@ export default function NotificationWindow() {
|
|||||||
<div
|
<div
|
||||||
id="notification-current"
|
id="notification-current"
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
|
className={position === 'top-center' ? 'anim-center' : ''}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative', // Takes up space
|
position: 'relative', // Takes up space
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
@@ -154,7 +162,7 @@ export default function NotificationWindow() {
|
|||||||
data={notification}
|
data={notification}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
position="top-right"
|
position={position as any}
|
||||||
isStatic={true}
|
isStatic={true}
|
||||||
initialVisible={true}
|
initialVisible={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.settings-modal-overlay {
|
.settings-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 41px;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-page {
|
.settings-page {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: min(1160px, calc(100vw - 96px));
|
width: min(1160px, calc(100vw - 96px));
|
||||||
@@ -1704,7 +1705,7 @@
|
|||||||
|
|
||||||
.wxid-dialog-item {
|
.wxid-dialog-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -1742,6 +1743,66 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wxid-profile-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.wxid-avatar {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-avatar-fallback {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-info-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-profile-mini {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.wxid-avatar {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-avatar-fallback {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-info-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 通知过滤双列表容器
|
// 通知过滤双列表容器
|
||||||
.notification-filter-container {
|
.notification-filter-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||||
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
|
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
|
||||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X
|
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X, UserRound
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
@@ -30,10 +30,22 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
|||||||
{ id: 'about', label: '关于', icon: Info }
|
{ id: 'about', label: '关于', icon: Info }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||||
|
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||||
|
|
||||||
|
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||||
|
const dbPathPlaceholder = isMac
|
||||||
|
? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
|
||||||
|
: isLinux
|
||||||
|
? '例如: ~/.local/share/WeChat/xwechat_files 或者 ~/Documents/xwechat_files'
|
||||||
|
: '例如: C:\\Users\\xxx\\Documents\\xwechat_files'
|
||||||
|
|
||||||
|
|
||||||
interface WxidOption {
|
interface WxidOption {
|
||||||
wxid: string
|
wxid: string
|
||||||
modifiedTime: number
|
modifiedTime: number
|
||||||
|
nickname?: string
|
||||||
|
avatarUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
@@ -102,12 +114,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||||
|
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(true)
|
const [notificationEnabled, setNotificationEnabled] = useState(true)
|
||||||
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
|
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
|
||||||
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
|
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
|
||||||
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
||||||
|
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
|
||||||
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
||||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||||
|
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
|
||||||
|
|
||||||
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
|
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
|
||||||
const [excludeWordsInput, setExcludeWordsInput] = useState('')
|
const [excludeWordsInput, setExcludeWordsInput] = useState('')
|
||||||
@@ -157,6 +171,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
|
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
|
||||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||||
const [showApiWarning, setShowApiWarning] = useState(false)
|
const [showApiWarning, setShowApiWarning] = useState(false)
|
||||||
|
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
|
||||||
|
|
||||||
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
||||||
|
|
||||||
@@ -251,15 +266,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
if (!target.closest('.custom-select')) {
|
if (!target.closest('.custom-select')) {
|
||||||
setFilterModeDropdownOpen(false)
|
setFilterModeDropdownOpen(false)
|
||||||
setPositionDropdownOpen(false)
|
setPositionDropdownOpen(false)
|
||||||
|
setCloseBehaviorDropdownOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filterModeDropdownOpen || positionDropdownOpen) {
|
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
}, [filterModeDropdownOpen, positionDropdownOpen])
|
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen])
|
||||||
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -281,6 +297,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedNotificationPosition = await configService.getNotificationPosition()
|
const savedNotificationPosition = await configService.getNotificationPosition()
|
||||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||||
|
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
||||||
|
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||||
|
|
||||||
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||||
@@ -316,6 +334,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setNotificationPosition(savedNotificationPosition)
|
setNotificationPosition(savedNotificationPosition)
|
||||||
setNotificationFilterMode(savedNotificationFilterMode)
|
setNotificationFilterMode(savedNotificationFilterMode)
|
||||||
setNotificationFilterList(savedNotificationFilterList)
|
setNotificationFilterList(savedNotificationFilterList)
|
||||||
|
setMessagePushEnabled(savedMessagePushEnabled)
|
||||||
|
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||||
|
|
||||||
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
||||||
setWordCloudExcludeWords(savedExcludeWords)
|
setWordCloudExcludeWords(savedExcludeWords)
|
||||||
@@ -557,24 +577,37 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validatePath = (path: string): string | null => {
|
||||||
|
if (!path) return null
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(path)) {
|
||||||
|
return '路径包含中文字符,请迁移至全英文目录'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const handleAutoDetectPath = async () => {
|
const handleAutoDetectPath = async () => {
|
||||||
if (isDetectingPath) return
|
if (isDetectingPath) return
|
||||||
setIsDetectingPath(true)
|
setIsDetectingPath(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.dbPath.autoDetect()
|
const result = await window.electronAPI.dbPath.autoDetect()
|
||||||
if (result.success && result.path) {
|
if (result.success && result.path) {
|
||||||
setDbPath(result.path)
|
const validationError = validatePath(result.path)
|
||||||
await configService.setDbPath(result.path)
|
if (validationError) {
|
||||||
showMessage(`自动检测成功:${result.path}`, true)
|
showMessage(validationError, false)
|
||||||
|
} else {
|
||||||
|
setDbPath(result.path)
|
||||||
|
await configService.setDbPath(result.path)
|
||||||
|
showMessage(`自动检测成功:${result.path}`, true)
|
||||||
|
|
||||||
const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
|
const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
|
||||||
setWxidOptions(wxids)
|
setWxidOptions(wxids)
|
||||||
if (wxids.length === 1) {
|
if (wxids.length === 1) {
|
||||||
await applyWxidSelection(wxids[0].wxid, {
|
await applyWxidSelection(wxids[0].wxid, {
|
||||||
toastText: `已检测到账号:${wxids[0].wxid}`
|
toastText: `已检测到账号:${wxids[0].wxid}`
|
||||||
})
|
})
|
||||||
} else if (wxids.length > 1) {
|
} else if (wxids.length > 1) {
|
||||||
setShowWxidSelect(true)
|
setShowWxidSelect(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showMessage(result.error || '未能自动检测到数据库目录', false)
|
showMessage(result.error || '未能自动检测到数据库目录', false)
|
||||||
@@ -591,9 +624,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] })
|
const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] })
|
||||||
if (!result.canceled && result.filePaths.length > 0) {
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
const selectedPath = result.filePaths[0]
|
const selectedPath = result.filePaths[0]
|
||||||
setDbPath(selectedPath)
|
const validationError = validatePath(selectedPath)
|
||||||
await configService.setDbPath(selectedPath)
|
if (validationError) {
|
||||||
showMessage('已选择数据库目录', true)
|
showMessage(validationError, false)
|
||||||
|
} else {
|
||||||
|
setDbPath(selectedPath)
|
||||||
|
await configService.setDbPath(selectedPath)
|
||||||
|
showMessage('已选择数据库目录', true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showMessage('选择目录失败', false)
|
showMessage('选择目录失败', false)
|
||||||
@@ -1004,6 +1042,61 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>关闭主窗口时</label>
|
||||||
|
<span className="form-hint">设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。</span>
|
||||||
|
<div className="custom-select">
|
||||||
|
<div
|
||||||
|
className={`custom-select-trigger ${closeBehaviorDropdownOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => setCloseBehaviorDropdownOpen(!closeBehaviorDropdownOpen)}
|
||||||
|
>
|
||||||
|
<span className="custom-select-value">
|
||||||
|
{windowCloseBehavior === 'tray'
|
||||||
|
? '最小化到系统托盘'
|
||||||
|
: windowCloseBehavior === 'quit'
|
||||||
|
? '完全关闭'
|
||||||
|
: '每次询问'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={14} className={`custom-select-arrow ${closeBehaviorDropdownOpen ? 'rotate' : ''}`} />
|
||||||
|
</div>
|
||||||
|
<div className={`custom-select-dropdown ${closeBehaviorDropdownOpen ? 'open' : ''}`}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
value: 'ask' as const,
|
||||||
|
label: '每次询问',
|
||||||
|
successMessage: '已恢复关闭确认弹窗'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tray' as const,
|
||||||
|
label: '最小化到系统托盘',
|
||||||
|
successMessage: '关闭按钮已改为最小化到托盘'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'quit' as const,
|
||||||
|
label: '完全关闭',
|
||||||
|
successMessage: '关闭按钮已改为完全关闭'
|
||||||
|
}
|
||||||
|
].map(option => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={`custom-select-option ${windowCloseBehavior === option.value ? 'selected' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setWindowCloseBehavior(option.value)
|
||||||
|
setCloseBehaviorDropdownOpen(false)
|
||||||
|
await configService.setWindowCloseBehavior(option.value)
|
||||||
|
showMessage(option.successMessage, true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
{windowCloseBehavior === option.value && <Check size={14} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1084,12 +1177,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<span className="custom-select-value">
|
<span className="custom-select-value">
|
||||||
{notificationPosition === 'top-right' ? '右上角' :
|
{notificationPosition === 'top-right' ? '右上角' :
|
||||||
notificationPosition === 'bottom-right' ? '右下角' :
|
notificationPosition === 'bottom-right' ? '右下角' :
|
||||||
notificationPosition === 'top-left' ? '左上角' : '左下角'}
|
notificationPosition === 'top-left' ? '左上角' :
|
||||||
|
notificationPosition === 'top-center' ? '中间上方' : '左下角'}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={14} className={`custom-select-arrow ${positionDropdownOpen ? 'rotate' : ''}`} />
|
<ChevronDown size={14} className={`custom-select-arrow ${positionDropdownOpen ? 'rotate' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className={`custom-select-dropdown ${positionDropdownOpen ? 'open' : ''}`}>
|
<div className={`custom-select-dropdown ${positionDropdownOpen ? 'open' : ''}`}>
|
||||||
{[
|
{[
|
||||||
|
{ value: 'top-center', label: '中间上方' },
|
||||||
{ value: 'top-right', label: '右上角' },
|
{ value: 'top-right', label: '右上角' },
|
||||||
{ value: 'bottom-right', label: '右下角' },
|
{ value: 'bottom-right', label: '右下角' },
|
||||||
{ value: 'top-left', label: '左上角' },
|
{ value: 'top-left', label: '左上角' },
|
||||||
@@ -1099,7 +1194,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`}
|
className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||||
setNotificationPosition(val)
|
setNotificationPosition(val)
|
||||||
setPositionDropdownOpen(false)
|
setPositionDropdownOpen(false)
|
||||||
await configService.setNotificationPosition(val)
|
await configService.setNotificationPosition(val)
|
||||||
@@ -1287,10 +1382,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>数据库根目录</label>
|
<label>数据库根目录</label>
|
||||||
<span className="form-hint">xwechat_files 目录</span>
|
<span className="form-hint">xwechat_files 目录</span>
|
||||||
<span className="form-hint" style={{ color: '#ff6b6b' }}> 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</span>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="例如: C:\Users\xxx\Documents\xwechat_files"
|
placeholder={dbPathPlaceholder}
|
||||||
value={dbPath}
|
value={dbPath}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
@@ -1655,6 +1749,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
showMessage('已复制 API 地址', true)
|
showMessage('已复制 API 地址', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleMessagePush = async (enabled: boolean) => {
|
||||||
|
setMessagePushEnabled(enabled)
|
||||||
|
await configService.setMessagePushEnabled(enabled)
|
||||||
|
showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true)
|
||||||
|
}
|
||||||
|
|
||||||
const renderApiTab = () => (
|
const renderApiTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1721,6 +1821,70 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>主动推送</label>
|
||||||
|
<span className="form-hint">检测到新收到的消息后,会通过当前 API 端口下的固定 SSE 地址主动推送给外部订阅端</span>
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">
|
||||||
|
{messagePushEnabled ? '已开启' : '已关闭'}
|
||||||
|
</span>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={messagePushEnabled}
|
||||||
|
onChange={(e) => { void handleToggleMessagePush(e.target.checked) }}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>推送地址</label>
|
||||||
|
<span className="form-hint">外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`</span>
|
||||||
|
<div className="api-url-display">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`)
|
||||||
|
showMessage('已复制推送地址', true)
|
||||||
|
}}
|
||||||
|
title="复制"
|
||||||
|
>
|
||||||
|
<Copy size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>推送内容</label>
|
||||||
|
<span className="form-hint">SSE 事件名为 `message.new`;私聊推送 `avatarUrl/sourceName/content`,群聊额外附带 `groupName`</span>
|
||||||
|
<div className="api-docs">
|
||||||
|
<div className="api-item">
|
||||||
|
<div className="api-endpoint">
|
||||||
|
<span className="method get">GET</span>
|
||||||
|
<code>{`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}</code>
|
||||||
|
</div>
|
||||||
|
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
||||||
|
<div className="api-params">
|
||||||
|
{['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
|
||||||
|
<span key={param} className="param">
|
||||||
|
<code>{param}</code>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showApiWarning && (
|
{showApiWarning && (
|
||||||
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
|
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
|
||||||
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -2113,14 +2277,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="wxid-dialog-list">
|
<div className="wxid-dialog-list">
|
||||||
{wxidOptions.map((opt) => (
|
{wxidOptions.map((opt) => (
|
||||||
<div
|
<div
|
||||||
key={opt.wxid}
|
key={opt.wxid}
|
||||||
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
|
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
|
||||||
onClick={() => handleSelectWxid(opt.wxid)}
|
onClick={() => handleSelectWxid(opt.wxid)}
|
||||||
>
|
>
|
||||||
<span className="wxid-id">{opt.wxid}</span>
|
<div className="wxid-profile-row">
|
||||||
<span className="wxid-date">最后修改 {new Date(opt.modifiedTime).toLocaleString()}</span>
|
{opt.avatarUrl ? (
|
||||||
</div>
|
<img src={opt.avatarUrl} alt="avatar" className="wxid-avatar" />
|
||||||
|
) : (
|
||||||
|
<div className="wxid-avatar-fallback"><UserRound size={18}/></div>
|
||||||
|
)}
|
||||||
|
<div className="wxid-info-col">
|
||||||
|
<span className="wxid-id">{opt.nickname || opt.wxid}</span>
|
||||||
|
{opt.nickname && <span className="wxid-date">{opt.wxid}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="wxid-date" style={{marginLeft: 'auto'}}>最后修改 {new Date(opt.modifiedTime).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="wxid-dialog-footer">
|
<div className="wxid-dialog-footer">
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
|
|
||||||
/* Unified Card Container */
|
/* Unified Card Container */
|
||||||
.welcome-container {
|
.welcome-container {
|
||||||
|
position: relative;
|
||||||
width: 900px;
|
width: 900px;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
height: 620px;
|
height: 620px;
|
||||||
@@ -487,6 +488,48 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wxid-profile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-avatar-fallback {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-nickname {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.field-with-toggle {
|
.field-with-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -543,6 +586,18 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
&.is-success {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: rgb(22, 163, 74);
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: rgb(134, 239, 172);
|
||||||
|
border-color: rgba(34, 197, 94, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
@@ -878,4 +933,4 @@
|
|||||||
@keyframes progress-shimmer {
|
@keyframes progress-shimmer {
|
||||||
0% { transform: translateX(-100%); }
|
0% { transform: translateX(-100%); }
|
||||||
100% { transform: translateX(100%); }
|
100% { transform: translateX(100%); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,22 @@ import {
|
|||||||
FolderOpen, FolderSearch, KeyRound, ShieldCheck, Sparkles,
|
FolderOpen, FolderSearch, KeyRound, ShieldCheck, Sparkles,
|
||||||
UserRound, Wand2, Minus, X, HardDrive, RotateCcw
|
UserRound, Wand2, Minus, X, HardDrive, RotateCcw
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
import './WelcomePage.scss'
|
import './WelcomePage.scss'
|
||||||
|
|
||||||
|
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||||
|
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||||
|
|
||||||
|
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||||
|
const dbPathPlaceholder = isMac
|
||||||
|
? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
|
||||||
|
: isLinux
|
||||||
|
? '例如: ~/.local/share/WeChat/xwechat_files 或者 ~/Documents/xwechat_files'
|
||||||
|
: '例如: C:\\Users\\xxx\\Documents\\xwechat_files'
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ id: 'intro', title: '欢迎', desc: '准备开始你的本地数据探索' },
|
{ id: 'intro', title: '欢迎', desc: '准备开始你的本地数据探索' },
|
||||||
{ id: 'db', title: '数据库目录', desc: '定位 xwechat_files 目录' },
|
{ id: 'db', title: '数据库目录', desc: `定位 ${dbDirName}` },
|
||||||
{ id: 'cache', title: '缓存目录', desc: '设置本地缓存存储位置(可选)' },
|
{ id: 'cache', title: '缓存目录', desc: '设置本地缓存存储位置(可选)' },
|
||||||
{ id: 'key', title: '解密密钥', desc: '获取密钥与自动识别账号' },
|
{ id: 'key', title: '解密密钥', desc: '获取密钥与自动识别账号' },
|
||||||
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' },
|
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' },
|
||||||
@@ -46,7 +57,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const [imageAesKey, setImageAesKey] = useState('')
|
const [imageAesKey, setImageAesKey] = useState('')
|
||||||
const [cachePath, setCachePath] = useState('')
|
const [cachePath, setCachePath] = useState('')
|
||||||
const [wxid, setWxid] = useState('')
|
const [wxid, setWxid] = useState('')
|
||||||
const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([])
|
const [wxidOptions, setWxidOptions] = useState<Array<{
|
||||||
|
avatarUrl?: string;
|
||||||
|
nickname?: string;
|
||||||
|
wxid: string;
|
||||||
|
modifiedTime: number
|
||||||
|
}>>([])
|
||||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||||
const wxidSelectRef = useRef<HTMLDivElement>(null)
|
const wxidSelectRef = useRef<HTMLDivElement>(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -61,6 +77,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||||
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
||||||
|
const [showDbKeyConfirm, setShowDbKeyConfirm] = useState(false)
|
||||||
|
|
||||||
// 安全相关 state
|
// 安全相关 state
|
||||||
const [enableAuth, setEnableAuth] = useState(false)
|
const [enableAuth, setEnableAuth] = useState(false)
|
||||||
@@ -123,6 +140,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||||
setDbKeyStatus(payload.message)
|
setDbKeyStatus(payload.message)
|
||||||
|
if (payload.message.includes('现在可以登录') || payload.message.includes('Hook安装成功')) {
|
||||||
|
window.electronAPI.notification?.show({
|
||||||
|
title: 'WeFlow 准备就绪',
|
||||||
|
content: '现在可以登录微信了',
|
||||||
|
avatarUrl: './logo.png',
|
||||||
|
sessionId: 'weflow-system'
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => {
|
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => {
|
||||||
let msg = payload.message;
|
let msg = payload.message;
|
||||||
@@ -187,6 +212,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
window.electronAPI.window.close()
|
window.electronAPI.window.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validatePath = (path: string): string | null => {
|
||||||
|
if (!path) return null
|
||||||
|
// 检测中文字符和其他可能有问题的特殊字符
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(path)) {
|
||||||
|
return '路径包含中文字符,请迁移至全英文目录'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectPath = async () => {
|
const handleSelectPath = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await dialog.openFile({
|
const result = await dialog.openFile({
|
||||||
@@ -195,8 +229,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!result.canceled && result.filePaths.length > 0) {
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
setDbPath(result.filePaths[0])
|
const selectedPath = result.filePaths[0]
|
||||||
setError('')
|
const validationError = validatePath(selectedPath)
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError)
|
||||||
|
} else {
|
||||||
|
setDbPath(selectedPath)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError('选择目录失败')
|
setError('选择目录失败')
|
||||||
@@ -210,8 +250,13 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.dbPath.autoDetect()
|
const result = await window.electronAPI.dbPath.autoDetect()
|
||||||
if (result.success && result.path) {
|
if (result.success && result.path) {
|
||||||
setDbPath(result.path)
|
const validationError = validatePath(result.path)
|
||||||
setError('')
|
if (validationError) {
|
||||||
|
setError(validationError)
|
||||||
|
} else {
|
||||||
|
setDbPath(result.path)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || '未能检测到数据库目录')
|
setError(result.error || '未能检测到数据库目录')
|
||||||
}
|
}
|
||||||
@@ -287,6 +332,11 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
const handleAutoGetDbKey = async () => {
|
const handleAutoGetDbKey = async () => {
|
||||||
if (isFetchingDbKey) return
|
if (isFetchingDbKey) return
|
||||||
|
setShowDbKeyConfirm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDbKeyConfirm = async () => {
|
||||||
|
setShowDbKeyConfirm(false)
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
setError('')
|
setError('')
|
||||||
setIsManualStartPrompt(false)
|
setIsManualStartPrompt(false)
|
||||||
@@ -297,7 +347,6 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
setDecryptKey(result.key)
|
setDecryptKey(result.key)
|
||||||
setDbKeyStatus('密钥获取成功')
|
setDbKeyStatus('密钥获取成功')
|
||||||
setError('')
|
setError('')
|
||||||
// 获取成功后自动扫描并填入 wxid
|
|
||||||
await handleScanWxid(true)
|
await handleScanWxid(true)
|
||||||
} else {
|
} else {
|
||||||
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
||||||
@@ -598,7 +647,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
placeholder="例如:C:\\Users\\xxx\\Documents\\xwechat_files"
|
placeholder={dbPathPlaceholder}
|
||||||
value={dbPath}
|
value={dbPath}
|
||||||
onChange={(e) => setDbPath(e.target.value)}
|
onChange={(e) => setDbPath(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -613,9 +662,6 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field-hint">请选择微信-设置-存储位置对应的目录</div>
|
<div className="field-hint">请选择微信-设置-存储位置对应的目录</div>
|
||||||
<div className="field-hint warning">
|
|
||||||
目录路径不可包含中文,如有中文请先在微信中迁移至全英文目录
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -657,22 +703,32 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
onChange={(e) => setWxid(e.target.value)}
|
onChange={(e) => setWxid(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{showWxidSelect && wxidOptions.length > 0 && (
|
{showWxidSelect && wxidOptions.length > 0 && (
|
||||||
<div className="wxid-dropdown">
|
<div className="wxid-dropdown">
|
||||||
{wxidOptions.map((opt) => (
|
{wxidOptions.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.wxid}
|
key={opt.wxid}
|
||||||
type="button"
|
type="button"
|
||||||
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
|
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setWxid(opt.wxid)
|
setWxid(opt.wxid)
|
||||||
setShowWxidSelect(false)
|
setShowWxidSelect(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="wxid-name">{opt.wxid}</span>
|
<div className="wxid-profile">
|
||||||
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span>
|
{opt.avatarUrl ? (
|
||||||
</button>
|
<img src={opt.avatarUrl} alt="avatar" className="wxid-avatar" />
|
||||||
))}
|
) : (
|
||||||
</div>
|
<div className="wxid-avatar-fallback"><UserRound size={14}/></div>
|
||||||
|
)}
|
||||||
|
<div className="wxid-info">
|
||||||
|
<span className="wxid-nickname">{opt.nickname || opt.wxid}</span>
|
||||||
|
{opt.nickname && <span className="wxid-sub">{opt.wxid}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -705,7 +761,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dbKeyStatus && <div className="status-message">{dbKeyStatus}</div>}
|
{dbKeyStatus && <div className={`status-message ${dbKeyStatus.includes('现在可以登录') || dbKeyStatus.includes('Hook安装成功') ? 'is-success' : ''}`}>{dbKeyStatus}</div>}
|
||||||
<div className="field-hint">点击自动获取后微信将重启,请留意弹窗提示</div>
|
<div className="field-hint">点击自动获取后微信将重启,请留意弹窗提示</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -840,6 +896,20 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showDbKeyConfirm}
|
||||||
|
title="开始获取数据库密钥"
|
||||||
|
message={`当开始获取后 WeFlow 将会执行准备操作。
|
||||||
|
${isLinux ? `
|
||||||
|
【⚠️ Linux 用户特别注意】
|
||||||
|
如果您在微信里勾选了“自动登录”,请务必先关闭自动登录,然后再点击下方确认!
|
||||||
|
(因为授权弹窗输入密码需要时间,若自动登录太快会导致获取失败)
|
||||||
|
` : ''}
|
||||||
|
当 WeFlow 内的提示条变为绿色显示允许登录或看到来自 WeFlow 的登录通知时,请在手机上确认登录微信。`}
|
||||||
|
onConfirm={handleDbKeyConfirm}
|
||||||
|
onCancel={() => setShowDbKeyConfirm(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
||||||
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||||
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
||||||
|
EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP: 'exportSessionMutualFriendsCacheMap',
|
||||||
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
||||||
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
||||||
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
||||||
@@ -62,6 +63,8 @@ export const CONFIG_KEYS = {
|
|||||||
NOTIFICATION_POSITION: 'notificationPosition',
|
NOTIFICATION_POSITION: 'notificationPosition',
|
||||||
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
||||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||||
|
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
||||||
|
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||||
|
|
||||||
// 词云
|
// 词云
|
||||||
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
|
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
|
||||||
@@ -85,6 +88,8 @@ export interface ExportDefaultMediaConfig {
|
|||||||
emojis: boolean
|
emojis: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||||
|
|
||||||
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
@@ -593,6 +598,34 @@ export interface ExportSnsUserPostCountsCacheItem {
|
|||||||
counts: Record<string, number>
|
counts: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExportSessionMutualFriendDirection = 'incoming' | 'outgoing' | 'bidirectional'
|
||||||
|
export type ExportSessionMutualFriendBehavior = 'likes' | 'comments' | 'both'
|
||||||
|
|
||||||
|
export interface ExportSessionMutualFriendCacheItem {
|
||||||
|
name: string
|
||||||
|
incomingLikeCount: number
|
||||||
|
incomingCommentCount: number
|
||||||
|
outgoingLikeCount: number
|
||||||
|
outgoingCommentCount: number
|
||||||
|
totalCount: number
|
||||||
|
latestTime: number
|
||||||
|
direction: ExportSessionMutualFriendDirection
|
||||||
|
behavior: ExportSessionMutualFriendBehavior
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionMutualFriendsCacheEntry {
|
||||||
|
count: number
|
||||||
|
items: ExportSessionMutualFriendCacheItem[]
|
||||||
|
loadedPosts: number
|
||||||
|
totalPosts: number | null
|
||||||
|
computedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionMutualFriendsCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
metrics: Record<string, ExportSessionMutualFriendsCacheEntry>
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnsPageOverviewCache {
|
export interface SnsPageOverviewCache {
|
||||||
totalPosts: number
|
totalPosts: number
|
||||||
totalFriends: number
|
totalFriends: number
|
||||||
@@ -852,6 +885,148 @@ export async function setExportSnsUserPostCountsCache(
|
|||||||
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
|
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeMutualFriendDirection = (value: unknown): ExportSessionMutualFriendDirection | null => {
|
||||||
|
if (value === 'incoming' || value === 'outgoing' || value === 'bidirectional') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeMutualFriendBehavior = (value: unknown): ExportSessionMutualFriendBehavior | null => {
|
||||||
|
if (value === 'likes' || value === 'comments' || value === 'both') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeExportSessionMutualFriendsCacheEntry = (raw: unknown): ExportSessionMutualFriendsCacheEntry | null => {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const count = Number(source.count)
|
||||||
|
const loadedPosts = Number(source.loadedPosts)
|
||||||
|
const computedAt = Number(source.computedAt)
|
||||||
|
const itemsRaw = Array.isArray(source.items) ? source.items : []
|
||||||
|
const totalPostsRaw = source.totalPosts
|
||||||
|
const totalPosts = totalPostsRaw === null || totalPostsRaw === undefined
|
||||||
|
? null
|
||||||
|
: Number(totalPostsRaw)
|
||||||
|
|
||||||
|
if (!Number.isFinite(count) || count < 0 || !Number.isFinite(loadedPosts) || loadedPosts < 0 || !Number.isFinite(computedAt) || computedAt < 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: ExportSessionMutualFriendCacheItem[] = []
|
||||||
|
for (const itemRaw of itemsRaw) {
|
||||||
|
if (!itemRaw || typeof itemRaw !== 'object') continue
|
||||||
|
const item = itemRaw as Record<string, unknown>
|
||||||
|
const name = String(item.name || '').trim()
|
||||||
|
const direction = normalizeMutualFriendDirection(item.direction)
|
||||||
|
const behavior = normalizeMutualFriendBehavior(item.behavior)
|
||||||
|
const incomingLikeCount = Number(item.incomingLikeCount)
|
||||||
|
const incomingCommentCount = Number(item.incomingCommentCount)
|
||||||
|
const outgoingLikeCount = Number(item.outgoingLikeCount)
|
||||||
|
const outgoingCommentCount = Number(item.outgoingCommentCount)
|
||||||
|
const totalCount = Number(item.totalCount)
|
||||||
|
const latestTime = Number(item.latestTime)
|
||||||
|
if (!name || !direction || !behavior) continue
|
||||||
|
if (
|
||||||
|
!Number.isFinite(incomingLikeCount) || incomingLikeCount < 0 ||
|
||||||
|
!Number.isFinite(incomingCommentCount) || incomingCommentCount < 0 ||
|
||||||
|
!Number.isFinite(outgoingLikeCount) || outgoingLikeCount < 0 ||
|
||||||
|
!Number.isFinite(outgoingCommentCount) || outgoingCommentCount < 0 ||
|
||||||
|
!Number.isFinite(totalCount) || totalCount < 0 ||
|
||||||
|
!Number.isFinite(latestTime) || latestTime < 0
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
name,
|
||||||
|
incomingLikeCount: Math.floor(incomingLikeCount),
|
||||||
|
incomingCommentCount: Math.floor(incomingCommentCount),
|
||||||
|
outgoingLikeCount: Math.floor(outgoingLikeCount),
|
||||||
|
outgoingCommentCount: Math.floor(outgoingCommentCount),
|
||||||
|
totalCount: Math.floor(totalCount),
|
||||||
|
latestTime: Math.floor(latestTime),
|
||||||
|
direction,
|
||||||
|
behavior
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: Math.floor(count),
|
||||||
|
items,
|
||||||
|
loadedPosts: Math.floor(loadedPosts),
|
||||||
|
totalPosts: totalPosts === null
|
||||||
|
? null
|
||||||
|
: (Number.isFinite(totalPosts) && totalPosts >= 0 ? Math.floor(totalPosts) : null),
|
||||||
|
computedAt: Math.floor(computedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportSessionMutualFriendsCache(scopeKey: string): Promise<ExportSessionMutualFriendsCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||||
|
const rawMetrics = (rawItem as Record<string, unknown>).metrics
|
||||||
|
if (!rawMetrics || typeof rawMetrics !== 'object') return null
|
||||||
|
|
||||||
|
const metrics: Record<string, ExportSessionMutualFriendsCacheEntry> = {}
|
||||||
|
for (const [sessionIdRaw, metricRaw] of Object.entries(rawMetrics as Record<string, unknown>)) {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
const metric = normalizeExportSessionMutualFriendsCacheEntry(metricRaw)
|
||||||
|
if (!metric) continue
|
||||||
|
metrics[sessionId] = metric
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||||
|
metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSessionMutualFriendsCache(
|
||||||
|
scopeKey: string,
|
||||||
|
metrics: Record<string, ExportSessionMutualFriendsCacheEntry>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const normalized: Record<string, ExportSessionMutualFriendsCacheEntry> = {}
|
||||||
|
for (const [sessionIdRaw, metricRaw] of Object.entries(metrics || {})) {
|
||||||
|
const sessionId = String(sessionIdRaw || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
const metric = normalizeExportSessionMutualFriendsCacheEntry(metricRaw)
|
||||||
|
if (!metric) continue
|
||||||
|
normalized[sessionId] = metric
|
||||||
|
}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
metrics: normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearExportSessionMutualFriendsCache(scopeKey: string): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP)
|
||||||
|
if (!current || typeof current !== 'object') return
|
||||||
|
const map = { ...(current as Record<string, unknown>) }
|
||||||
|
if (!(scopeKey in map)) return
|
||||||
|
delete map[scopeKey]
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
||||||
if (!scopeKey) return null
|
if (!scopeKey) return null
|
||||||
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
||||||
@@ -1188,6 +1363,25 @@ export async function setNotificationFilterList(list: string[]): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
|
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMessagePushEnabled(): Promise<boolean> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_ENABLED)
|
||||||
|
return value === true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMessagePushEnabled(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.MESSAGE_PUSH_ENABLED, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWindowCloseBehavior(): Promise<WindowCloseBehavior> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
|
||||||
|
if (value === 'tray' || value === 'quit') return value
|
||||||
|
return 'ask'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setWindowCloseBehavior(behavior: WindowCloseBehavior): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR, behavior)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取词云排除词列表
|
// 获取词云排除词列表
|
||||||
export async function getWordCloudExcludeWords(): Promise<string[]> {
|
export async function getWordCloudExcludeWords(): Promise<string[]> {
|
||||||
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)
|
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export type BatchVoiceTaskType = 'transcribe' | 'decrypt'
|
||||||
|
|
||||||
export interface BatchTranscribeState {
|
export interface BatchTranscribeState {
|
||||||
/** 是否正在批量转写 */
|
/** 是否正在批量转写 */
|
||||||
isBatchTranscribing: boolean
|
isBatchTranscribing: boolean
|
||||||
|
/** 当前批量任务类型 */
|
||||||
|
taskType: BatchVoiceTaskType
|
||||||
/** 转写进度 */
|
/** 转写进度 */
|
||||||
progress: { current: number; total: number }
|
progress: { current: number; total: number }
|
||||||
/** 是否显示进度浮窗 */
|
/** 是否显示进度浮窗 */
|
||||||
@@ -16,7 +20,7 @@ export interface BatchTranscribeState {
|
|||||||
sessionName: string
|
sessionName: string
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
startTranscribe: (total: number, sessionName: string) => void
|
startTranscribe: (total: number, sessionName: string, taskType?: BatchVoiceTaskType) => void
|
||||||
updateProgress: (current: number, total: number) => void
|
updateProgress: (current: number, total: number) => void
|
||||||
finishTranscribe: (success: number, fail: number) => void
|
finishTranscribe: (success: number, fail: number) => void
|
||||||
setShowToast: (show: boolean) => void
|
setShowToast: (show: boolean) => void
|
||||||
@@ -26,6 +30,7 @@ export interface BatchTranscribeState {
|
|||||||
|
|
||||||
export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
||||||
isBatchTranscribing: false,
|
isBatchTranscribing: false,
|
||||||
|
taskType: 'transcribe',
|
||||||
progress: { current: 0, total: 0 },
|
progress: { current: 0, total: 0 },
|
||||||
showToast: false,
|
showToast: false,
|
||||||
showResult: false,
|
showResult: false,
|
||||||
@@ -33,8 +38,9 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
|||||||
sessionName: '',
|
sessionName: '',
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
|
|
||||||
startTranscribe: (total, sessionName) => set({
|
startTranscribe: (total, sessionName, taskType = 'transcribe') => set({
|
||||||
isBatchTranscribing: true,
|
isBatchTranscribing: true,
|
||||||
|
taskType,
|
||||||
showToast: true,
|
showToast: true,
|
||||||
progress: { current: 0, total },
|
progress: { current: 0, total },
|
||||||
showResult: false,
|
showResult: false,
|
||||||
@@ -60,6 +66,7 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
|||||||
|
|
||||||
reset: () => set({
|
reset: () => set({
|
||||||
isBatchTranscribing: false,
|
isBatchTranscribing: false,
|
||||||
|
taskType: 'transcribe',
|
||||||
progress: { current: 0, total: 0 },
|
progress: { current: 0, total: 0 },
|
||||||
showToast: false,
|
showToast: false,
|
||||||
showResult: false,
|
showResult: false,
|
||||||
|
|||||||
@@ -81,14 +81,48 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
setMessages: (messages) => set({ messages }),
|
setMessages: (messages) => set({ messages }),
|
||||||
|
|
||||||
appendMessages: (newMessages, prepend = false) => set((state) => {
|
appendMessages: (newMessages, prepend = false) => set((state) => {
|
||||||
// 强制去重逻辑
|
const buildPrimaryKey = (m: Message): string => {
|
||||||
const getMsgKey = (m: Message) => {
|
if (m.messageKey) return String(m.messageKey)
|
||||||
if (m.localId && m.localId > 0) return `l:${m.localId}`
|
return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}`
|
||||||
return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}`
|
|
||||||
}
|
}
|
||||||
|
const buildAliasKeys = (m: Message): string[] => {
|
||||||
|
const keys = [buildPrimaryKey(m)]
|
||||||
|
const localId = Math.max(0, Number(m.localId || 0))
|
||||||
|
const serverId = Math.max(0, Number(m.serverId || 0))
|
||||||
|
const createTime = Math.max(0, Number(m.createTime || 0))
|
||||||
|
const localType = Math.floor(Number(m.localType || 0))
|
||||||
|
const sender = String(m.senderUsername || '')
|
||||||
|
const isSend = Number(m.isSend ?? -1)
|
||||||
|
|
||||||
|
if (localId > 0) {
|
||||||
|
keys.push(`lid:${localId}`)
|
||||||
|
}
|
||||||
|
if (serverId > 0) {
|
||||||
|
keys.push(`sid:${serverId}`)
|
||||||
|
}
|
||||||
|
if (localType === 3) {
|
||||||
|
const imageIdentity = String(m.imageMd5 || m.imageDatName || '').trim()
|
||||||
|
if (imageIdentity) {
|
||||||
|
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
const currentMessages = state.messages || []
|
const currentMessages = state.messages || []
|
||||||
const existingKeys = new Set(currentMessages.map(getMsgKey))
|
const existingAliases = new Set<string>()
|
||||||
const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m)))
|
currentMessages.forEach((msg) => {
|
||||||
|
buildAliasKeys(msg).forEach((key) => existingAliases.add(key))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered: Message[] = []
|
||||||
|
newMessages.forEach((msg) => {
|
||||||
|
const aliasKeys = buildAliasKeys(msg)
|
||||||
|
const exists = aliasKeys.some((key) => existingAliases.has(key))
|
||||||
|
if (exists) return
|
||||||
|
filtered.push(msg)
|
||||||
|
aliasKeys.forEach((key) => existingAliases.add(key))
|
||||||
|
})
|
||||||
|
|
||||||
if (filtered.length === 0) return state
|
if (filtered.length === 0) return state
|
||||||
|
|
||||||
|
|||||||
@@ -530,4 +530,4 @@ body {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/types/electron.d.ts
vendored
35
src/types/electron.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
import type { ChatSession, Message, Contact, ContactInfo } from './models'
|
import type { ChatSession, Message, Contact, ContactInfo, ChatRecordItem } from './models'
|
||||||
|
|
||||||
export interface SessionChatWindowOpenOptions {
|
export interface SessionChatWindowOpenOptions {
|
||||||
source?: 'chat' | 'export'
|
source?: 'chat' | 'export'
|
||||||
@@ -14,6 +14,8 @@ export interface ElectronAPI {
|
|||||||
isMaximized: () => Promise<boolean>
|
isMaximized: () => Promise<boolean>
|
||||||
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void
|
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void
|
||||||
close: () => void
|
close: () => void
|
||||||
|
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => () => void
|
||||||
|
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise<boolean>
|
||||||
openAgreementWindow: () => Promise<boolean>
|
openAgreementWindow: () => Promise<boolean>
|
||||||
completeOnboarding: () => Promise<boolean>
|
completeOnboarding: () => Promise<boolean>
|
||||||
openOnboardingWindow: () => Promise<boolean>
|
openOnboardingWindow: () => Promise<boolean>
|
||||||
@@ -22,6 +24,8 @@ export interface ElectronAPI {
|
|||||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
|
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
|
||||||
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
||||||
|
openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: ChatRecordItem[] }) => Promise<boolean>
|
||||||
|
getChatHistoryPayload: (payloadId: string) => Promise<{ success: boolean; payload?: { sessionId: string; title?: string; recordList: ChatRecordItem[] }; error?: string }>
|
||||||
openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise<boolean>
|
openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise<boolean>
|
||||||
}
|
}
|
||||||
config: {
|
config: {
|
||||||
@@ -183,12 +187,14 @@ export interface ElectronAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
|
nextOffset?: number;
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
|
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
messages?: Message[]
|
messages?: Message[]
|
||||||
hasMore?: boolean
|
hasMore?: boolean
|
||||||
|
nextOffset?: number
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
|
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
|
||||||
@@ -219,6 +225,7 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
getMyAvatarUrl: () => Promise<{ success: boolean; avatarUrl?: string; error?: string }>
|
getMyAvatarUrl: () => Promise<{ success: boolean; avatarUrl?: string; error?: string }>
|
||||||
downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||||
|
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => Promise<{ success: boolean; messages?: Message[]; error?: string }>
|
||||||
close: () => Promise<boolean>
|
close: () => Promise<boolean>
|
||||||
getSessionDetail: (sessionId: string) => Promise<{
|
getSessionDetail: (sessionId: string) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -314,8 +321,7 @@ export interface ElectronAPI {
|
|||||||
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
|
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
|
||||||
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
||||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => () => void
|
||||||
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
|
|
||||||
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
|
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
|
||||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
|
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
|
||||||
}
|
}
|
||||||
@@ -489,6 +495,19 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
getGroupMemberMessages: (
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: {
|
||||||
|
messages: Message[]
|
||||||
|
hasMore: boolean
|
||||||
|
nextCursor: number
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
|
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
count?: number
|
count?: number
|
||||||
@@ -844,6 +863,16 @@ export interface ExportProgress {
|
|||||||
phaseProgress?: number
|
phaseProgress?: number
|
||||||
phaseTotal?: number
|
phaseTotal?: number
|
||||||
phaseLabel?: string
|
phaseLabel?: string
|
||||||
|
collectedMessages?: number
|
||||||
|
exportedMessages?: number
|
||||||
|
estimatedTotalMessages?: number
|
||||||
|
writtenFiles?: number
|
||||||
|
mediaDoneFiles?: number
|
||||||
|
mediaCacheHitFiles?: number
|
||||||
|
mediaCacheMissFiles?: number
|
||||||
|
mediaCacheFillFiles?: number
|
||||||
|
mediaDedupReuseFiles?: number
|
||||||
|
mediaBytesWritten?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WxidInfo {
|
export interface WxidInfo {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface ChatSession {
|
|||||||
selfWxid?: string // Helper field to avoid extra API calls
|
selfWxid?: string // Helper field to avoid extra API calls
|
||||||
isFolded?: boolean // 是否已折叠进"折叠的群聊"
|
isFolded?: boolean // 是否已折叠进"折叠的群聊"
|
||||||
isMuted?: boolean // 是否开启免打扰
|
isMuted?: boolean // 是否开启免打扰
|
||||||
|
alias?: string // 微信号
|
||||||
|
matchedField?: 'wxid' | 'alias' | 'name' // 搜索匹配的字段
|
||||||
}
|
}
|
||||||
|
|
||||||
// 联系人
|
// 联系人
|
||||||
@@ -41,8 +43,10 @@ export interface ContactInfo {
|
|||||||
|
|
||||||
// 消息
|
// 消息
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
messageKey: string
|
||||||
localId: number
|
localId: number
|
||||||
serverId: number
|
serverId: number
|
||||||
|
serverIdRaw?: string
|
||||||
localType: number
|
localType: number
|
||||||
createTime: number
|
createTime: number
|
||||||
sortSeq: number
|
sortSeq: number
|
||||||
@@ -105,6 +109,10 @@ export interface Message {
|
|||||||
// 聊天记录
|
// 聊天记录
|
||||||
chatRecordTitle?: string // 聊天记录标题
|
chatRecordTitle?: string // 聊天记录标题
|
||||||
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
chatRecordList?: ChatRecordItem[] // 聊天记录列表
|
||||||
|
_db_path?: string
|
||||||
|
// 运行时补充的发送者信息
|
||||||
|
senderDisplayName?: string
|
||||||
|
senderAvatarUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 聊天记录项
|
// 聊天记录项
|
||||||
@@ -121,11 +129,19 @@ export interface ChatRecordItem {
|
|||||||
dataurl?: string // 数据URL
|
dataurl?: string // 数据URL
|
||||||
datathumburl?: string // 缩略图URL
|
datathumburl?: string // 缩略图URL
|
||||||
datacdnurl?: string // CDN URL
|
datacdnurl?: string // CDN URL
|
||||||
|
cdndatakey?: string // CDN 数据 key
|
||||||
|
cdnthumbkey?: string // CDN 缩略图 key
|
||||||
aeskey?: string // AES密钥
|
aeskey?: string // AES密钥
|
||||||
md5?: string // MD5
|
md5?: string // MD5
|
||||||
|
fullmd5?: string // 原图 MD5
|
||||||
|
thumbfullmd5?: string // 缩略图 MD5
|
||||||
|
srcMsgLocalid?: number // 源消息 LocalId
|
||||||
imgheight?: number // 图片高度
|
imgheight?: number // 图片高度
|
||||||
imgwidth?: number // 图片宽度
|
imgwidth?: number // 图片宽度
|
||||||
duration?: number // 时长(毫秒)
|
duration?: number // 时长(毫秒)
|
||||||
|
chatRecordTitle?: string // 嵌套聊天记录标题
|
||||||
|
chatRecordDesc?: string // 嵌套聊天记录描述
|
||||||
|
chatRecordList?: ChatRecordItem[] // 嵌套聊天记录列表
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
export class AvatarLoadQueue {
|
export class AvatarLoadQueue {
|
||||||
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
|
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
|
||||||
private loading = new Map<string, Promise<void>>()
|
private loading = new Map<string, Promise<void>>()
|
||||||
|
private failed = new Map<string, number>()
|
||||||
private activeCount = 0
|
private activeCount = 0
|
||||||
private readonly maxConcurrent = 3
|
private readonly maxConcurrent = 3
|
||||||
private readonly delayBetweenBatches = 10
|
private readonly delayBetweenBatches = 10
|
||||||
|
private readonly failedTtlMs = 10 * 60 * 1000
|
||||||
|
|
||||||
private static instance: AvatarLoadQueue
|
private static instance: AvatarLoadQueue
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ export class AvatarLoadQueue {
|
|||||||
|
|
||||||
async enqueue(url: string): Promise<void> {
|
async enqueue(url: string): Promise<void> {
|
||||||
if (!url) return Promise.resolve()
|
if (!url) return Promise.resolve()
|
||||||
|
if (this.hasFailed(url)) {
|
||||||
|
return Promise.reject(new Error(`Failed: ${url}`))
|
||||||
|
}
|
||||||
|
|
||||||
// 核心修复:防止重复并发请求同一个 URL
|
// 核心修复:防止重复并发请求同一个 URL
|
||||||
const existingPromise = this.loading.get(url)
|
const existingPromise = this.loading.get(url)
|
||||||
@@ -31,13 +36,40 @@ export class AvatarLoadQueue {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.loading.set(url, loadPromise)
|
this.loading.set(url, loadPromise)
|
||||||
loadPromise.finally(() => {
|
void loadPromise.then(
|
||||||
this.loading.delete(url)
|
() => {
|
||||||
})
|
this.loading.delete(url)
|
||||||
|
this.clearFailed(url)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.loading.delete(url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return loadPromise
|
return loadPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasFailed(url: string): boolean {
|
||||||
|
if (!url) return false
|
||||||
|
const failedAt = this.failed.get(url)
|
||||||
|
if (!failedAt) return false
|
||||||
|
if (Date.now() - failedAt > this.failedTtlMs) {
|
||||||
|
this.failed.delete(url)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
markFailed(url: string) {
|
||||||
|
if (!url) return
|
||||||
|
this.failed.set(url, Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFailed(url: string) {
|
||||||
|
if (!url) return
|
||||||
|
this.failed.delete(url)
|
||||||
|
}
|
||||||
|
|
||||||
private async processQueue() {
|
private async processQueue() {
|
||||||
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
|
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
|
||||||
return
|
return
|
||||||
@@ -49,13 +81,16 @@ export class AvatarLoadQueue {
|
|||||||
this.activeCount++
|
this.activeCount++
|
||||||
|
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
|
img.referrerPolicy = 'no-referrer'
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
this.activeCount--
|
this.activeCount--
|
||||||
|
this.clearFailed(task.url)
|
||||||
task.resolve()
|
task.resolve()
|
||||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||||
}
|
}
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
this.activeCount--
|
this.activeCount--
|
||||||
|
this.markFailed(task.url)
|
||||||
task.reject(new Error(`Failed: ${task.url}`))
|
task.reject(new Error(`Failed: ${task.url}`))
|
||||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||||
}
|
}
|
||||||
@@ -67,6 +102,7 @@ export class AvatarLoadQueue {
|
|||||||
clear() {
|
clear() {
|
||||||
this.queue = []
|
this.queue = []
|
||||||
this.loading.clear()
|
this.loading.clear()
|
||||||
|
this.failed.clear()
|
||||||
this.activeCount = 0
|
this.activeCount = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export default defineConfig({
|
|||||||
'whisper-node',
|
'whisper-node',
|
||||||
'shelljs',
|
'shelljs',
|
||||||
'exceljs',
|
'exceljs',
|
||||||
'node-llama-cpp'
|
'node-llama-cpp',
|
||||||
|
'sudo-prompt'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +127,26 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
entry: 'electron/exportWorker.ts',
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron',
|
||||||
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
'better-sqlite3',
|
||||||
|
'koffi',
|
||||||
|
'fsevents',
|
||||||
|
'exceljs'
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'exportWorker.js',
|
||||||
|
inlineDynamicImports: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
entry: 'electron/preload.ts',
|
entry: 'electron/preload.ts',
|
||||||
onstart(options) {
|
onstart(options) {
|
||||||
|
|||||||
Reference in New Issue
Block a user