Compare commits

..

170 Commits

Author SHA1 Message Date
dependabot[bot]
12be5311ce chore(deps): bump sherpa-onnx-node from 1.12.34 to 1.12.35
Bumps [sherpa-onnx-node](https://github.com/csukuangfj/sherpa-onnx) from 1.12.34 to 1.12.35.
- [Release notes](https://github.com/csukuangfj/sherpa-onnx/releases)
- [Changelog](https://github.com/csukuangfj/sherpa-onnx/blob/dart-v1.12.35/CHANGELOG.md)
- [Commits](https://github.com/csukuangfj/sherpa-onnx/compare/dart-v1.12.34...dart-v1.12.35)

---
updated-dependencies:
- dependency-name: sherpa-onnx-node
  dependency-version: 1.12.35
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 23:16:03 +00:00
H3CoF6
d1b8d86a20 Merge pull request #625 from H3CoF6/dev
修复biz的一些问题
2026-04-04 02:58:54 +08:00
H3CoF6
25ef7c5d8a 更快的排序 2026-04-04 02:52:12 +08:00
H3CoF6
db429abf5b 时间排序 2026-04-04 02:34:57 +08:00
H3CoF6
19d5ae7e15 fix: 修复账号类型,删除广告账号 2026-04-04 01:53:03 +08:00
cc
fcbd613f4a Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 23:23:47 +08:00
cc
5fae370c55 更新打包 2026-04-03 23:23:42 +08:00
xuncha
f2dbe6ee8f Merge pull request #622 from xunchahaha/dev
Dev
2026-04-03 21:11:08 +08:00
xuncha
0175a6998b Merge branch 'dev' into dev 2026-04-03 21:08:36 +08:00
xuncha
758de9949b 新增开机自启动 [Enhancement]: 希望能够支持静默启动和开机自启动
Fixes #516
2026-04-03 21:08:05 +08:00
xuncha
81b8960d41 双人年度报告支持导出 [Enhancement]: 双人年度报告不支持导出 但总年度报告支持
Fixes #531
2026-04-03 21:07:44 +08:00
xuncha
5b25619b24 Merge pull request #620 from xunchahaha/dev
卡片链接新增解析
2026-04-03 20:50:44 +08:00
xuncha
62e23aaf23 卡片链接新增解析 2026-04-03 20:47:15 +08:00
cc
aac8eed898 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 20:35:10 +08:00
cc
108980befb 修复了一些问题 2026-04-03 20:34:57 +08:00
xuncha
a6c899c098 Merge pull request #557 from jinkangHe/dev
feat(sns):增加朋友圈相关api
2026-04-03 20:14:57 +08:00
xuncha
28170d31df Merge branch 'dev' into dev 2026-04-03 20:11:25 +08:00
cc
0047685f54 修复了一些问题 2026-04-03 20:09:37 +08:00
xuncha
2cc0fc64a4 Merge branch 'dev' into dev 2026-04-03 20:08:03 +08:00
xuncha
67642cebfd fix(http): stream live sns media and clarify docs 2026-04-03 20:07:11 +08:00
cc
327dc85d14 优化通道结构 2026-04-03 20:05:23 +08:00
cc
8c4f42bab1 Merge branch 'dev' into dev 2026-04-03 19:52:35 +08:00
cc
40c29e494c 更新配置文件 2026-04-03 19:49:43 +08:00
xuncha
0235ec7edc Merge branch 'dev' into dev 2026-04-03 19:49:29 +08:00
cc
fa2a000624 Merge pull request #617 from hicccc77/dependabot/npm_and_yarn/dev/electron-store-11.0.2
chore(deps): bump electron-store from 10.1.0 to 11.0.2
2026-04-03 19:43:41 +08:00
dependabot[bot]
861b24cef1 chore(deps): bump electron-store from 10.1.0 to 11.0.2
Bumps [electron-store](https://github.com/sindresorhus/electron-store) from 10.1.0 to 11.0.2.
- [Release notes](https://github.com/sindresorhus/electron-store/releases)
- [Commits](https://github.com/sindresorhus/electron-store/compare/v10.1.0...v11.0.2)

---
updated-dependencies:
- dependency-name: electron-store
  dependency-version: 11.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 11:42:44 +00:00
cc
ee1977384e Merge pull request #616 from hicccc77/dependabot/npm_and_yarn/dev/react-router-dom-7.14.0
chore(deps): bump react-router-dom from 7.13.2 to 7.14.0
2026-04-03 19:41:56 +08:00
cc
5d08505f62 Merge pull request #614 from hicccc77/dependabot/npm_and_yarn/dev/electron-41.1.1
chore(deps-dev): bump electron from 39.8.6 to 41.1.1
2026-04-03 19:41:32 +08:00
cc
ab21124327 Merge branch 'dev' into dependabot/npm_and_yarn/dev/electron-41.1.1 2026-04-03 19:41:16 +08:00
cc
1df792ec9c Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 19:35:11 +08:00
cc
a8fa6e5987 修复了一些打包问题 2026-04-03 19:34:32 +08:00
dependabot[bot]
1d69c5a78d chore(deps): bump react-router-dom from 7.13.2 to 7.14.0
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.13.2 to 7.14.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 23:16:50 +00:00
dependabot[bot]
0ae7ba3e11 chore(deps-dev): bump electron from 39.8.6 to 41.1.1
Bumps [electron](https://github.com/electron/electron) from 39.8.6 to 41.1.1.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v39.8.6...v41.1.1)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 41.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 23:16:24 +00:00
H3CoF6
c421ca7f2f Merge pull request #613 from H3CoF6/dev
feat: 公众号服务号内容单独解析
2026-04-03 06:00:43 +08:00
H3CoF6
ea4fff5b10 Merge remote-tracking branch 'upstream/main' into dev 2026-04-03 05:45:10 +08:00
H3CoF6
e0b0e38271 fix: 服务号类型说明 2026-04-03 05:44:19 +08:00
H3CoF6
510b956649 refactor: 样式对齐 2026-04-03 05:20:58 +08:00
H3CoF6
17b8af4bc4 fix: 删除广告,增添无记录显示 2026-04-03 04:48:39 +08:00
H3CoF6
617b400884 feat: 以chat的方式实现biz的解析 2026-04-03 04:40:34 +08:00
cc
a58518ccb5 Merge pull request #611 from hicccc77/dev
Dev
2026-04-03 00:01:58 +08:00
cc
cdd17d919e Merge branch 'main' into dev 2026-04-03 00:01:49 +08:00
cc
4580cef7f2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 00:01:18 +08:00
cc
6f9c765bab 更新依赖 2026-04-03 00:01:05 +08:00
H3CoF6
5b56b2e0be Merge remote-tracking branch 'upstream/dev' into dev 2026-04-02 23:50:35 +08:00
cc
b0cc811807 Merge pull request #610 from hicccc77/dev
Dev
2026-04-02 23:30:36 +08:00
cc
eb540d5c13 Merge branch 'main' into dev 2026-04-02 23:30:29 +08:00
cc
e308293cf6 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 23:29:50 +08:00
cc
9ed4659c5c 修复依赖问题 2026-04-02 23:28:55 +08:00
cc
f5f2b76914 Merge pull request #609 from hicccc77/dev
Dev
2026-04-02 23:23:07 +08:00
cc
551a065497 Merge branch 'main' into dev 2026-04-02 23:22:59 +08:00
cc
88d7e38d82 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 23:22:16 +08:00
cc
65e6cb22dd 修复下载源问题 2026-04-02 23:22:12 +08:00
cc
689a396f6e Merge pull request #608 from hicccc77/dev
Dev
2026-04-02 23:16:30 +08:00
cc
512ea84850 Merge branch 'main' into dev 2026-04-02 23:16:19 +08:00
cc
1542e583f7 修复了一些问题 2026-04-02 23:15:39 +08:00
cc
c488dcc3c6 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 23:15:20 +08:00
cc
1594e0e24b 修复安装问题 2026-04-02 23:15:17 +08:00
cc
1e1fa77621 Merge pull request #607 from hicccc77/dev
Dev
2026-04-02 23:04:50 +08:00
cc
3270c17514 Merge branch 'main' into dev 2026-04-02 23:04:28 +08:00
cc
e635942e3d 补充更新 2026-04-02 23:03:50 +08:00
cc
64dc2858a7 补充更新 2026-04-02 23:02:36 +08:00
cc
d05496bb3d 补充更新 2026-04-02 23:02:24 +08:00
cc
bb94553fff 补充更新 2026-04-02 23:02:15 +08:00
cc
113216b7ba 修复打包问题 2026-04-02 23:01:54 +08:00
cc
55181edaa8 Merge pull request #599 from hicccc77/dependabot/npm_and_yarn/dev/lucide-react-1.7.0
chore(deps): bump lucide-react from 0.562.0 to 1.7.0
2026-04-02 23:01:20 +08:00
cc
4f01a7b577 Merge branch 'dev' into dependabot/npm_and_yarn/dev/lucide-react-1.7.0 2026-04-02 23:01:08 +08:00
cc
ea21111037 Merge pull request #600 from hicccc77/dependabot/npm_and_yarn/dev/typescript-6.0.2
chore(deps-dev): bump typescript from 5.9.3 to 6.0.2
2026-04-02 23:00:57 +08:00
dependabot[bot]
fba9f1de42 chore(deps): bump lucide-react from 0.562.0 to 1.7.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.562.0 to 1.7.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.7.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:53:21 +00:00
dependabot[bot]
7d0b8db7a6 chore(deps-dev): bump typescript from 5.9.3 to 6.0.2
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:53:14 +00:00
cc
12faa31e34 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:51:48 +08:00
cc
74945b1752 修复了一些打包问题 2026-04-02 22:51:44 +08:00
cc
a7c66517d2 Merge pull request #606 from hicccc77/dev
Dev
2026-04-02 22:42:32 +08:00
cc
adf187ddf5 Merge branch 'main' into dev 2026-04-02 22:42:13 +08:00
cc
614d897dd2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:41:42 +08:00
cc
ec9a7d68e6 修复了一些问题 2026-04-02 22:41:36 +08:00
cc
79dd91b270 Merge pull request #605 from hicccc77/dev
Dev
2026-04-02 22:31:26 +08:00
cc
b26bcd7603 Merge branch 'main' into dev 2026-04-02 22:31:25 +08:00
cc
a65468191b Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:30:53 +08:00
cc
4ed5271703 修复管理器问题 2026-04-02 22:30:49 +08:00
cc
f2afe2a977 Merge pull request #604 from hicccc77/dev
Dev
2026-04-02 22:28:35 +08:00
cc
430b0f30c9 Merge branch 'main' into dev 2026-04-02 22:28:24 +08:00
cc
8aac9a795e 修复检测问题与依赖问题 2026-04-02 22:27:45 +08:00
cc
b9405765f9 Merge pull request #601 from hicccc77/dependabot/npm_and_yarn/dev/echarts-6.0.0
chore(deps): bump echarts from 5.6.0 to 6.0.0
2026-04-02 22:25:00 +08:00
cc
90856b3812 Merge pull request #602 from hicccc77/dependabot/npm_and_yarn/dev/sass-1.98.0
chore(deps-dev): bump sass from 1.97.2 to 1.98.0
2026-04-02 22:24:49 +08:00
cc
1e78af3c25 Merge pull request #603 from hicccc77/dependabot/npm_and_yarn/dev/vite-8.0.3
chore(deps-dev): bump vite from 6.4.1 to 8.0.3
2026-04-02 22:24:38 +08:00
dependabot[bot]
4be232b951 chore(deps-dev): bump vite from 6.4.1 to 8.0.3
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 8.0.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:22:00 +00:00
dependabot[bot]
59d5c2762d chore(deps-dev): bump sass from 1.97.2 to 1.98.0
Bumps [sass](https://github.com/sass/dart-sass) from 1.97.2 to 1.98.0.
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.97.2...1.98.0)

---
updated-dependencies:
- dependency-name: sass
  dependency-version: 1.98.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:21:28 +00:00
dependabot[bot]
21a55439ec chore(deps): bump echarts from 5.6.0 to 6.0.0
Bumps [echarts](https://github.com/apache/echarts) from 5.6.0 to 6.0.0.
- [Release notes](https://github.com/apache/echarts/releases)
- [Commits](https://github.com/apache/echarts/compare/5.6.0...6.0.0)

---
updated-dependencies:
- dependency-name: echarts
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:21:19 +00:00
cc
c4a35f5c15 Merge pull request #598 from hicccc77/dev
Dev
2026-04-02 22:20:04 +08:00
cc
b42f761011 配置更新 2026-04-02 22:18:50 +08:00
cc
46e2e64e65 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:14:52 +08:00
cc
54ed35ffb3 新增渠道更新 2026-04-02 22:14:46 +08:00
cc
7b8bd747ad Merge branch 'dev' into dev 2026-04-02 21:39:55 +08:00
cc
3e379957e1 Merge pull request #596 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-282a1442c2
chore(deps-dev): bump @xmldom/xmldom from 0.8.11 to 0.8.12 in the npm_and_yarn group across 1 directory
2026-04-02 20:53:57 +08:00
dependabot[bot]
b64525487e chore(deps-dev): bump @xmldom/xmldom
Bumps the npm_and_yarn group with 1 update in the / directory: [@xmldom/xmldom](https://github.com/xmldom/xmldom).


Updates `@xmldom/xmldom` from 0.8.11 to 0.8.12
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.11...0.8.12)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 05:11:30 +00:00
hicccc77
e544f5c862 fix: add forceReopen retry logic for openMessageCursorLite 2026-04-01 04:04:53 +08:00
hicccc77
669759c52e chore: 更新资源文件 2026-03-31 21:36:53 +08:00
cc
4a13d3209b 更新 2026-03-31 21:24:45 +08:00
cc
be069e9aed 实现 #584 2026-03-31 21:24:31 +08:00
hicccc77
0b20ee1aa2 chore: 更新资源文件 2026-03-31 20:02:07 +08:00
hejk
e4872a78f5 feat(http): change default API host to 0.0.0.0 for external access
- 修改 httpApiHost 默认值从 127.0.0.1 到 0.0.0.0
- 允许 HTTP API 服务接受外网访问
- 用户需要配置 httpApiToken 以确保安全

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:32:46 +08:00
H3CoF6
4692216325 Merge remote-tracking branch 'upstream/dev' into dev 2026-03-31 00:18:34 +08:00
hicccc77
69128062fe chore: 更新资源文件 2026-03-30 23:56:34 +08:00
H3CoF6
f81ba3028d Merge remote-tracking branch 'upstream/dev' into dev 2026-03-30 20:36:36 +08:00
H3CoF6
73a948c528 feat: 初步实现服务号/公众号解析 2026-03-30 20:36:20 +08:00
hicccc77
6d652130e6 chore: 更新资源文件 2026-03-30 20:10:35 +08:00
hicccc77
9e6f8077f7 fix: 数据匿名收集受 analyticsConsent 开关控制 (#589)
- 新增 analyticsConsent state 追踪用户同意状态
- initCloudControl() 仅在用户明确同意后执行
- recordPage() 同样受 analyticsConsent 守卫
- handleAnalyticsAllow 同步更新 state,用户同意后立即生效

Fixes #589
2026-03-30 12:05:41 +08:00
hicccc77
40342ca824 fix(deps): 修复 npm install postinstall 阶段 ajv-keywords 兼容性错误
将 npm overrides 中的 ajv 版本范围从 >=6.14.0 改为 ^6.14.0,
确保 electron-builder 依赖链使用 ajv v6,避免在 Node.js v22 上
@develar/schema-utils 加载 ajv-keywords 时访问 formats 返回 undefined 的问题。

Fixes #588
2026-03-30 11:04:44 +08:00
hejinkang
71238d4a01 Merge branch 'dev' into dev 2026-03-30 10:04:56 +08:00
hicccc77
4da9f1e6cf feat: 添加 anti-spam workflow,自动检测并关闭垃圾 issue 2026-03-29 23:19:55 +08:00
hicccc77
93b55fe370 feat: 添加 anti-spam workflow,自动检测并关闭垃圾 issue 2026-03-29 23:18:24 +08:00
hicccc77
ee5e7d2586 fix: 修复微信重装后 openMessageCursor 返回 -3 (no message db) 的问题
- 新增 forceReopen() 方法:清空路径缓存后强制重新初始化账号连接
- openMessageCursor 在 result=-3 时自动触发 forceReopen 并重试一次
- 改善 -3 错误的提示文案,引导用户重新指定数据目录

修复 #591
2026-03-29 19:18:20 +08:00
hicccc77
4f4e09c3de fix: 修复微信重装后 openMessageCursor 返回 -3 (no message db) 的问题
- 新增 forceReopen() 方法:清空路径缓存后强制重新初始化账号连接
- openMessageCursor 在 result=-3 时自动触发 forceReopen 并重试一次
- 改善 -3 错误的提示文案,引导用户重新指定数据目录

修复 #591
2026-03-29 16:07:09 +08:00
hicccc77
d537d81f1c fix(deps): 修复安全漏洞 2026-03-28 21:15:14 +08:00
hicccc77
26c6700152 fix: 修复 CodeQL code scanning warning 问题 2026-03-28 21:12:29 +08:00
hicccc77
49fb96d7a3 Revert "Revert "fix(deps): 修复安全漏洞""
This reverts commit d256ee5696.
2026-03-28 19:29:17 +08:00
hicccc77
d256ee5696 Revert "fix(deps): 修复安全漏洞"
This reverts commit 06079659af.
2026-03-28 19:28:45 +08:00
hicccc77
bd70a7bfa8 Revert "fix: 修复 Linux 下内存扫描找不到微信进程的问题\n\n增加 pidof/pgrep/ps aux 三重兜底逻辑,兼容不同发行版\n(flatpak、AppImage、wechat-bin 等安装方式),解决 #575"
This reverts commit 3fb09bad0d.
2026-03-28 19:28:45 +08:00
hicccc77
3fb09bad0d fix: 修复 Linux 下内存扫描找不到微信进程的问题\n\n增加 pidof/pgrep/ps aux 三重兜底逻辑,兼容不同发行版\n(flatpak、AppImage、wechat-bin 等安装方式),解决 #575 2026-03-28 19:05:21 +08:00
hicccc77
06079659af fix(deps): 修复安全漏洞 2026-03-28 17:36:28 +08:00
hicccc77
22d8049c2c Revert "fix: 兼容微信新目录结构多一层嵌套导致账号目录识别失败的问题"
This reverts commit 5f6b0e8960.
2026-03-28 17:30:56 +08:00
hicccc77
5f6b0e8960 fix: 兼容微信新目录结构多一层嵌套导致账号目录识别失败的问题
修复 scanWxids 和 scanWxidCandidates 在 2.0b4.0.9/xwechat_files/wxid_xxx
结构下扫描不到账号目录的问题,增加往下多扫一层的兜底逻辑

Fixes #541
2026-03-28 17:28:52 +08:00
hicccc77
9b8da7774d fix: 替换失效的 downloads badge 为 shields.io 2026-03-28 17:05:33 +08:00
hicccc77
eabed55a7a fix: 修复 README Downloads badge 嵌套在 Issues 链接内的问题 2026-03-28 17:03:28 +08:00
hicccc77
32cc74f99c merge: 同步 main 最新代码到 dev(依赖更新、版本 4.3.0、资源文件) 2026-03-28 16:54:47 +08:00
cc
ffc4cc3d96 Merge pull request #574 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-8abc9b7730
chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates
2026-03-28 16:50:28 +08:00
dependabot[bot]
007cf57efd chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates
Bumps the npm_and_yarn group with 3 updates in the / directory: [minimatch](https://github.com/isaacs/minimatch), [brace-expansion](https://github.com/juliangruber/brace-expansion) and [rollup](https://github.com/rollup/rollup).


Updates `minimatch` from 3.1.2 to 3.1.5
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

Updates `brace-expansion` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

Updates `rollup` from 4.55.1 to 4.60.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.55.1...v4.60.0)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: rollup
  dependency-version: 4.60.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 07:56:41 +00:00
hicccc77
c6dba71197 fix: 修复 macOS release notes 中 xattr 命令被 bash 吞掉的问题 2026-03-28 15:55:11 +08:00
cc
8aa162e294 Merge pull request #568 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-1ca40131d0
chore(deps): bump @tootallnate/once from 2.0.0 to removed in the npm_and_yarn group across 1 directory
2026-03-28 14:51:11 +08:00
dependabot[bot]
51d6dec7ff chore(deps): bump @tootallnate/once
Bumps the npm_and_yarn group with 1 update in the / directory: [@tootallnate/once](https://github.com/TooTallNate/once).


Removes `@tootallnate/once`

---
updated-dependencies:
- dependency-name: "@tootallnate/once"
  dependency-version: 
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 22:39:12 +00:00
hicccc77
f1b2762769 fix(deps): 修复安全漏洞 2026-03-28 06:37:44 +08:00
hicccc77
d126be2aa5 chore: 更新资源文件 2026-03-27 22:03:27 +08:00
hicccc77
ea034ee76a ci: fix pnpm audit exit code causing job failure 2026-03-27 21:43:16 +08:00
hicccc77
39634a690c fix(deps): remove ajv override to fix electron-builder compatibility 2026-03-27 21:09:28 +08:00
hicccc77
a7001eb6da fix(deps): upgrade react-router-dom to 7.13.2 and add pnpm overrides for security vulnerabilities
- Upgrade react-router-dom ^7.1.1 -> ^7.13.2
- Add pnpm.overrides to force safe versions of: tar, minimatch, rollup,
  immutable, lodash, ajv, brace-expansion, picomatch
2026-03-27 21:04:44 +08:00
hicccc77
71e3540f18 ci: add gitleaks config to suppress false positives 2026-03-27 20:58:14 +08:00
cc
4ea4020faa Merge branch 'dev' into dev 2026-03-27 20:53:56 +08:00
hicccc77
78cadfd352 ci: add security-events write permission for CodeQL 2026-03-27 19:12:31 +08:00
hicccc77
da15f829d3 ci: add security-events write permission for CodeQL 2026-03-27 19:12:20 +08:00
hicccc77
bb60694013 ci: fix pnpm install frozen-lockfile issue 2026-03-27 18:17:26 +08:00
hicccc77
b3758d2baf ci: fix pnpm install frozen-lockfile issue 2026-03-27 18:17:14 +08:00
hicccc77
bc794e9a44 ci: add daily security scan workflow for all branches 2026-03-27 17:59:09 +08:00
hicccc77
c80115d0f7 ci: add daily security scan workflow for all branches 2026-03-27 17:56:35 +08:00
xuncha
6277576249 Merge pull request #560 from JiQingzhe2004/main
feat: 强制更新支持 minimumVersion,阻止低版本用户继续使用
2026-03-27 15:23:02 +08:00
JiQingzhe2004
2201d369fa chore: bump version to 4.3.0 2026-03-27 14:43:33 +08:00
JiQingzhe2004
9f4e4790f5 feat: 强制更新支持 minimumVersion,阻止低版本用户继续使用 2026-03-27 14:43:08 +08:00
xuncha
501e373e38 Merge pull request #559 from xunchahaha/main
更新打包
2026-03-27 13:10:50 +08:00
xuncha
b2cf7c92d5 更新打包 2026-03-27 13:10:27 +08:00
xuncha
e92e13c045 Merge pull request #558 from hicccc77/xunchahaha-patch-1
Delete preinstall.js
2026-03-27 12:59:45 +08:00
xuncha
f3dec958b0 Delete preinstall.js 2026-03-27 12:48:42 +08:00
hejinkang
c88a1c5848 Merge branch 'hicccc77:dev' into dev 2026-03-27 10:08:08 +08:00
hicccc77
0cf8ea8166 chore: bump version to 4.2.0 2026-03-26 23:20:42 +08:00
hicccc77
74b830dd79 chore: update service files and xkey_helper
- xkey_helper: use mach exception port to intercept EXC_BREAKPOINT,
  fixes key capture failure on macOS 26.2
2026-03-26 23:19:08 +08:00
cc
8668c168a7 333 2026-03-26 22:43:59 +08:00
cc
8b8c5f33ce 333 2026-03-26 22:34:50 +08:00
cc
2fcbb026df 222 2026-03-26 22:32:33 +08:00
cc
66ee72380d 222 2026-03-26 22:30:21 +08:00
cc
4f16345351 111 2026-03-26 22:26:33 +08:00
cc
5110618996 再次修复 2026-03-26 22:19:30 +08:00
cc
bf51368cf4 修复密钥问题 2026-03-26 22:16:30 +08:00
cc
d6054745d6 修复macos打包错误 2026-03-26 22:00:42 +08:00
hicccc77
a4731f25f8 chore: update xkey_helper (macOS) with pure semantic scan mode
Always use pure semantic scan mode (KNOWN_RVA=0) regardless of
WeChat version, improving compatibility for versions < 4.1.8.
2026-03-26 21:16:14 +08:00
hicccc77
6c4507e495 fix(ci): remove invalid --no-fail-on-no-release flag from gh release edit 2026-03-26 20:33:18 +08:00
hejk
0162769d22 fix(sns): fallback usernames from timeline when SQL result is empty 2026-03-26 19:38:06 +08:00
hejk
fa55755921 feat(http): add sns HTTP API endpoints 2026-03-26 19:36:19 +08:00
cc
cfa335564a Merge pull request #549 from hicccc77/dev
Dev
2026-03-25 20:02:58 +08:00
Forrest
61ef10de9b Merge pull request #545 from JiQingzhe2004/main
更新图标
2026-03-25 02:09:50 +08:00
Forrest
73f36d6b29 更新图标 2026-03-25 01:36:04 +08:00
Forrest
666a1a3296 Merge branch 'hicccc77:main' into main 2026-03-25 00:18:12 +08:00
xuncha
b5a371da87 Merge pull request #349 from hicccc77/dev
Dev
2026-03-13 08:55:32 +03:00
60 changed files with 6245 additions and 2743 deletions

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
target-branch: "dev"

134
.github/workflows/anti-spam.yml vendored Normal file
View File

@@ -0,0 +1,134 @@
name: Anti-Spam
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
check-spam:
runs-on: ubuntu-latest
steps:
- name: Check for spam
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const text = title + ' ' + body;
// 博彩/赌球类
const gamblingPatterns = [
/世界杯.*买球/, /买球.*世界杯/,
/世界杯.*下注/, /世界杯.*竞猜/,
/世界杯.*投注/, /世界杯.*押注/,
/世界杯.*彩票/, /世界杯.*平台/,
/世界杯.*app/, /世界杯.*软件/,
/世界杯.*网站/, /世界杯.*网址/,
/足球.*买球/, /买球.*足球/,
/足球.*投注/, /足球.*押注/,
/足球.*竞猜/, /足球.*平台/,
/篮球.*买球/, /篮球.*投注/,
/体育.*投注/, /体育.*竞猜/,
/体育.*买球/, /体育.*押注/,
/赌球/, /赌博.*网站/, /赌博.*平台/,
/博彩/, /博彩.*网站/, /博彩.*平台/,
/正规.*买球/, /官方.*买球/,
/买球.*网站/, /买球.*app/,
/买球.*软件/, /买球.*网址/,
/买球.*平台/, /买球.*技巧/,
/投注.*网站/, /投注.*平台/,
/押注.*网站/, /押注.*平台/,
/竞猜.*网站/, /竞猜.*平台/,
/彩票.*网站/, /彩票.*平台/,
/欧洲杯.*买球/, /欧冠.*买球/,
/nba.*买球/, /nba.*投注/,
];
// 色情/交友类
const adultPatterns = [
/约炮/, /一夜情/, /外围/,
/包养/, /援交/, /陪聊/,
/成人.*网站/, /成人.*视频/,
/av.*网站/, /黄色.*网站/,
];
// 贷款/金融诈骗类
const financePatterns = [
/秒到账.*贷款/, /无抵押.*贷款/,
/征信.*贷款/, /黑户.*贷款/,
/快速.*放款/, /私人.*放贷/,
/刷单/, /兼职.*日入/, /兼职.*月入/,
/网赚/, /躺赚/, /被动收入.*平台/,
/虚拟货币.*投资/, /usdt.*投资/,
/炒币.*平台/, /数字货币.*平台/,
];
// 垃圾推广类
const spamPromoPatterns = [
/代刷/, /粉丝.*购买/, /涨粉/,
/seo.*优化/, /快速排名/,
/微商/, /代理.*招募/,
];
// 账号特征检测(新账号 + 无 contribution
const allPatterns = [
...gamblingPatterns,
...adultPatterns,
...financePatterns,
...spamPromoPatterns,
];
const isSpam = allPatterns.some(pattern => pattern.test(text));
// 额外检测:标题超短且含可疑关键词(常见于批量刷单)
const suspiciousShort = title.length < 10 && /(买球|投注|博彩|赌博|下注|押注)/.test(title);
if (isSpam || suspiciousShort) {
// 确保 spam label 存在
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'spam',
color: 'e4e669',
description: 'Spam issue'
});
} catch (e) {
// label 已存在,忽略
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: '此 issue 已被自动识别为垃圾内容并关闭。\n\nThis issue has been automatically identified as spam and closed.'
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['spam']
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned'
});
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
lock_reason: 'spam'
});
console.log(`Closed spam issue #${issue.number}: ${issue.title}`);
}

327
.github/workflows/dev-daily-fixed.yml vendored Normal file
View File

@@ -0,0 +1,327 @@
name: Dev Daily
on:
schedule:
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
- cron: "0 16 * * *"
workflow_dispatch:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
FIXED_DEV_TAG: nightly-dev
TARGET_BRANCH: dev
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
dev_version: ${{ steps.meta.outputs.dev_version }}
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: "npm"
- name: Generate daily dev version
id: meta
shell: bash
run: |
set -euo pipefail
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
MONTH="$(TZ=Asia/Shanghai date +%-m)"
DAY="$(TZ=Asia/Shanghai date +%-d)"
DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}"
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
echo "Dev version: $DEV_VERSION"
- name: Ensure fixed prerelease exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --prerelease
else
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease
fi
dev-mac-arm64:
needs: prepare
runs-on: macos-14
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package macOS arm64 dev artifacts
shell: bash
run: |
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
- name: Upload macOS arm64 assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
dev-linux:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package Linux dev artifacts
run: |
npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}'
- name: Upload Linux assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
dev-win-x64:
needs: prepare
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package Windows x64 dev artifacts
run: |
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}'
- name: Upload Windows x64 assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
dev-win-arm64:
needs: prepare
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package Windows arm64 dev artifacts
run: |
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}'
- name: Upload Windows arm64 assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
update-dev-release-notes:
needs:
- prepare
- dev-mac-arm64
- dev-linux
- dev-win-x64
- dev-win-arm64
if: always() && needs.prepare.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Update fixed dev release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }}
shell: bash
run: |
set -euo pipefail
TAG="$FIXED_DEV_TAG"
REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
echo "Release $TAG not found, skip notes update."
exit 0
fi
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 "dev-x64-Setup[.]exe$")"
WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")"
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")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
cat > dev_release_notes.md <<EOF
## Daily Dev Build
- 该发布页为 **开发版**。
- 当前构建版本:\`${{ needs.prepare.outputs.dev_version }}\`
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
## 下载
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSApple Silicon: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。
## 说明
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
EOF
gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md

View File

@@ -0,0 +1,368 @@
name: Preview Nightly
on:
schedule:
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
- cron: "0 16 * * *"
workflow_dispatch:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
FIXED_PREVIEW_TAG: nightly-preview
TARGET_BRANCH: main
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.meta.outputs.should_build }}
preview_version: ${{ steps.meta.outputs.preview_version }}
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: "npm"
- name: Decide whether to build and generate preview version
id: meta
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
git fetch origin main --depth=1
COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
SHOULD_BUILD=true
elif [ "$COMMITS_24H" -gt 0 ]; then
SHOULD_BUILD=true
else
SHOULD_BUILD=false
fi
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
YEARLY_RUN_COUNT=1
LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)"
if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then
LAST_YEAR="${BASH_REMATCH[1]}"
LAST_COUNT="${BASH_REMATCH[2]}"
if [ "$LAST_YEAR" = "$YEAR_2" ]; then
YEARLY_RUN_COUNT=$((LAST_COUNT + 1))
fi
fi
PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}"
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)"
- name: Ensure fixed preview prerelease exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --prerelease
else
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease
fi
preview-mac-arm64:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: macos-14
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package macOS arm64 preview artifacts
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
shell: bash
run: |
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
- name: Upload macOS arm64 assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
preview-linux:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package Linux preview artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}'
- name: Upload Linux assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
preview-win-x64:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package Windows x64 preview artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}'
- name: Upload Windows x64 assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
preview-win-arm64:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package Windows arm64 preview artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}'
- name: Upload Windows arm64 assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
update-preview-release-notes:
needs:
- prepare
- preview-mac-arm64
- preview-linux
- preview-win-x64
- preview-win-arm64
if: needs.prepare.outputs.should_build == 'true' && always()
runs-on: ubuntu-latest
steps:
- name: Update preview release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="$FIXED_PREVIEW_TAG"
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
exit 0
fi
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 "x64.*[.]exe$")"
if [ -z "$WINDOWS_ASSET" ]; then
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("[.]exe$")) | select(test("arm64") | not)][0] // ""')"
fi
WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")"
MAC_ASSET="$(pick_asset "[.]dmg$")"
LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")"
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")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
cat > preview_release_notes.md <<EOF
## Preview Nightly 说明
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
- 可能包含尚未完全稳定的改动,不建议长期使用
- 当前版本号:\`$CURRENT_PREVIEW_VERSION\`
## 下载
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSApple Silicon: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
EOF
gh release edit "$TAG" --repo "$REPO" --notes-file preview_release_notes.md

View File

@@ -10,29 +10,11 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
jobs:
prepare-release:
runs-on: ubuntu-latest
steps:
- name: Mark release as pre-release (building)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="$GITHUB_REF_NAME"
REPO="$GITHUB_REPOSITORY"
# Create or update the release as a pre-release with a placeholder note
if gh release view "$TAG" --repo "$REPO" > /dev/null 2>&1; then
gh release edit "$TAG" --repo "$REPO" --prerelease --notes $'## ⚠️ 正在自动构建中,请勿下载\n\n各平台安装包正在构建完成后将自动更新本页面并正式发布。\n\n**请勿在此期间下载任何文件。**'
else
gh release create "$TAG" --repo "$REPO" --prerelease --title "$TAG" --notes $'## ⚠️ 正在自动构建中,请勿下载\n\n各平台安装包正在构建完成后将自动更新本页面并正式发布。\n\n**请勿在此期间下载任何文件。**'
fi
release-mac-arm64:
runs-on: macos-14
needs: prepare-release
steps:
- name: Check out git repository
@@ -65,12 +47,30 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
shell: bash
run: |
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
npx electron-builder --mac dmg --arm64 --publish always
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do
gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE" 2>/dev/null || continue
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
fi
gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
done
release-linux:
runs-on: ubuntu-latest
needs: prepare-release
steps:
- name: Check out git repository
@@ -105,9 +105,22 @@ jobs:
run: |
npx electron-builder --linux --publish always
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
fi
release:
runs-on: windows-latest
needs: prepare-release
steps:
- name: Check out git repository
@@ -137,15 +150,27 @@ jobs:
npx vite build
- name: Package and Publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --win nsis --x64 --publish always "-c.artifactName=\${productName}-\${version}-x64-Setup.\${ext}"
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
fi
release-windows-arm64:
runs-on: windows-latest
needs: prepare-release
steps:
- name: Check out git repository
@@ -175,11 +200,24 @@ jobs:
npx vite build
- name: Package and Publish Windows arm64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --win nsis --arm64 --publish always -c.publish.channel=latest-arm64 "-c.artifactName=\${productName}-\${version}-arm64-Setup.\${ext}"
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
fi
update-release-notes:
runs-on: ubuntu-latest
@@ -190,53 +228,6 @@ jobs:
- release-windows-arm64
steps:
- name: Fix latest.yml to point to x64 installer
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="$GITHUB_REF_NAME"
VERSION="${TAG#v}"
REPO="$GITHUB_REPOSITORY"
# Find the x64 exe asset name
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
X64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')"
if [ -z "$X64_ASSET" ]; then
X64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
fi
if [ -z "$X64_ASSET" ]; then
echo "ERROR: Could not find x64 exe asset"
exit 1
fi
echo "Downloading x64 installer: $X64_ASSET"
gh release download "$TAG" --repo "$REPO" --pattern "$X64_ASSET" --dir /tmp/weflow-x64
SHA512_B64="$(sha512sum "/tmp/weflow-x64/$X64_ASSET" | awk '{print $1}' | xxd -r -p | base64 -w 0)"
SIZE="$(stat -c%s "/tmp/weflow-x64/$X64_ASSET")"
RELEASE_DATE="$(gh release view "$TAG" --repo "$REPO" --json publishedAt -q .publishedAt)"
cat > /tmp/latest.yml <<YMLEOF
version: $VERSION
files:
- url: $X64_ASSET
sha512: $SHA512_B64
size: $SIZE
path: $X64_ASSET
sha512: $SHA512_B64
releaseDate: '$RELEASE_DATE'
YMLEOF
# Strip leading spaces (heredoc indentation)
sed -i 's/^ //' /tmp/latest.yml
cat /tmp/latest.yml
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
echo "latest.yml updated successfully to point to $X64_ASSET"
- name: Generate release notes with platform download links
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -285,27 +276,18 @@ jobs:
[点击加入 Telegram 频道](https://t.me/weflow_cc)
## 下载
- Windows x64Win10+: ${WINDOWS_URL:-$RELEASE_PAGE}
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
- macOSM系列芯片: ${MAC_URL:-$RELEASE_PAGE}
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
- Windows x64Win10+: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSM系列芯片: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示(未知来源)
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
- xattr -rd com.apple.quarantine /Applications/WeFlow.app
## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
EOF
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
- name: Mark release as published (no longer pre-release)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --latest --no-fail-on-no-release --draft=false --prerelease=false

87
.github/workflows/security-scan.yml vendored Normal file
View File

@@ -0,0 +1,87 @@
name: Security Scan
on:
schedule:
- cron: '0 2 * * *' # 每天 UTC 02:00
workflow_dispatch: # 手动触发
pull_request: # PR 时触发
branches: [ main, dev ]
permissions:
contents: read
security-events: write
actions: read
jobs:
security-scan:
name: Security Scan (${{ matrix.branch }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
branch:
- main
steps:
- name: Checkout ${{ matrix.branch }}
uses: actions/checkout@v4
with:
ref: ${{ matrix.branch }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # 使用 npm 缓存加速
- name: Install dependencies
run: npm ci --ignore-scripts
# 1. npm audit - 检查依赖漏洞
- name: Dependency vulnerability audit
run: npm audit --audit-level=moderate
continue-on-error: true
# 2. CodeQL 静态分析
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript, typescript
queries: security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: '/language:javascript-typescript/branch:${{ matrix.branch }}'
# 3. 密钥/敏感信息扫描
- name: Secret scanning with Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
# 动态获取所有分支并扫描
scan-all-branches:
name: Scan additional branches
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run npm audit on all branches
run: |
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
echo "===== Auditing branch: $branch ====="
git checkout "$branch" 2>/dev/null || continue
# 尝试安装并审计
npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true
npm audit --audit-level=moderate 2>/dev/null || true
done
continue-on-error: true

1
.gitignore vendored
View File

@@ -71,3 +71,4 @@ resources/wx_send
pnpm-lock.yaml
/pnpm-workspace.yaml
wechat-research-site
.codex

23
.gitleaks.toml Normal file
View File

@@ -0,0 +1,23 @@
title = "Gitleaks Config"
[extend]
# 继承默认规则
useDefault = true
# 排除误报路径
[[rules]]
id = "curl-auth-header"
[rules.allowlist]
paths = [
'''docs/HTTP-API\.md'''
]
regexes = [
'''YOUR_TOKEN'''
]
[[rules]]
id = "generic-api-key"
[rules.allowlist]
paths = [
'''src/pages/ChatPage\.tsx'''
]

4
.npmrc
View File

@@ -1,3 +1 @@
registry=https://registry.npmmirror.com
electron-mirror=https://npmmirror.com/mirrors/electron/
electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
registry=https://registry.npmjs.org

View File

@@ -19,7 +19,9 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
</a>
<a href="https://github.com/hicccc77/WeFlow/issues">
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
</a>
<a href="https://github.com/hicccc77/WeFlow/releases">
<img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat-square" alt="Downloads" />
</a>
<a href="https://t.me/weflow_cc">
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">

View File

@@ -433,7 +433,123 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
---
## 7. 访问导出媒体
## 7. 朋友圈接口
### 7.1 获取朋友圈时间线
```http
GET /api/v1/sns/timeline
```
参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `limit` | number | 否 | 返回数量,默认 20范围 `1~200` |
| `offset` | number | 否 | 偏移量,默认 0 |
| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` |
| `keyword` | string | 否 | 关键词过滤(正文) |
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` |
| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` |
| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL默认 `0` |
示例:
```bash
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5"
curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行"
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1"
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1"
```
媒体字段说明(`media=1`
- `media[].url/thumb`:你应该优先直接使用的字段。
- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`
- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。
- `media[].rawUrl/rawThumb`:原始朋友圈地址
- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址
- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL
- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url``key` 原样传回即可。
- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。
- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。
### 7.2 获取朋友圈发布者
```http
GET /api/v1/sns/usernames
```
### 7.3 获取朋友圈导出统计
```http
GET /api/v1/sns/export/stats
```
参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
### 7.4 朋友圈媒体代理
```http
GET /api/v1/sns/media/proxy
```
参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `url` | string | 是 | 媒体原始 URL |
| `key` | string/number | 否 | 解密 key部分资源需要 |
### 7.5 导出朋友圈
```http
POST /api/v1/sns/export
Content-Type: application/json
```
Body 示例:
```json
{
"outputDir": "C:\\Users\\Alice\\Desktop\\sns-export",
"format": "json",
"usernames": "wxid_a,wxid_b",
"keyword": "旅行",
"exportMedia": true,
"exportImages": true,
"exportLivePhotos": true,
"exportVideos": true,
"start": "20250101",
"end": "20251231"
}
```
`format` 支持:`json``html``arkmejson`(兼容写法:`arkme-json`)。
### 7.6 朋友圈防删开关
```http
GET /api/v1/sns/block-delete/status
POST /api/v1/sns/block-delete/install
POST /api/v1/sns/block-delete/uninstall
```
### 7.7 删除单条朋友圈
```http
DELETE /api/v1/sns/post/{postId}
```
---
## 8. 访问导出媒体
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
@@ -476,7 +592,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
---
## 8. 使用示例
## 9. 使用示例
### PowerShell
@@ -525,7 +641,7 @@ members = requests.get(
---
## 9. 注意事项
## 10. 注意事项
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
2. 使用前需要先在 WeFlow 中完成数据库连接。

View File

@@ -30,21 +30,259 @@ import { cloudControlService } from './services/cloudControlService'
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
import { httpService } from './services/httpService'
import { messagePushService } from './services/messagePushService'
import { bizService } from './services/bizService'
// 配置自动更新
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
// Windows x64 与 arm64 使用不同更新通道,避免 latest.yml 互相覆盖导致下错架构安装包。
if (process.platform === 'win32' && process.arch === 'arm64') {
autoUpdater.channel = 'latest-arm64'
// 更新通道策略:
// - 稳定版(如 4.3.0)默认走 latest
// - 预览版(如 0.26.2)默认走 preview0.年.当年发布序号)
// - 开发版(如 26.4.5)默认走 dev年.月.日)
// - 用户可在设置页切换稳定/预览/开发,切换后即时生效
// 同时区分 Windows x64 / arm64避免更新清单互相覆盖。
const appVersion = app.getVersion()
const inferUpdateTrackFromVersion = (version: string): 'stable' | 'preview' | 'dev' => {
const normalized = String(version || '').trim().replace(/^v/i, '')
if (/^0\.\d{2}\.\d+$/i.test(normalized)) return 'preview'
if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(normalized)) return 'dev'
// 兼容旧版命名(如 4.3.0-preview.26.1 / 4.3.0-dev.26.3.4
if (/-preview\.\d+\.\d+$/i.test(normalized)) return 'preview'
if (/-dev\.\d+\.\d+\.\d+$/i.test(normalized)) return 'dev'
// 兼容 alpha/beta/rc 预发布
if (/(alpha|beta|rc)/i.test(normalized)) return 'dev'
return 'stable'
}
const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => {
const inferred = inferUpdateTrackFromVersion(appVersion)
if (inferred === 'preview' || inferred === 'dev') return inferred
return 'stable'
})()
let configService: ConfigService | null = null
const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw
return null
}
const getEffectiveUpdateTrack = (): 'stable' | 'preview' | 'dev' => {
const configuredTrack = normalizeUpdateTrack(configService?.get('updateChannel'))
return configuredTrack || defaultUpdateTrack
}
const isRemoteVersionNewer = (latestVersion: string, currentVersion: string): boolean => {
const latest = String(latestVersion || '').trim()
const current = String(currentVersion || '').trim()
if (!latest || !current) return false
const parseVersion = (version: string) => {
const normalized = version.replace(/^v/i, '')
const [main, pre = ''] = normalized.split('-', 2)
const core = main.split('.').map((segment) => Number.parseInt(segment, 10) || 0)
const prerelease = pre ? pre.split('.').map((segment) => /^\d+$/.test(segment) ? Number.parseInt(segment, 10) : segment) : []
return { core, prerelease }
}
const compareParsedVersion = (a: ReturnType<typeof parseVersion>, b: ReturnType<typeof parseVersion>): number => {
const maxLen = Math.max(a.core.length, b.core.length)
for (let i = 0; i < maxLen; i += 1) {
const left = a.core[i] || 0
const right = b.core[i] || 0
if (left > right) return 1
if (left < right) return -1
}
const aPre = a.prerelease
const bPre = b.prerelease
if (aPre.length === 0 && bPre.length === 0) return 0
if (aPre.length === 0) return 1
if (bPre.length === 0) return -1
const preMaxLen = Math.max(aPre.length, bPre.length)
for (let i = 0; i < preMaxLen; i += 1) {
const left = aPre[i]
const right = bPre[i]
if (left === undefined) return -1
if (right === undefined) return 1
if (left === right) continue
const leftNum = typeof left === 'number'
const rightNum = typeof right === 'number'
if (leftNum && rightNum) return left > right ? 1 : -1
if (leftNum) return -1
if (rightNum) return 1
return String(left) > String(right) ? 1 : -1
}
return 0
}
try {
return autoUpdater.currentVersion.compare(latest) < 0
} catch {
return compareParsedVersion(parseVersion(latest), parseVersion(current)) > 0
}
}
const shouldOfferUpdateForTrack = (latestVersion: string, currentVersion: string): boolean => {
if (isRemoteVersionNewer(latestVersion, currentVersion)) return true
const effectiveTrack = getEffectiveUpdateTrack()
const currentTrack = inferUpdateTrackFromVersion(currentVersion)
// 切换通道后,目标通道最新版本与当前版本不同即提示更新(即使是降级)
if (effectiveTrack !== currentTrack && latestVersion !== currentVersion) return true
return false
}
let lastAppliedUpdaterChannel: string | null = null
const resetUpdaterProviderCache = () => {
const updater = autoUpdater as any
// electron-updater 会缓存 provider切换 channel 后需清理缓存,避免仍请求旧通道
for (const key of ['clientPromise', '_clientPromise', 'updateInfoAndProvider']) {
if (Object.prototype.hasOwnProperty.call(updater, key)) {
updater[key] = null
}
}
}
const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
const track = getEffectiveUpdateTrack()
const currentTrack = inferUpdateTrackFromVersion(appVersion)
const baseUpdateChannel = track === 'stable' ? 'latest' : track
const nextUpdaterChannel =
process.platform === 'win32' && process.arch === 'arm64'
? `${baseUpdateChannel}-arm64`
: baseUpdateChannel
if (lastAppliedUpdaterChannel && lastAppliedUpdaterChannel !== nextUpdaterChannel) {
resetUpdaterProviderCache()
}
autoUpdater.allowPrerelease = track !== 'stable'
// 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级)
autoUpdater.allowDowngrade = track !== currentTrack
autoUpdater.channel = nextUpdaterChannel
lastAppliedUpdaterChannel = nextUpdaterChannel
console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel}allowDowngrade=${autoUpdater.allowDowngrade}`)
}
applyAutoUpdateChannel('startup')
const AUTO_UPDATE_ENABLED =
process.env.AUTO_UPDATE_ENABLED === 'true' ||
process.env.AUTO_UPDATE_ENABLED === '1' ||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
const getLaunchAtStartupUnsupportedReason = (): string | null => {
if (process.platform !== 'win32' && process.platform !== 'darwin') {
return '当前平台暂不支持开机自启动'
}
if (!app.isPackaged) {
return '仅安装后的 Windows / macOS 版本支持开机自启动'
}
return null
}
const isLaunchAtStartupSupported = (): boolean => getLaunchAtStartupUnsupportedReason() == null
const getStoredLaunchAtStartupPreference = (): boolean | undefined => {
const value = configService?.get('launchAtStartup')
return typeof value === 'boolean' ? value : undefined
}
const getSystemLaunchAtStartup = (): boolean => {
if (!isLaunchAtStartupSupported()) return false
try {
return app.getLoginItemSettings().openAtLogin === true
} catch (error) {
console.error('[WeFlow] 读取开机自启动状态失败:', error)
return false
}
}
const buildLaunchAtStartupSettings = (enabled: boolean): Parameters<typeof app.setLoginItemSettings>[0] =>
process.platform === 'win32'
? { openAtLogin: enabled, path: process.execPath }
: { openAtLogin: enabled }
const setSystemLaunchAtStartup = (enabled: boolean): { success: boolean; enabled: boolean; error?: string } => {
try {
app.setLoginItemSettings(buildLaunchAtStartupSettings(enabled))
const effectiveEnabled = app.getLoginItemSettings().openAtLogin === true
if (effectiveEnabled !== enabled) {
return {
success: false,
enabled: effectiveEnabled,
error: '系统未接受该开机自启动设置'
}
}
return { success: true, enabled: effectiveEnabled }
} catch (error) {
return {
success: false,
enabled: getSystemLaunchAtStartup(),
error: `设置开机自启动失败: ${String((error as Error)?.message || error)}`
}
}
}
const getLaunchAtStartupStatus = (): { enabled: boolean; supported: boolean; reason?: string } => {
const unsupportedReason = getLaunchAtStartupUnsupportedReason()
if (unsupportedReason) {
return {
enabled: getStoredLaunchAtStartupPreference() === true,
supported: false,
reason: unsupportedReason
}
}
return {
enabled: getSystemLaunchAtStartup(),
supported: true
}
}
const applyLaunchAtStartupPreference = (
enabled: boolean
): { success: boolean; enabled: boolean; supported: boolean; reason?: string; error?: string } => {
const unsupportedReason = getLaunchAtStartupUnsupportedReason()
if (unsupportedReason) {
return {
success: false,
enabled: getStoredLaunchAtStartupPreference() === true,
supported: false,
reason: unsupportedReason
}
}
const result = setSystemLaunchAtStartup(enabled)
configService?.set('launchAtStartup', result.enabled)
return {
...result,
supported: true
}
}
const syncLaunchAtStartupPreference = () => {
if (!configService) return
const unsupportedReason = getLaunchAtStartupUnsupportedReason()
if (unsupportedReason) return
const storedPreference = getStoredLaunchAtStartupPreference()
const systemEnabled = getSystemLaunchAtStartup()
if (typeof storedPreference !== 'boolean') {
configService.set('launchAtStartup', systemEnabled)
return
}
if (storedPreference === systemEnabled) return
const result = setSystemLaunchAtStartup(storedPreference)
configService.set('launchAtStartup', result.enabled)
if (!result.success && result.error) {
console.error('[WeFlow] 同步开机自启动设置失败:', result.error)
}
}
// 使用白名单过滤 PATH避免被第三方目录中的旧版 VC++ 运行库劫持。
// 仅保留系统目录Windows/System32/SysWOW64和应用自身目录可执行目录、resources
function sanitizePathEnv() {
@@ -87,7 +325,6 @@ function sanitizePathEnv() {
sanitizePathEnv()
// 单例服务
let configService: ConfigService | null = null
// 协议窗口实例
let agreementWindow: BrowserWindow | null = null
@@ -243,6 +480,14 @@ const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => {
return cleaned
}
const getDialogReleaseNotes = (rawReleaseNotes: unknown): string => {
const track = getEffectiveUpdateTrack()
if (track !== 'stable') {
return '修复了一些已知问题'
}
return normalizeReleaseNotes(rawReleaseNotes)
}
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
@@ -1110,18 +1355,33 @@ const removeMatchedEntriesInDir = async (
// 注册 IPC 处理器
function registerIpcHandlers() {
registerNotificationHandlers()
bizService.registerHandlers()
// 配置相关
ipcMain.handle('config:get', async (_, key: string) => {
return configService?.get(key as any)
})
ipcMain.handle('config:set', async (_, key: string, value: any) => {
const result = configService?.set(key as any, value)
let result: unknown
if (key === 'launchAtStartup') {
result = applyLaunchAtStartupPreference(value === true)
} else {
result = configService?.set(key as any, value)
}
if (key === 'updateChannel') {
applyAutoUpdateChannel('settings')
}
void messagePushService.handleConfigChanged(key)
return result
})
ipcMain.handle('config:clear', async () => {
if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) {
const result = setSystemLaunchAtStartup(false)
if (!result.success && result.error) {
console.error('[WeFlow] 清空配置时关闭开机自启动失败:', result.error)
}
}
configService?.clear()
messagePushService.handleConfigCleared()
return true
@@ -1164,6 +1424,14 @@ function registerIpcHandlers() {
return app.getVersion()
})
ipcMain.handle('app:getLaunchAtStartupStatus', async () => {
return getLaunchAtStartupStatus()
})
ipcMain.handle('app:setLaunchAtStartup', async (_, enabled: boolean) => {
return applyLaunchAtStartupPreference(enabled === true)
})
ipcMain.handle('app:checkWayland', async () => {
if (process.platform !== 'linux') return false;
@@ -1233,16 +1501,19 @@ function registerIpcHandlers() {
if (!AUTO_UPDATE_ENABLED) {
return { hasUpdate: false }
}
// 每次主动检查前重新应用一次通道配置,确保使用最新选择的更新通道。
applyAutoUpdateChannel('settings')
try {
const result = await autoUpdater.checkForUpdates()
if (result && result.updateInfo) {
const currentVersion = app.getVersion()
const latestVersion = result.updateInfo.version
if (latestVersion !== currentVersion) {
if (shouldOfferUpdateForTrack(latestVersion, currentVersion)) {
return {
hasUpdate: true,
version: latestVersion,
releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
releaseNotes: getDialogReleaseNotes(result.updateInfo.releaseNotes),
minimumVersion: (result.updateInfo as any).minimumVersion
}
}
}
@@ -1300,7 +1571,7 @@ function registerIpcHandlers() {
try {
console.log('[Update] 开始下载更新...')
await autoUpdater.downloadUpdate()
} catch (error) {
} catch (error: any) {
console.error('[Update] 下载更新失败:', error)
// 失败时清理状态和监听器
isDownloadInProgress = false
@@ -1312,7 +1583,10 @@ function registerIpcHandlers() {
autoUpdater.removeListener('update-downloaded', downloadedHandler)
downloadedHandler = null
}
throw error
// 统一错误提示格式,避免出现 [object Object] 的 JSON 字符串
const errorMessage = error.message || (typeof error === 'string' ? error : JSON.stringify(error))
throw new Error(errorMessage)
}
})
@@ -2636,19 +2910,19 @@ function registerIpcHandlers() {
// 密钥获取
ipcMain.handle('key:autoGetDbKey', async (event) => {
return keyService.autoGetDbKey(180_000, (message, level) => {
return keyService.autoGetDbKey(180_000, (message: string, level: number) => {
event.sender.send('key:dbKeyStatus', { message, level })
})
})
ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string, wxid?: string) => {
return keyService.autoGetImageKey(manualDir, (message) => {
return keyService.autoGetImageKey(manualDir, (message: string) => {
event.sender.send('key:imageKeyStatus', { message })
}, wxid)
})
ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => {
return keyService.autoGetImageKeyByMemoryScan(userDir, (message) => {
return keyService.autoGetImageKeyByMemoryScan(userDir, (message: string) => {
event.sender.send('key:imageKeyStatus', { message })
})
})
@@ -2692,7 +2966,7 @@ function checkForUpdatesOnStartup() {
const latestVersion = result.updateInfo.version
// 检查是否有新版本
if (latestVersion !== currentVersion && mainWindow) {
if (shouldOfferUpdateForTrack(latestVersion, currentVersion) && mainWindow) {
// 检查该版本是否被用户忽略
const ignoredVersion = configService?.get('ignoredUpdateVersion')
if (ignoredVersion === latestVersion) {
@@ -2703,7 +2977,8 @@ function checkForUpdatesOnStartup() {
// 通知渲染进程有新版本
mainWindow.webContents.send('app:updateAvailable', {
version: latestVersion,
releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
releaseNotes: getDialogReleaseNotes(result.updateInfo.releaseNotes),
minimumVersion: (result.updateInfo as any).minimumVersion
})
}
}
@@ -2736,6 +3011,8 @@ app.whenReady().then(async () => {
// 初始化配置服务
updateSplashProgress(5, '正在加载配置...')
configService = new ConfigService()
applyAutoUpdateChannel('startup')
syncLaunchAtStartupPreference()
// 将用户主题配置推送给 Splash 窗口
if (splashWindow && !splashWindow.isDestroyed()) {

View File

@@ -53,6 +53,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
app: {
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
getVersion: () => ipcRenderer.invoke('app:getVersion'),
getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'),
setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
@@ -413,6 +415,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
},
biz: {
listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
listPayRecords: (account?: string, limit?: number, offset?: number) =>
ipcRenderer.invoke('biz:listPayRecords', account, limit, offset)
},
// 数据收集
cloud: {

View File

@@ -1135,7 +1135,7 @@ class AnnualReportService {
const now = Date.now()
if (now - lastProgressAt > 200) {
let progress = 30
let progress: number
if (totalMessagesForProgress > 0) {
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
progress = 30 + Math.floor(ratio * 50)

View File

@@ -0,0 +1,243 @@
import { join } from 'path'
import { readdirSync, existsSync } from 'fs'
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { chatService, Message } from './chatService'
import { ipcMain } from 'electron'
import { createHash } from 'crypto'
export interface BizAccount {
username: string
name: string
avatar: string
type: number
last_time: number
formatted_last_time: string
}
export interface BizMessage {
local_id: number
create_time: number
title: string
des: string
url: string
cover: string
content_list: any[]
}
export interface BizPayRecord {
local_id: number
create_time: number
title: string
description: string
merchant_name: string
merchant_icon: string
timestamp: number
formatted_time: string
}
export class BizService {
private configService: ConfigService
constructor() {
this.configService = new ConfigService()
}
private extractXmlValue(xml: string, tagName: string): string {
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
const match = regex.exec(xml)
if (match) {
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
return ''
}
private parseBizContentList(xmlStr: string): any[] {
if (!xmlStr) return []
const contentList: any[] = []
try {
const itemRegex = /<item>([\s\S]*?)<\/item>/gi
let match: RegExpExecArray | null
while ((match = itemRegex.exec(xmlStr)) !== null) {
const itemXml = match[1]
const itemStruct = {
title: this.extractXmlValue(itemXml, 'title'),
url: this.extractXmlValue(itemXml, 'url'),
cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'),
summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest')
}
if (itemStruct.title) contentList.push(itemStruct)
}
} catch (e) { }
return contentList
}
private parsePayXml(xmlStr: string): any {
if (!xmlStr) return null
try {
const title = this.extractXmlValue(xmlStr, 'title')
const description = this.extractXmlValue(xmlStr, 'des')
const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付'
const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url')
const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0')
if (!title && !description) return null
return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime }
} catch (e) { return null }
}
async listAccounts(account?: string): Promise<BizAccount[]> {
try {
// 1. 获取公众号联系人列表
const contactsResult = await chatService.getContacts({ lite: true })
if (!contactsResult.success || !contactsResult.contacts) return []
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
const usernames = officialContacts.map(c => c.username)
// 获取头像和昵称等补充信息
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
const root = this.configService.get('dbPath')
const myWxid = this.configService.get('myWxid')
const accountWxid = account || myWxid
if (!root || !accountWxid) return []
const bizLatestTime: Record<string, number> = {}
try {
const sessionsRes = await wcdbService.getSessions()
if (sessionsRes.success && sessionsRes.sessions) {
for (const session of sessionsRes.sessions) {
const uname = session.username || session.strUsrName || session.userName || session.id
// 适配日志中发现的字段,注意转为整型数字
const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
const time = parseInt(timeStr.toString(), 10)
if (usernames.includes(uname) && time > 0) {
bizLatestTime[uname] = time
}
}
}
} catch (e) {
console.error('获取 Sessions 失败:', e)
}
// 3. 格式化时间显示
const formatBizTime = (ts: number) => {
if (!ts) return ''
const date = new Date(ts * 1000)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return '昨天'
const isThisYear = date.getFullYear() === now.getFullYear()
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
}
// 4. 组装数据
const result: BizAccount[] = officialContacts.map(contact => {
const uname = contact.username
const info = contactInfoMap[uname]
const lastTime = bizLatestTime[uname] || 0
return {
username: uname,
name: info?.displayName || contact.displayName || uname,
avatar: info?.avatarUrl || '',
type: 0,
last_time: lastTime,
formatted_last_time: formatBizTime(lastTime)
}
})
// 5. 补充公众号类型 (订阅号/服务号)
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
if (existsSync(contactDbPath)) {
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
if (bizInfoRes.success && bizInfoRes.rows) {
const typeMap: Record<string, number> = {}
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
}
}
// 6. 排序输出
return result
.filter(acc => !acc.name.includes('广告'))
.sort((a, b) => {
if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶
if (b.username === 'gh_3dfda90e39d6') return 1
return b.last_time - a.last_time // 按最新时间降序排列
})
} catch (e) {
console.error('获取账号列表发生错误:', e)
return []
}
}
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
try {
// 仅保留核心路径:利用 chatService 的自动路由能力
const res = await chatService.getMessages(username, offset, limit)
if (!res.success || !res.messages) return []
return res.messages.map(msg => {
const bizMsg: BizMessage = {
local_id: msg.localId,
create_time: msg.createTime,
title: msg.linkTitle || msg.parsedContent || '',
des: msg.appMsgDesc || '',
url: msg.linkUrl || '',
cover: msg.linkThumb || msg.appMsgThumbUrl || '',
content_list: []
}
if (msg.rawContent) {
bizMsg.content_list = this.parseBizContentList(msg.rawContent)
if (bizMsg.content_list.length > 0 && !bizMsg.title) {
bizMsg.title = bizMsg.content_list[0].title
bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover
}
}
return bizMsg
})
} catch (e) { return [] }
}
async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> {
const username = 'gh_3dfda90e39d6'
try {
const res = await chatService.getMessages(username, offset, limit)
if (!res.success || !res.messages) return []
const records: BizPayRecord[] = []
for (const msg of res.messages) {
if (!msg.rawContent) continue
const parsedData = this.parsePayXml(msg.rawContent)
if (parsedData) {
records.push({
local_id: msg.localId,
create_time: msg.createTime,
...parsedData,
timestamp: parsedData.timestamp || msg.createTime,
formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString()
})
}
}
return records
} catch (e) { return [] }
}
registerHandlers() {
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
}
}
export const bizService = new BizService()

View File

@@ -2009,7 +2009,7 @@ class ChatService {
selectableColumns = resolvedColumns
}
if (!selectableColumns || selectableColumns.length === 0) return rows
if (selectableColumns.length === 0) return rows
const selectColumns = ['username', ...selectableColumns]
const sql = `SELECT ${selectColumns.map((column) => this.quoteSqlIdentifier(column)).join(', ')} FROM contact WHERE username IS NOT NULL AND username != ''`

View File

@@ -15,15 +15,31 @@ class CloudControlService {
private timer: NodeJS.Timeout | null = null
private pages: Set<string> = new Set()
private platformVersionCache: string | null = null
private pendingReports: UsageStats[] = []
private flushInProgress = false
private retryDelayMs = 5_000
private consecutiveFailures = 0
private circuitOpenedAt = 0
private nextDelayOverrideMs: number | null = null
private initialized = false
private static readonly BASE_FLUSH_MS = 300_000
private static readonly JITTER_MS = 30_000
private static readonly MAX_BUFFER_REPORTS = 200
private static readonly MAX_BATCH_REPORTS = 20
private static readonly MAX_RETRY_MS = 120_000
private static readonly CIRCUIT_FAIL_THRESHOLD = 5
private static readonly CIRCUIT_COOLDOWN_MS = 120_000
async init() {
if (this.initialized) return
this.initialized = true
this.deviceId = this.getDeviceId()
await wcdbService.cloudInit(300)
await this.reportOnline()
this.timer = setInterval(() => {
this.reportOnline()
}, 300000)
this.enqueueCurrentReport()
await this.flushQueue(true)
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
this.nextDelayOverrideMs = null
}
private getDeviceId(): string {
@@ -33,8 +49,8 @@ class CloudControlService {
return crypto.createHash('md5').update(machineId).digest('hex')
}
private async reportOnline() {
const data: UsageStats = {
private buildCurrentReport(): UsageStats {
return {
appVersion: app.getVersion(),
platform: this.getPlatformVersion(),
deviceId: this.deviceId,
@@ -42,11 +58,69 @@ class CloudControlService {
online: true,
pages: Array.from(this.pages)
}
}
await wcdbService.cloudReport(JSON.stringify(data))
private enqueueCurrentReport() {
const report = this.buildCurrentReport()
this.pendingReports.push(report)
if (this.pendingReports.length > CloudControlService.MAX_BUFFER_REPORTS) {
this.pendingReports.splice(0, this.pendingReports.length - CloudControlService.MAX_BUFFER_REPORTS)
}
this.pages.clear()
}
private isCircuitOpen(nowMs: number): boolean {
if (this.circuitOpenedAt <= 0) return false
return nowMs-this.circuitOpenedAt < CloudControlService.CIRCUIT_COOLDOWN_MS
}
private scheduleNextFlush(delayMs?: number) {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
const jitter = Math.floor(Math.random() * CloudControlService.JITTER_MS)
const nextDelay = Math.max(1_000, Number(delayMs) > 0 ? Number(delayMs) : CloudControlService.BASE_FLUSH_MS + jitter)
this.timer = setTimeout(() => {
this.enqueueCurrentReport()
this.flushQueue(false).finally(() => {
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
this.nextDelayOverrideMs = null
})
}, nextDelay)
}
private async flushQueue(force: boolean) {
if (this.flushInProgress) return
if (this.pendingReports.length === 0) return
const now = Date.now()
if (!force && this.isCircuitOpen(now)) {
return
}
this.flushInProgress = true
try {
while (this.pendingReports.length > 0) {
const batch = this.pendingReports.slice(0, CloudControlService.MAX_BATCH_REPORTS)
const result = await wcdbService.cloudReport(JSON.stringify(batch))
if (!result || result.success !== true) {
this.consecutiveFailures += 1
this.retryDelayMs = Math.min(CloudControlService.MAX_RETRY_MS, this.retryDelayMs * 2)
if (this.consecutiveFailures >= CloudControlService.CIRCUIT_FAIL_THRESHOLD) {
this.circuitOpenedAt = Date.now()
}
this.nextDelayOverrideMs = this.retryDelayMs
return
}
this.pendingReports.splice(0, batch.length)
this.consecutiveFailures = 0
this.retryDelayMs = 5_000
this.circuitOpenedAt = 0
}
} finally {
this.flushInProgress = false
}
}
private getPlatformVersion(): string {
if (this.platformVersionCache) {
return this.platformVersionCache
@@ -146,9 +220,16 @@ class CloudControlService {
stop() {
if (this.timer) {
clearInterval(this.timer)
clearTimeout(this.timer)
this.timer = null
}
this.pendingReports = []
this.flushInProgress = false
this.retryDelayMs = 5_000
this.consecutiveFailures = 0
this.circuitOpenedAt = 0
this.nextDelayOverrideMs = null
this.initialized = false
wcdbService.cloudStop()
}
@@ -158,4 +239,3 @@ class CloudControlService {
}
export const cloudControlService = new CloudControlService()

View File

@@ -27,6 +27,7 @@ interface ConfigSchema {
themeId: string
language: string
logEnabled: boolean
launchAtStartup?: boolean
llmModelPath: string
whisperModelName: string
whisperModelDir: string
@@ -45,6 +46,7 @@ interface ConfigSchema {
// 更新相关
ignoredUpdateVersion: string
updateChannel: 'auto' | 'stable' | 'preview' | 'dev'
// 通知
notificationEnabled: boolean
@@ -119,6 +121,7 @@ export class ConfigService {
authUseHello: false,
authHelloSecret: '',
ignoredUpdateVersion: '',
updateChannel: 'auto',
notificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
@@ -126,7 +129,7 @@ export class ConfigService {
httpApiToken: '',
httpApiEnabled: false,
httpApiPort: 5031,
httpApiHost: '127.0.0.1',
httpApiHost: '0.0.0.0',
messagePushEnabled: false,
windowCloseBehavior: 'ask',
quoteLayout: 'quote-top',

View File

@@ -3310,15 +3310,29 @@ class ExportService {
const subType = this.extractAppMessageType(normalized)
if (subType && subType !== '5' && subType !== '49') return null
const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url'))
const url = [
this.extractXmlValue(normalized, 'url'),
this.extractXmlValue(normalized, 'shareurlopen'),
this.extractXmlValue(normalized, 'shareurloriginal'),
this.extractXmlValue(normalized, 'shareurl'),
this.extractXmlValue(normalized, 'shorturl'),
this.extractXmlValue(normalized, 'dataurl'),
this.extractXmlValue(normalized, 'lowurl'),
this.extractXmlValue(normalized, 'streamvideoweburl'),
this.extractXmlValue(normalized, 'weburl')
]
.map(candidate => this.normalizeHtmlLinkUrl(candidate))
.find(Boolean) || ''
if (!url) return null
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url
const title = this.stripSenderPrefix(
this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url
) || url
return { title, url }
}
private normalizeHtmlLinkUrl(rawUrl: string): string {
const value = (rawUrl || '').trim()
const value = (rawUrl || '').trim().replace(/&amp;/gi, '&')
if (!value) return ''
const parseHttpUrl = (candidate: string): string => {
@@ -3349,6 +3363,46 @@ class ExportService {
return ''
}
private getLinkCardDisplayTitle(linkCard: { title: string; url: string }): string {
const normalizedTitle = this.stripSenderPrefix(String(linkCard.title || '').trim())
return normalizedTitle || linkCard.url || '链接'
}
private formatLinkCardExportText(
content: string,
localType: number,
style: 'markdown' | 'append-url'
): string | null {
const linkCard = this.extractHtmlLinkCard(content, localType)
if (!linkCard?.url) return null
const title = this.getLinkCardDisplayTitle(linkCard)
if (style === 'markdown') {
return `[${title}](${linkCard.url})`
}
const prefix = title && title !== linkCard.url ? `[链接] ${title}` : '[链接]'
return `${prefix}\n${linkCard.url}`
}
private applyExcelLinkCardCell(cell: ExcelJS.Cell, content: string, localType: number): boolean {
const linkCard = this.extractHtmlLinkCard(content, localType)
if (!linkCard?.url) return false
const title = this.getLinkCardDisplayTitle(linkCard)
cell.value = {
text: title,
hyperlink: linkCard.url,
tooltip: linkCard.url
} as any
cell.font = {
...(cell.font || {}),
color: { argb: 'FF0563C1' },
underline: true
}
return true
}
/**
* 导出媒体文件到指定目录
*/
@@ -5066,6 +5120,11 @@ class ExportService {
}
}
const markdownLinkContent = this.formatLinkCardExportText(msg.content, msg.localType, 'markdown')
if (markdownLinkContent) {
content = markdownLinkContent
}
const message: ChatLabMessage = {
sender: msg.senderUsername,
accountName: senderProfile.displayName || memberInfo.accountName,
@@ -5558,6 +5617,13 @@ class ExportService {
content = this.buildQuotedReplyText(quotedReplyDisplay)
}
const appendedLinkContent = quotedReplyDisplay
? null
: this.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
if (appendedLinkContent) {
content = appendedLinkContent
}
// 获取发送者信息用于名称显示
const senderWxid = msg.senderUsername
const contact = senderWxid
@@ -6484,16 +6550,14 @@ class ExportService {
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
}
// 调试日志
if (msg.localType === 3 || msg.localType === 47) {
}
const contentCellIndex = useCompactColumns ? 5 : 9
const contentCell = worksheet.getCell(currentRow, contentCellIndex)
worksheet.getCell(currentRow, 1).value = i + 1
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
if (useCompactColumns) {
worksheet.getCell(currentRow, 3).value = senderRole
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType)
worksheet.getCell(currentRow, 5).value = enrichedContentValue
} else {
worksheet.getCell(currentRow, 3).value = senderNickname
worksheet.getCell(currentRow, 4).value = senderWxid
@@ -6501,7 +6565,10 @@ class ExportService {
worksheet.getCell(currentRow, 6).value = senderGroupNickname
worksheet.getCell(currentRow, 7).value = senderRole
worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType)
worksheet.getCell(currentRow, 9).value = enrichedContentValue
}
contentCell.value = enrichedContentValue
if (!quotedReplyDisplay) {
this.applyExcelLinkCardCell(contentCell, msg.content, msg.localType)
}
currentRow++
@@ -6747,7 +6814,7 @@ class ExportService {
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
}
appendRow(useCompactColumns
const row = worksheet.addRow(useCompactColumns
? [
i + 1,
this.formatTimestamp(msg.createTime),
@@ -6766,6 +6833,10 @@ class ExportService {
this.getMessageTypeName(msg.localType),
enrichedContentValue
])
if (!quotedReplyDisplay) {
this.applyExcelLinkCardCell(row.getCell(useCompactColumns ? 5 : 9), msg.content, msg.localType)
}
row.commit()
if ((i + 1) % 200 === 0) {
onProgress?.({
@@ -7119,6 +7190,13 @@ class ExportService {
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
}
const appendedLinkContent = quotedReplyDisplay
? null
: this.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
if (appendedLinkContent) {
enrichedContentValue = appendedLinkContent
}
let senderRole: string
let senderWxid: string
let senderNickname: string

View File

@@ -12,6 +12,7 @@ import { ConfigService } from './config'
import { videoService } from './videoService'
import { imageDecryptService } from './imageDecryptService'
import { groupAnalyticsService } from './groupAnalyticsService'
import { snsService } from './snsService'
// ChatLab 格式定义
interface ChatLabHeader {
@@ -308,7 +309,7 @@ class HttpService {
*/
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
if (req.method === 'OPTIONS') {
@@ -348,6 +349,33 @@ class HttpService {
await this.handleContacts(url, res)
} else if (pathname === '/api/v1/group-members') {
await this.handleGroupMembers(url, res)
} else if (pathname === '/api/v1/sns/timeline') {
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
await this.handleSnsTimeline(url, res)
} else if (pathname === '/api/v1/sns/usernames') {
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
await this.handleSnsUsernames(res)
} else if (pathname === '/api/v1/sns/export/stats') {
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
await this.handleSnsExportStats(url, res)
} else if (pathname === '/api/v1/sns/media/proxy') {
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
await this.handleSnsMediaProxy(url, res)
} else if (pathname === '/api/v1/sns/export') {
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
await this.handleSnsExport(url, res)
} else if (pathname === '/api/v1/sns/block-delete/status') {
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
await this.handleSnsBlockDeleteStatus(res)
} else if (pathname === '/api/v1/sns/block-delete/install') {
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
await this.handleSnsBlockDeleteInstall(res)
} else if (pathname === '/api/v1/sns/block-delete/uninstall') {
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
await this.handleSnsBlockDeleteUninstall(res)
} else if (pathname.startsWith('/api/v1/sns/post/')) {
if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE')
await this.handleSnsDeletePost(pathname, res)
} else if (pathname.startsWith('/api/v1/media/')) {
this.handleMediaRequest(pathname, res)
} else {
@@ -559,6 +587,15 @@ class HttpService {
return defaultValue
}
private parseStringListParam(value: string | null): string[] | undefined {
if (!value) return undefined
const values = value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
return values.length > 0 ? Array.from(new Set(values)) : undefined
}
private parseMediaOptions(url: URL): ApiMediaOptions {
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
if (!mediaEnabled) {
@@ -790,6 +827,313 @@ class HttpService {
}
}
private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise<void> {
const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200)
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
const keyword = (url.searchParams.get('keyword') || '').trim() || undefined
const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true)
const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false)
const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true)
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
const startTime = startTimeRaw > 0 ? startTimeRaw : undefined
const endTime = endTimeRaw > 0 ? endTimeRaw : undefined
const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to get sns timeline')
return
}
let timeline = result.timeline || []
if (resolveMedia && timeline.length > 0) {
timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia)
}
this.sendJson(res, {
success: true,
count: timeline.length,
timeline
})
}
private async handleSnsUsernames(res: http.ServerResponse): Promise<void> {
const result = await snsService.getSnsUsernames()
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to get sns usernames')
return
}
this.sendJson(res, {
success: true,
usernames: result.usernames || []
})
}
private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise<void> {
const fast = this.parseBooleanParam(url, ['fast'], false)
const result = fast
? await snsService.getExportStatsFast()
: await snsService.getExportStats()
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to get sns export stats')
return
}
this.sendJson(res, result)
}
private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise<void> {
const mediaUrl = (url.searchParams.get('url') || '').trim()
if (!mediaUrl) {
this.sendError(res, 400, 'Missing required parameter: url')
return
}
const key = this.toSnsMediaKey(url.searchParams.get('key'))
const result = await snsService.downloadImage(mediaUrl, key)
if (!result.success) {
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
return
}
if (result.data) {
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
res.setHeader('Content-Length', result.data.length)
res.writeHead(200)
res.end(result.data)
return
}
if (result.cachePath && fs.existsSync(result.cachePath)) {
try {
const stat = fs.statSync(result.cachePath)
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
res.setHeader('Content-Length', stat.size)
res.writeHead(200)
const stream = fs.createReadStream(result.cachePath)
stream.on('error', () => {
if (!res.headersSent) {
this.sendError(res, 500, 'Failed to read proxied sns media')
} else {
try { res.destroy() } catch {}
}
})
stream.pipe(res)
return
} catch (error) {
console.error('[HttpService] Failed to stream sns media cache:', error)
}
}
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
}
private async handleSnsExport(url: URL, res: http.ServerResponse): Promise<void> {
const outputDir = String(url.searchParams.get('outputDir') || '').trim()
if (!outputDir) {
this.sendError(res, 400, 'Missing required field: outputDir')
return
}
const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase()
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat
if (!['json', 'html', 'arkmejson'].includes(format)) {
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson')
return
}
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
const options: {
outputDir: string
format: 'json' | 'html' | 'arkmejson'
usernames?: string[]
keyword?: string
exportMedia?: boolean
exportImages?: boolean
exportLivePhotos?: boolean
exportVideos?: boolean
startTime?: number
endTime?: number
} = {
outputDir,
format: format as 'json' | 'html' | 'arkmejson',
usernames,
keyword,
exportMedia: this.parseBooleanParam(url, ['exportMedia'], false)
}
if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false)
if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false)
if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false)
if (startTimeRaw > 0) options.startTime = startTimeRaw
if (endTimeRaw > 0) options.endTime = endTimeRaw
const result = await snsService.exportTimeline(options)
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to export sns timeline')
return
}
this.sendJson(res, result)
}
private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise<void> {
const result = await snsService.checkSnsBlockDeleteTrigger()
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to check sns block-delete status')
return
}
this.sendJson(res, result)
}
private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise<void> {
const result = await snsService.installSnsBlockDeleteTrigger()
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger')
return
}
this.sendJson(res, result)
}
private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise<void> {
const result = await snsService.uninstallSnsBlockDeleteTrigger()
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger')
return
}
this.sendJson(res, result)
}
private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise<void> {
const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim()
if (!postId) {
this.sendError(res, 400, 'Missing required path parameter: postId')
return
}
const result = await snsService.deleteSnsPost(postId)
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to delete sns post')
return
}
this.sendJson(res, result)
}
private toSnsMediaKey(value: unknown): string | number | undefined {
if (value == null) return undefined
if (typeof value === 'number' && Number.isFinite(value)) return value
const text = String(value).trim()
if (!text) return undefined
if (/^-?\d+$/.test(text)) return Number(text)
return text
}
private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined {
const target = String(rawUrl || '').trim()
if (!target) return undefined
const params = new URLSearchParams({ url: target })
if (key !== undefined) params.set('key', String(key))
return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}`
}
private async resolveSnsMediaUrl(
rawUrl: string,
key: string | number | undefined,
inline: boolean
): Promise<{ resolvedUrl?: string; proxyUrl?: string }> {
const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key)
if (!proxyUrl) return {}
if (!inline) return { resolvedUrl: proxyUrl, proxyUrl }
try {
const resolved = await snsService.proxyImage(rawUrl, key)
if (resolved.success && resolved.dataUrl) {
return { resolvedUrl: resolved.dataUrl, proxyUrl }
}
} catch (error) {
console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error)
}
return { resolvedUrl: proxyUrl, proxyUrl }
}
private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise<any[]> {
return Promise.all(
(posts || []).map(async (post) => {
const mediaList = Array.isArray(post?.media) ? post.media : []
if (mediaList.length === 0) return post
const nextMedia = await Promise.all(
mediaList.map(async (media: any) => {
const rawUrl = typeof media?.url === 'string' ? media.url : ''
const rawThumb = typeof media?.thumb === 'string' ? media.thumb : ''
const mediaKey = this.toSnsMediaKey(media?.key)
const [urlResolved, thumbResolved] = await Promise.all([
this.resolveSnsMediaUrl(rawUrl, mediaKey, inline),
this.resolveSnsMediaUrl(rawThumb, mediaKey, inline)
])
const nextItem: any = {
...media,
rawUrl,
rawThumb,
resolvedUrl: urlResolved.resolvedUrl,
resolvedThumbUrl: thumbResolved.resolvedUrl,
proxyUrl: urlResolved.proxyUrl,
proxyThumbUrl: thumbResolved.proxyUrl
}
if (replace) {
nextItem.url = urlResolved.resolvedUrl || rawUrl
nextItem.thumb = thumbResolved.resolvedUrl || rawThumb
}
if (media?.livePhoto && typeof media.livePhoto === 'object') {
const livePhoto = media.livePhoto
const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : ''
const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : ''
const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey)
const [liveUrlResolved, liveThumbResolved] = await Promise.all([
this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline),
this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline)
])
const nextLive: any = {
...livePhoto,
rawUrl: rawLiveUrl,
rawThumb: rawLiveThumb,
resolvedUrl: liveUrlResolved.resolvedUrl,
resolvedThumbUrl: liveThumbResolved.resolvedUrl,
proxyUrl: liveUrlResolved.proxyUrl,
proxyThumbUrl: liveThumbResolved.proxyUrl
}
if (replace) {
nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl
nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb
}
nextItem.livePhoto = nextLive
}
return nextItem
})
)
return {
...post,
media: nextMedia
}
})
)
}
private getApiMediaExportPath(): string {
return path.join(this.configService.getCacheBasePath(), 'api-media')
}
@@ -1451,6 +1795,11 @@ class HttpService {
res.end(JSON.stringify(data, null, 2))
}
private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void {
res.setHeader('Allow', allow)
this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`)
}
/**
* 发送错误响应
*/

View File

@@ -562,14 +562,14 @@ export class ImageDecryptService {
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
this.cacheDatPath(accountDir, imageDatName, preferredPath)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath)
this.cacheDatPath(accountDir, imageMd5, preferredPath)
return preferredPath
}
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
const hdPath = this.findHdVariantInSameDir(preferredPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath)
this.cacheDatPath(accountDir, imageMd5, hdPath)
return hdPath
}
return null

View File

@@ -389,7 +389,7 @@ export class KeyServiceMac {
`set timeoutSec to ${timeoutSec}`,
'try',
'with timeout of timeoutSec seconds',
'set outText to do shell script cmd with administrator privileges',
'set outText to do shell script (cmd & " 2>&1") with administrator privileges',
'end timeout',
'return "WF_OK::" & outText',
'on error errMsg number errNum partial result pr',

View File

@@ -537,6 +537,32 @@ class SnsService {
return raw.trim()
}
private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise<string[]> {
const pageSize = 500
const uniqueUsers = new Set<string>()
let offset = 0
for (let round = 0; round < maxRounds; round++) {
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
if (!result.success || !Array.isArray(result.timeline)) {
throw new Error(result.error || '获取朋友圈发布者失败')
}
const rows = result.timeline
if (rows.length === 0) break
for (const row of rows) {
const username = this.pickTimelineUsername(row)
if (username) uniqueUsers.add(username)
}
if (rows.length < pageSize) break
offset += rows.length
}
return Array.from(uniqueUsers)
}
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
const pageSize = 500
const uniqueUsers = new Set<string>()
@@ -794,7 +820,22 @@ class SnsService {
if (!result.success) {
return { success: false, error: result.error || '获取朋友圈联系人失败' }
}
return { success: true, usernames: result.usernames || [] }
const directUsernames = Array.isArray(result.usernames) ? result.usernames : []
if (directUsernames.length > 0) {
return { success: true, usernames: directUsernames }
}
// 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。
try {
const timelineUsers = await this.collectSnsUsernamesFromTimeline()
if (timelineUsers.length > 0) {
return { success: true, usernames: timelineUsers }
}
} catch {
// 忽略回退错误,保持与原行为一致返回空数组
}
return { success: true, usernames: directUsernames }
}
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
@@ -1199,7 +1240,7 @@ class SnsService {
return { success: false, error: result.error }
}
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> {
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
return this.fetchAndDecryptImage(url, key)
}

View File

@@ -2596,13 +2596,34 @@ export class WcdbCore {
}
}
/**
* 强制重新打开账号连接(绕过路径缓存),用于微信重装后消息数据库刷新失败时的自动恢复。
* 返回重新打开是否成功。
*/
private async forceReopen(): Promise<boolean> {
if (!this.currentPath || !this.currentKey || !this.currentWxid) return false
const path = this.currentPath
const key = this.currentKey
const wxid = this.currentWxid
this.writeLog('forceReopen: clearing cached handle and reopening...', true)
// 清空缓存状态,让 open() 真正重新打开
try { this.wcdbShutdown() } catch { }
this.handle = null
this.currentPath = null
this.currentKey = null
this.currentWxid = null
this.currentDbStoragePath = null
this.initialized = false
return this.open(path, key, wxid)
}
async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outCursor = [0]
const result = this.wcdbOpenMessageCursor(
let result = this.wcdbOpenMessageCursor(
this.handle,
sessionId,
batchSize,
@@ -2611,13 +2632,37 @@ export class WcdbCore {
endTimestamp,
outCursor
)
// result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB消息数据库缓存为空常见于微信重装后
// 自动强制重连并重试一次
if (result === -3 && outCursor[0] <= 0) {
this.writeLog('openMessageCursor: result=-3 (no message db), attempting forceReopen...', true)
const reopened = await this.forceReopen()
if (reopened && this.handle !== null) {
outCursor[0] = 0
result = this.wcdbOpenMessageCursor(
this.handle,
sessionId,
batchSize,
ascending ? 1 : 0,
beginTimestamp,
endTimestamp,
outCursor
)
this.writeLog(`openMessageCursor retry after forceReopen: result=${result} cursor=${outCursor[0]}`, true)
} else {
this.writeLog('openMessageCursor forceReopen failed, giving up', true)
}
}
if (result !== 0 || outCursor[0] <= 0) {
await this.printLogs(true)
this.writeLog(
`openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
true
)
return { success: false, error: `创建游标失败: ${result},请查看日志` }
const hint = result === -3
? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试`
: `创建游标失败: ${result},请查看日志`
return { success: false, error: hint }
}
return { success: true, cursor: outCursor[0] }
} catch (e) {
@@ -2636,7 +2681,7 @@ export class WcdbCore {
}
try {
const outCursor = [0]
const result = this.wcdbOpenMessageCursorLite(
let result = this.wcdbOpenMessageCursorLite(
this.handle,
sessionId,
batchSize,
@@ -2645,6 +2690,29 @@ export class WcdbCore {
endTimestamp,
outCursor
)
// result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB消息数据库缓存为空
// 自动强制重连并重试一次
if (result === -3 && outCursor[0] <= 0) {
this.writeLog('openMessageCursorLite: result=-3 (no message db), attempting forceReopen...', true)
const reopened = await this.forceReopen()
if (reopened && this.handle !== null) {
outCursor[0] = 0
result = this.wcdbOpenMessageCursorLite(
this.handle,
sessionId,
batchSize,
ascending ? 1 : 0,
beginTimestamp,
endTimestamp,
outCursor
)
this.writeLog(`openMessageCursorLite retry after forceReopen: result=${result} cursor=${outCursor[0]}`, true)
} else {
this.writeLog('openMessageCursorLite forceReopen failed, giving up', true)
}
}
if (result !== 0 || outCursor[0] <= 0) {
await this.printLogs(true)
this.writeLog(

4116
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "weflow",
"version": "2.1.0",
"version": "4.3.0",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": {
@@ -23,9 +23,9 @@
"electron:build": "npm run build"
},
"dependencies": {
"echarts": "^5.5.1",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"electron-store": "^10.0.0",
"electron-store": "^11.0.2",
"electron-updater": "^6.3.9",
"exceljs": "^4.4.0",
"ffmpeg-static": "^5.3.0",
@@ -34,14 +34,14 @@
"jieba-wasm": "^2.2.0",
"jszip": "^3.10.1",
"koffi": "^2.9.0",
"lucide-react": "^0.562.0",
"lucide-react": "^1.7.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.1.1",
"react-router-dom": "^7.14.0",
"react-virtuoso": "^4.18.1",
"remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38",
"sherpa-onnx-node": "^1.12.35",
"silk-wasm": "^3.7.1",
"sudo-prompt": "^9.2.1",
"wechat-emojis": "^1.0.2",
@@ -52,15 +52,27 @@
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"electron": "^39.2.7",
"electron-builder": "^25.1.8",
"sass": "^1.83.0",
"electron": "^41.1.1",
"electron-builder": "^26.8.1",
"sass": "^1.98.0",
"sharp": "^0.34.5",
"typescript": "^5.6.3",
"vite": "^6.0.5",
"typescript": "^6.0.2",
"vite": "^7.0.0",
"vite-plugin-electron": "^0.28.8",
"vite-plugin-electron-renderer": "^0.14.6"
},
"pnpm": {
"overrides": {
"tar": ">=6.2.1",
"minimatch": ">=3.1.2",
"rollup": ">=4.0.0",
"immutable": ">=4.0.0",
"lodash": ">=4.17.21",
"brace-expansion": ">=1.1.11",
"picomatch": ">=2.3.1",
"ajv": ">=8.18.0"
}
},
"build": {
"appId": "com.WeFlow.app",
"publish": {
@@ -90,7 +102,25 @@
"target": [
"nsis"
],
"icon": "public/icon.ico"
"icon": "public/icon.ico",
"extraFiles": [
{
"from": "resources/msvcp140.dll",
"to": "."
},
{
"from": "resources/msvcp140_1.dll",
"to": "."
},
{
"from": "resources/vcruntime140.dll",
"to": "."
},
{
"from": "resources/vcruntime140_1.dll",
"to": "."
}
]
},
"linux": {
"icon": "public/icon.png",
@@ -158,24 +188,11 @@
"node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*"
],
"extraFiles": [
{
"from": "resources/msvcp140.dll",
"to": "."
},
{
"from": "resources/msvcp140_1.dll",
"to": "."
},
{
"from": "resources/vcruntime140.dll",
"to": "."
},
{
"from": "resources/vcruntime140_1.dll",
"to": "."
}
],
"icon": "resources/icon.icns"
},
"overrides": {
"picomatch": "^4.0.4",
"tar": "^7.5.13",
"immutable": "^5.1.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

Binary file not shown.

BIN
resources/libwcdb_api.so Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
resources/xkey_helper_macos Normal file

Binary file not shown.

View File

@@ -20,6 +20,7 @@ import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow'
import ImageWindow from './pages/ImageWindow'
import SnsPage from './pages/SnsPage'
import BizPage from './pages/BizPage'
import ContactsPage from './pages/ContactsPage'
import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow'
@@ -103,6 +104,7 @@ function App() {
// 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
const [analyticsConsent, setAnalyticsConsent] = useState<boolean | null>(null)
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
@@ -252,6 +254,7 @@ function App() {
// 协议已同意,检查数据收集同意状态
const consent = await configService.getAnalyticsConsent()
const denyCount = await configService.getAnalyticsDenyCount()
setAnalyticsConsent(consent)
// 如果未设置同意状态且拒绝次数小于2次显示弹窗
if (consent === null && denyCount < 2) {
setShowAnalyticsConsent(true)
@@ -266,18 +269,21 @@ function App() {
checkAgreement()
}, [])
// 初始化数据收集
// 初始化数据收集(仅在用户同意后)
useEffect(() => {
if (analyticsConsent === true) {
cloudControl.initCloudControl()
}, [])
}
}, [analyticsConsent])
// 记录页面访问
// 记录页面访问(仅在用户同意后)
useEffect(() => {
if (analyticsConsent !== true) return
const path = location.pathname
if (path && path !== '/') {
cloudControl.recordPage(path)
}
}, [location.pathname])
}, [location.pathname, analyticsConsent])
const handleAgree = async () => {
if (!agreementChecked) return
@@ -296,6 +302,7 @@ function App() {
const handleAnalyticsAllow = async () => {
await configService.setAnalyticsConsent(true)
setAnalyticsConsent(true)
setShowAnalyticsConsent(false)
}
@@ -312,10 +319,14 @@ function App() {
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
if (info) {
setUpdateInfo({ ...info, hasUpdate: true })
window.electronAPI.app.getVersion().then((currentVersion: string) => {
const isMandatory = !!(info.minimumVersion && currentVersion &&
currentVersion.localeCompare(info.minimumVersion, undefined, { numeric: true, sensitivity: 'base' }) <= 0)
setUpdateInfo({ ...info, hasUpdate: true, isMandatory })
if (!useAppStore.getState().isLocked) {
setShowUpdateDialog(true)
}
})
}
})
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
@@ -685,10 +696,11 @@ function App() {
<UpdateDialog
open={showUpdateDialog}
updateInfo={updateInfo}
onClose={() => setShowUpdateDialog(false)}
onClose={() => { if (!(updateInfo as any)?.isMandatory) setShowUpdateDialog(false) }}
onUpdate={handleUpdateNow}
onIgnore={handleIgnoreUpdate}
isDownloading={isDownloading}
isMandatory={!!(updateInfo as any)?.isMandatory}
progress={downloadProgress}
/>
@@ -725,6 +737,7 @@ function App() {
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
<Route path="/sns" element={<SnsPage />} />
<Route path="/biz" element={<BizPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />

View File

@@ -75,6 +75,8 @@
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
border: none;
background: transparent;
&.clickable {
cursor: pointer;
@@ -172,6 +174,33 @@
}
}
}
.year-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.year-btn {
padding: 10px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'
import React, { useState } from 'react'
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
import './JumpToDateDialog.scss'
@@ -21,10 +21,15 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
messageDates,
loadingDates = false
}) => {
type CalendarViewMode = 'day' | 'month' | 'year'
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
const [yearPageStart, setYearPageStart] = useState<number>(
getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear())
)
if (!isOpen) return null
@@ -116,6 +121,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
const updateCalendarDate = (nextDate: Date) => {
setCalendarDate(nextDate)
}
const openMonthView = () => setViewMode('month')
const openYearView = () => {
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
setViewMode('year')
}
const handleTitleClick = () => {
if (viewMode === 'day') {
openMonthView()
return
}
if (viewMode === 'month') {
openYearView()
}
}
const handlePrev = () => {
if (viewMode === 'day') {
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
return
}
if (viewMode === 'month') {
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
return
}
setYearPageStart((prev) => prev - 12)
}
const handleNext = () => {
if (viewMode === 'day') {
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
return
}
if (viewMode === 'month') {
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
return
}
setYearPageStart((prev) => prev + 12)
}
const navTitle = viewMode === 'day'
? `${calendarDate.getFullYear()}${calendarDate.getMonth() + 1}`
: viewMode === 'month'
? `${calendarDate.getFullYear()}`
: `${yearPageStart}年 - ${yearPageStart + 11}`
return (
<div className="jump-date-overlay" onClick={onClose}>
@@ -134,45 +190,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
<div className="calendar-nav">
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
onClick={handlePrev}
>
<ChevronLeft size={18} />
</button>
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span>
<button
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
onClick={handleTitleClick}
type="button"
>
{navTitle}
</button>
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
onClick={handleNext}
>
<ChevronRight size={18} />
</button>
</div>
{showYearMonthPicker ? (
{viewMode === 'month' ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
<ChevronLeft size={16} />
</button>
<span className="year-label">{calendarDate.getFullYear()}</span>
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
<ChevronRight size={16} />
</button>
</div>
<div className="month-grid">
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
{monthNames.map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setShowYearMonthPicker(false)
updateCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setViewMode('day')
}}
>{name}</button>
))}
</div>
</div>
) : viewMode === 'year' ? (
<div className="year-month-picker">
<div className="year-grid">
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
<button
key={year}
className={`year-btn ${year === calendarDate.getFullYear() ? 'active' : ''}`}
onClick={() => {
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
setViewMode('month')
}}
>
{year}
</button>
))}
</div>
</div>
) : (
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
{loadingDates && (
@@ -208,18 +276,21 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
const d = new Date()
setSelectedDate(d)
setCalendarDate(new Date(d))
setViewMode('day')
}}></button>
<button onClick={() => {
const d = new Date()
d.setDate(d.getDate() - 7)
setSelectedDate(d)
setCalendarDate(new Date(d))
setViewMode('day')
}}></button>
<button onClick={() => {
const d = new Date()
d.setMonth(d.getMonth() - 1)
setSelectedDate(d)
setCalendarDate(new Date(d))
setViewMode('day')
}}></button>
</div>

View File

@@ -28,6 +28,20 @@
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
border: none;
background: transparent;
border-radius: 8px;
padding: 4px 8px;
}
.jump-date-popover .current-month.clickable {
cursor: pointer;
transition: all 0.18s ease;
}
.jump-date-popover .current-month.clickable:hover {
color: var(--primary);
background: var(--bg-hover);
}
.jump-date-popover .nav-btn {
@@ -83,6 +97,37 @@
gap: 4px;
}
.jump-date-popover .month-grid,
.jump-date-popover .year-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
min-height: 256px;
}
.jump-date-popover .month-cell,
.jump-date-popover .year-cell {
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
transition: all 0.18s ease;
}
.jump-date-popover .month-cell:hover,
.jump-date-popover .year-cell:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.jump-date-popover .month-cell.active,
.jump-date-popover .year-cell.active {
background: var(--primary);
color: #fff;
}
.jump-date-popover .day-cell {
position: relative;
border: 1px solid transparent;

View File

@@ -31,14 +31,20 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
loadingDates = false,
loadingDateCounts = false
}) => {
type CalendarViewMode = 'day' | 'month' | 'year'
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
const [yearPageStart, setYearPageStart] = useState<number>(getYearPageStart(new Date(currentDate).getFullYear()))
useEffect(() => {
if (!isOpen) return
const normalized = new Date(currentDate)
setCalendarDate(normalized)
setSelectedDate(normalized)
setViewMode('day')
setYearPageStart(getYearPageStart(normalized.getFullYear()))
}, [isOpen, currentDate])
if (!isOpen) return null
@@ -114,25 +120,78 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
const updateCalendarDate = (nextDate: Date) => {
setCalendarDate(nextDate)
onMonthChange?.(nextDate)
}
const openMonthView = () => setViewMode('month')
const openYearView = () => {
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
setViewMode('year')
}
const handleTitleClick = () => {
if (viewMode === 'day') {
openMonthView()
return
}
if (viewMode === 'month') {
openYearView()
}
}
const handlePrev = () => {
if (viewMode === 'day') {
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
return
}
if (viewMode === 'month') {
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
return
}
setYearPageStart((prev) => prev - 12)
}
const handleNext = () => {
if (viewMode === 'day') {
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
return
}
if (viewMode === 'month') {
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
return
}
setYearPageStart((prev) => prev + 12)
}
const navTitle = viewMode === 'day'
? `${calendarDate.getFullYear()}${calendarDate.getMonth() + 1}`
: viewMode === 'month'
? `${calendarDate.getFullYear()}`
: `${yearPageStart}年 - ${yearPageStart + 11}`
return (
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
<div className="calendar-nav">
<button
className="nav-btn"
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
onClick={handlePrev}
aria-label="上一月"
>
<ChevronLeft size={16} />
</button>
<span className="current-month">{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}</span>
<button
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
onClick={handleTitleClick}
type="button"
>
{navTitle}
</button>
<button
className="nav-btn"
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
onClick={handleNext}
aria-label="下一月"
>
<ChevronRight size={16} />
@@ -154,6 +213,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
)}
</div>
{viewMode === 'day' && (
<div className="calendar-grid">
<div className="weekdays">
{weekdays.map(day => (
@@ -184,6 +244,43 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
})}
</div>
</div>
)}
{viewMode === 'month' && (
<div className="month-grid">
{['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'].map((name, monthIndex) => (
<button
key={name}
className={`month-cell ${monthIndex === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
updateCalendarDate(new Date(calendarDate.getFullYear(), monthIndex, 1))
setViewMode('day')
}}
type="button"
>
{name}
</button>
))}
</div>
)}
{viewMode === 'year' && (
<div className="year-grid">
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
<button
key={year}
className={`year-cell ${year === calendarDate.getFullYear() ? 'active' : ''}`}
onClick={() => {
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
setViewMode('month')
}}
type="button"
>
{year}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -283,3 +283,12 @@
opacity: 1;
}
}
.mandatory-tip {
color: #e53e3e;
font-size: 13px;
text-align: center;
margin: 0 0 8px;
padding: 6px 12px;
background: rgba(229, 62, 62, 0.08);
border-radius: 6px;
}

View File

@@ -14,6 +14,7 @@ interface UpdateDialogProps {
onUpdate: () => void
onIgnore?: () => void
isDownloading: boolean
isMandatory?: boolean
progress: number | {
percent: number
bytesPerSecond?: number
@@ -30,6 +31,7 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
onUpdate,
onIgnore,
isDownloading,
isMandatory,
progress
}) => {
if (!open || !updateInfo) return null
@@ -69,7 +71,7 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
return (
<div className="update-dialog-overlay">
<div className="update-dialog">
{!isDownloading && (
{!isDownloading && !isMandatory && (
<button className="close-btn" onClick={onClose}>
<X size={20} />
</button>
@@ -119,11 +121,14 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
</div>
) : (
<div className="actions">
{onIgnore && (
{onIgnore && !isMandatory && (
<button className="btn-ignore" onClick={onIgnore}>
</button>
)}
{isMandatory && (
<p className="mandatory-tip">使</p>
)}
<button className="btn-update" onClick={onUpdate}>
</button>

View File

@@ -8,44 +8,9 @@ import {
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import { drawPatternBackground } from '../utils/reportExport'
import './AnnualReportWindow.scss'
// SVG 背景图案 (用于导出)
const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
// 绘制 SVG 图案背景到 canvas
const drawPatternBackground = async (ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: string, isDark: boolean) => {
// 先填充背景色
ctx.fillStyle = bgColor
ctx.fillRect(0, 0, width, height)
// 加载 SVG 图案
const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG
const blob = new Blob([svgString], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
return new Promise<void>((resolve) => {
const img = new window.Image()
img.onload = () => {
// 平铺绘制图案
const pattern = ctx.createPattern(img, 'repeat')
if (pattern) {
ctx.fillStyle = pattern
ctx.fillRect(0, 0, width, height)
}
URL.revokeObjectURL(url)
resolve()
}
img.onerror = () => {
URL.revokeObjectURL(url)
resolve()
}
img.src = url
})
}
interface TopContact {
username: string
displayName: string

360
src/pages/BizPage.scss Normal file
View File

@@ -0,0 +1,360 @@
.biz-account-list {
flex: 1;
overflow-y: auto;
background-color: var(--bg-secondary); // 对齐会话列表背景
.biz-loading {
padding: 20px;
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
}
.biz-account-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s;
border-bottom: 1px solid var(--border-color);
&:hover {
background-color: var(--bg-hover);
}
&.active {
background-color: var(--primary-light) !important;
border-left: 3px solid var(--primary);
padding-left: 13px; // 补偿 border-left
}
&.pay-account {
background-color: var(--bg-primary);
&.active {
background-color: var(--primary-light) !important;
border-left: 3px solid var(--primary);
}
}
.biz-avatar {
width: 48px;
height: 48px;
border-radius: 8px; // 对齐会话列表头像圆角
object-fit: cover;
flex-shrink: 0;
background-color: var(--bg-tertiary);
}
.biz-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
.biz-info-top {
display: flex;
justify-content: space-between;
align-items: center;
.biz-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.biz-time {
font-size: 11px;
color: var(--text-tertiary);
flex-shrink: 0;
}
}
.biz-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
width: fit-content;
margin-top: 2px;
&.type-service { color: #07c160; background: rgba(7, 193, 96, 0.1); }
&.type-sub { color: var(--primary); background: var(--primary-light); }
&.type-enterprise { color: #f5222d; background: rgba(245, 34, 45, 0.1); }
&.type-unknown { color: var(--text-tertiary); background: var(--bg-tertiary); }
}
}
}
}
.biz-main {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--bg-secondary); // 对齐聊天页背景
.main-header {
height: 56px;
padding: 0 20px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-color);
background-color: var(--card-bg);
flex-shrink: 0;
h2 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
}
.message-container {
flex: 1;
overflow-y: auto;
padding: 24px 16px;
background: var(--chat-pattern);
background-color: var(--bg-tertiary); // 对齐聊天背景色
.messages-wrapper {
width: 100%;
max-width: 600px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px; // 减小间距,因为有了 time-divider
}
}
.time-divider {
text-align: center;
margin: 16px 0 8px;
span {
display: inline-block;
padding: 2px 8px;
background-color: var(--bg-primary);
color: var(--text-tertiary);
font-size: 11px;
border-radius: 4px;
opacity: 0.8;
}
}
// 占位状态:对齐 Chat 页面风格
.biz-no-record-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
background: var(--bg-tertiary);
.no-record-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background-color: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
color: var(--text-tertiary);
opacity: 0.5;
svg { width: 32px; height: 32px; }
}
h3 {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 8px;
}
p {
font-size: 13px;
color: var(--text-secondary);
max-width: 280px;
line-height: 1.5;
}
}
.biz-loading-more {
text-align: center;
padding: 20px;
font-size: 12px;
color: var(--text-tertiary);
}
.pay-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
.pay-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-tertiary);
margin-bottom: 20px;
.pay-icon {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.pay-icon-placeholder {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #07c160;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
}
.pay-title {
text-align: center;
font-size: 22px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 24px;
}
.pay-desc {
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
white-space: pre-wrap;
}
.pay-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
font-size: 12px;
color: var(--text-tertiary);
text-align: right;
}
}
.article-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
.main-article {
position: relative;
cursor: pointer;
.article-cover {
width: 100%;
height: 220px;
object-fit: cover;
background-color: var(--bg-tertiary);
}
.article-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
.article-title {
color: white;
font-size: 17px;
font-weight: 500;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}
.article-digest {
padding: 12px 16px;
font-size: 14px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
}
.sub-articles {
.sub-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid var(--border-color);
cursor: pointer;
&:hover { background-color: var(--bg-hover); }
.sub-title {
flex: 1;
font-size: 15px;
color: var(--text-primary);
padding-right: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.sub-cover {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border-color);
}
}
}
}
}
.biz-empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
height: 100%;
background: var(--bg-tertiary); // 对齐 Chat 页面空白背景
.empty-icon {
width: 80px;
height: 80px;
margin-bottom: 20px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-secondary);
color: var(--text-tertiary);
svg { width: 40px; height: 40px; }
}
p { color: var(--text-tertiary); font-size: 14px; }
}

336
src/pages/BizPage.tsx Normal file
View File

@@ -0,0 +1,336 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useThemeStore } from '../stores/themeStore';
import { Newspaper, MessageSquareOff } from 'lucide-react';
import './BizPage.scss';
export interface BizAccount {
username: string;
name: string;
avatar: string;
type: string;
last_time: number;
formatted_last_time: string;
}
export const BizAccountList: React.FC<{
onSelect: (account: BizAccount) => void;
selectedUsername?: string;
searchKeyword?: string;
}> = ({ onSelect, selectedUsername, searchKeyword }) => {
const [accounts, setAccounts] = useState<BizAccount[]>([]);
const [loading, setLoading] = useState(false);
const [myWxid, setMyWxid] = useState<string>('');
useEffect(() => {
const initWxid = async () => {
try {
const wxid = await window.electronAPI.config.get('myWxid');
if (wxid) {
setMyWxid(wxid as string);
}
} catch (e) {
console.error("获取 myWxid 失败:", e);
}
};
initWxid().then(_r => { });
}, []);
useEffect(() => {
const fetch = async () => {
if (!myWxid) {
return;
}
setLoading(true);
try {
const res = await window.electronAPI.biz.listAccounts(myWxid)
setAccounts(res || []);
} catch (err) {
console.error('获取服务号列表失败:', err);
} finally {
setLoading(false);
}
};
fetch().then(_r => { } );
}, [myWxid]);
const filtered = useMemo(() => {
let result = accounts;
if (searchKeyword) {
const q = searchKeyword.toLowerCase();
result = accounts.filter(a =>
(a.name && a.name.toLowerCase().includes(q)) ||
(a.username && a.username.toLowerCase().includes(q))
);
}
return result.sort((a, b) => {
if (a.username === 'gh_3dfda90e39d6') return -1; // 微信支付置顶
if (b.username === 'gh_3dfda90e39d6') return 1;
return b.last_time - a.last_time;
});
}, [accounts, searchKeyword]);
if (loading) return <div className="biz-loading">...</div>;
return (
<div className="biz-account-list">
{filtered.map(item => (
<div
key={item.username}
onClick={() => onSelect(item)}
className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`}
>
<img
src={item.avatar}
className="biz-avatar"
alt=""
/>
<div className="biz-info">
<div className="biz-info-top">
<span className="biz-name">{item.name || item.username}</span>
<span className="biz-time">{item.formatted_last_time}</span>
</div>
{/*{item.username === 'gh_3dfda90e39d6' && (*/}
{/* <div className="biz-badge type-service">微信支付</div>*/}
{/*)}*/}
<div className={`biz-badge ${
item.type === '1' ? 'type-service' :
item.type === '0' ? 'type-sub' :
item.type === '2' ? 'type-enterprise' :
item.type === '3' ? 'type-enterprise' : 'type-unknown'
}`}>
{item.type === '0' ? '公众号' : item.type === '1' ? '服务号' : item.type === '2' ? '企业号' : item.type === '3' ? '企业附属' : '未知'}
</div>
</div>
</div>
))}
</div>
);
};
export const BizMessageArea: React.FC<{
account: BizAccount | null;
}> = ({ account }) => {
const themeMode = useThemeStore((state) => state.themeMode);
const [messages, setMessages] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const limit = 20;
const messageListRef = useRef<HTMLDivElement>(null);
const lastScrollHeightRef = useRef<number>(0);
const isInitialLoadRef = useRef<boolean>(true);
const [myWxid, setMyWxid] = useState<string>('');
useEffect(() => {
const initWxid = async () => {
try {
const wxid = await window.electronAPI.config.get('myWxid');
if (wxid) {
setMyWxid(wxid as string);
}
} catch (e) { }
};
initWxid();
}, []);
const isDark = useMemo(() => {
if (themeMode === 'dark') return true;
if (themeMode === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
}, [themeMode]);
useEffect(() => {
if (account && myWxid) {
setMessages([]);
setOffset(0);
setHasMore(true);
isInitialLoadRef.current = true;
loadMessages(account.username, 0);
}
}, [account, myWxid]);
const loadMessages = async (username: string, currentOffset: number) => {
if (loading || !myWxid) return;
setLoading(true);
if (messageListRef.current) {
lastScrollHeightRef.current = messageListRef.current.scrollHeight;
}
try {
let res;
if (username === 'gh_3dfda90e39d6') {
res = await window.electronAPI.biz.listPayRecords(myWxid, limit, currentOffset);
} else {
res = await window.electronAPI.biz.listMessages(username, myWxid, limit, currentOffset);
}
if (res) {
if (res.length < limit) setHasMore(false);
setMessages(prev => {
const combined = currentOffset === 0 ? res : [...res, ...prev];
const uniqueMessages = Array.from(new Map(combined.map(item => [item.local_id || item.create_time, item])).values());
return uniqueMessages.sort((a, b) => a.create_time - b.create_time);
});
setOffset(currentOffset + limit);
}
} catch (err) {
console.error('加载消息失败:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!messageListRef.current) return;
if (isInitialLoadRef.current && messages.length > 0) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
isInitialLoadRef.current = false;
} else if (messages.length > 0 && !isInitialLoadRef.current && !loading) {
const newScrollHeight = messageListRef.current.scrollHeight;
const heightDiff = newScrollHeight - lastScrollHeightRef.current;
if (heightDiff > 0 && messageListRef.current.scrollTop < 100) {
messageListRef.current.scrollTop += heightDiff;
}
}
}, [messages, loading]);
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
// 向上滚动到顶部附近触发加载更多(更旧的消息)
if (target.scrollTop < 50) {
if (!loading && hasMore && account) {
loadMessages(account.username, offset);
}
}
};
if (!account) {
return (
<div className="biz-empty-state">
<div className="empty-icon"><Newspaper size={40} /></div>
<p></p>
</div>
);
}
const formatMessageTime = (timestamp: number) => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) {
return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
const isThisYear = date.getFullYear() === now.getFullYear();
if (isThisYear) {
return `${date.getMonth() + 1}${date.getDate()}${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
};
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg==';
return (
<div className={`biz-main ${isDark ? 'dark' : ''}`}>
<div className="main-header">
<h2>{account.name}</h2>
</div>
<div className="message-container" onScroll={handleScroll} ref={messageListRef}>
<div className="messages-wrapper">
{hasMore && messages.length > 0 && (
<div className="biz-loading-more">{loading ? '加载中...' : '向上滚动加载更多历史消息'}</div>
)}
{!loading && messages.length === 0 && (
<div className="biz-no-record-container">
<div className="no-record-icon">
<MessageSquareOff size={48} />
</div>
<h3></h3>
<p></p>
</div>
)}
{messages.map((msg, index) => {
const showTime = true;
return (
<div key={msg.local_id || index}>
{showTime && (
<div className="time-divider">
<span>{formatMessageTime(msg.create_time)}</span>
</div>
)}
{account.username === 'gh_3dfda90e39d6' ? (
<div className="pay-card">
<div className="pay-header">
{msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon-placeholder">¥</div>}
<span>{msg.merchant_name || '微信支付'}</span>
</div>
<div className="pay-title">{msg.title}</div>
<div className="pay-desc">{msg.description}</div>
{/* <div className="pay-footer">{msg.formatted_time}</div> */}
</div>
) : (
<div className="article-card">
<div onClick={() => window.electronAPI.shell.openExternal(msg.url)} className="main-article">
<img src={msg.cover || defaultImage} className="article-cover" alt=""/>
<div className="article-overlay"><h3 className="article-title">{msg.title}</h3></div>
</div>
{msg.des && <div className="article-digest">{msg.des}</div>}
{msg.content_list && msg.content_list.length > 1 && (
<div className="sub-articles">
{msg.content_list.slice(1).map((item: any, idx: number) => (
<div key={idx} onClick={() => window.electronAPI.shell.openExternal(item.url)} className="sub-item">
<span className="sub-title">{item.title}</span>
{item.cover && <img src={item.cover} className="sub-cover" alt=""/>}
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
{loading && offset === 0 && <div className="biz-loading-more">...</div>}
</div>
</div>
</div>
);
};
const BizPage: React.FC = () => {
const [selectedAccount, setSelectedAccount] = useState<BizAccount | null>(null);
return (
<div className="biz-page">
<div className="biz-sidebar">
<BizAccountList onSelect={setSelectedAccount} selectedUsername={selectedAccount?.username} />
</div>
<BizMessageArea account={selectedAccount} />
</div>
);
}
export default BizPage;

View File

@@ -4487,6 +4487,32 @@
font-weight: 500;
}
}
// 公众号入口样式
.session-item.biz-entry {
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: var(--hover-bg, rgba(0,0,0,0.05));
}
.biz-entry-avatar {
width: 48px;
height: 48px;
border-radius: 8px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #07c160;
}
.session-name {
font-weight: 500;
}
}
// 消息信息弹窗
.message-info-overlay {
position: fixed;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
@@ -16,6 +16,7 @@ import JumpToDatePopover from '../components/JumpToDatePopover'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
import * as configService from '../services/config'
import BizPage, { BizAccountList, BizMessageArea, BizAccount } from './BizPage'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
@@ -36,6 +37,8 @@ const SYSTEM_MESSAGE_TYPES = [
266287972401, // 拍一拍
]
const OFFICIAL_ACCOUNTS_VIRTUAL_ID = 'official_accounts_virtual'
interface PendingInSessionSearchPayload {
sessionId: string
keyword: string
@@ -983,6 +986,7 @@ const SessionItem = React.memo(function SessionItem({
)
const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup')
const isBizEntry = session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID
// 折叠入口:专属名称和图标
if (isFoldEntry) {
@@ -1007,6 +1011,29 @@ const SessionItem = React.memo(function SessionItem({
)
}
// 公众号入口:专属名称和图标
if (isBizEntry) {
return (
<div
className={`session-item biz-entry ${isActive ? 'active' : ''}`}
onClick={() => onSelect(session)}
>
<div className="biz-entry-avatar">
<Newspaper size={22} />
</div>
<div className="session-info">
<div className="session-top">
<span className="session-name">/</span>
<span className="session-time">{timeText}</span>
</div>
<div className="session-bottom">
<span className="session-summary">{session.summary || '查看公众号历史消息'}</span>
</div>
</div>
</div>
)
}
// 根据匹配字段显示不同的 summary
const summaryContent = useMemo(() => {
if (session.matchedField === 'wxid') {
@@ -1204,6 +1231,8 @@ function ChatPage(props: ChatPageProps) {
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图
const [bizView, setBizView] = useState(false) // 是否在"公众号"视图
const [selectedBizAccount, setSelectedBizAccount] = useState<BizAccount | null>(null)
const [hasInitialMessages, setHasInitialMessages] = useState(false)
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
const [noMessageTable, setNoMessageTable] = useState(false)
@@ -2691,6 +2720,9 @@ function ChatPage(props: ChatPageProps) {
setConnected(false)
setConnecting(false)
setHasMoreMessages(true)
setFoldedView(false)
setBizView(false)
setSelectedBizAccount(null)
setHasMoreLater(false)
const scope = await resolveChatCacheScope()
hydrateSessionListCache(scope)
@@ -3227,7 +3259,7 @@ function ChatPage(props: ChatPageProps) {
const session = sessionMapRef.current.get(sessionId)
const unreadCount = session?.unreadCount ?? 0
let messageLimit = currentBatchSizeRef.current
let messageLimit: number
if (offset === 0) {
const preferredLimit = Number.isFinite(options.forceInitialLimit)
@@ -3964,6 +3996,12 @@ function ChatPage(props: ChatPageProps) {
setFoldedView(true)
return
}
// 点击公众号入口,切换到公众号视图
if (session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID) {
setBizView(true)
setSelectedBizAccount(null) // 切入时默认不选中任何公众号
return
}
selectSessionById(session.username)
}
@@ -4946,14 +4984,30 @@ function ChatPage(props: ChatPageProps) {
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
const hasFoldedGroups = foldedGroups.length > 0
const visible = sessions.filter(s => {
let visible = sessions.filter(s => {
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
return true
})
// 如果有折叠的群聊,但列表中没有入口,则插入入口
const bizEntry: ChatSession = {
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
displayName: '公众号',
summary: '查看公众号历史消息',
type: 0,
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
lastTimestamp: 0,
lastMsgType: 0,
unreadCount: 0,
isMuted: false,
isFolded: false
}
if (!visible.some(s => s.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)) {
visible.unshift(bizEntry)
}
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
// 找到最新的折叠消息
const latestFolded = foldedGroups.reduce((latest, current) => {
const latestTime = latest.sortTimestamp || latest.lastTimestamp
const currentTime = current.sortTimestamp || current.lastTimestamp
@@ -6031,7 +6085,7 @@ function ChatPage(props: ChatPageProps) {
ref={sidebarRef}
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
>
<div className={`session-header session-header-viewport ${foldedView ? 'folded' : ''}`}>
<div className={`session-header session-header-viewport ${foldedView || bizView ? 'folded' : ''}`}>
{/* 普通 header */}
<div className="session-header-panel main-header">
<div className="search-row">
@@ -6061,12 +6115,18 @@ function ChatPage(props: ChatPageProps) {
{/* 折叠群 header */}
<div className="session-header-panel folded-header">
<div className="folded-view-header">
<button className="icon-btn back-btn" onClick={() => setFoldedView(false)}>
<button className="icon-btn back-btn" onClick={() => {
setFoldedView(false)
setBizView(false)
}}>
<ChevronLeft size={18} />
</button>
<span className="folded-view-title">
<Users size={14} />
{foldedView ? (
<><Users size={14} /> </>
) : bizView ? (
<><Newspaper size={14} /> /</>
) : null}
</span>
</div>
</div>
@@ -6173,7 +6233,7 @@ function ChatPage(props: ChatPageProps) {
))}
</div>
) : (
<div className={`session-list-viewport ${foldedView ? 'folded' : ''}`}>
<div className={`session-list-viewport ${foldedView || bizView ? 'folded' : ''}`}>
{/* 普通会话列表 */}
<div className="session-list-panel main-panel">
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
@@ -6199,7 +6259,7 @@ function ChatPage(props: ChatPageProps) {
<SessionItem
key={session.username}
session={session}
isActive={currentSessionId === session.username}
isActive={currentSessionId === session.username || (bizView && session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)}
onSelect={handleSelectSession}
formatTime={formatSessionTime}
searchKeyword={searchKeyword}
@@ -6218,13 +6278,14 @@ function ChatPage(props: ChatPageProps) {
{/* 折叠群列表 */}
<div className="session-list-panel folded-panel">
{foldedSessions.length > 0 ? (
{foldedView && (
foldedSessions.length > 0 ? (
<div className="session-list">
{foldedSessions.map(session => (
<SessionItem
key={session.username}
session={session}
isActive={currentSessionId === session.username}
isActive={currentSessionId === session.username || (bizView && session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)}
onSelect={handleSelectSession}
formatTime={formatSessionTime}
searchKeyword={searchKeyword}
@@ -6236,6 +6297,17 @@ function ChatPage(props: ChatPageProps) {
<Users size={32} />
<p></p>
</div>
)
)}
{bizView && (
<div style={{ height: '100%', overflowY: 'auto' }}>
<BizAccountList
onSelect={setSelectedBizAccount}
selectedUsername={selectedBizAccount?.username}
searchKeyword={searchKeyword}
/>
</div>
)}
</div>
</div>
@@ -6247,7 +6319,9 @@ function ChatPage(props: ChatPageProps) {
{/* 右侧消息区域 */}
<div className="message-area">
{currentSession ? (
{bizView ? (
<BizMessageArea account={selectedBizAccount} />
) : currentSession ? (
<>
<div className="message-header">
<Avatar
@@ -7901,7 +7975,7 @@ function MessageBubble({
useEffect(() => {
if (emojiLocalPath) return
// 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况)
if (isEmoji && message.emojiLocalPath && !emojiLocalPath) {
if (isEmoji && message.emojiLocalPath) {
captureEmojiResizeBaseline()
setEmojiLocalPath(message.emojiLocalPath)
return

View File

@@ -238,7 +238,7 @@
}
.scene-message.sent .scene-avatar {
border-color: color-mix(in srgb, var(--primary) 30%, var(--bg-tertiary, rgba(0, 0, 0, 0.08)));
border-color: rgba(var(--ar-primary-rgb), 0.3);
}
.dual-stat-grid {

View File

@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Check, Download, Image, SlidersHorizontal, X } from 'lucide-react'
import html2canvas from 'html2canvas'
import ReportHeatmap from '../components/ReportHeatmap'
import ReportWordCloud from '../components/ReportWordCloud'
import { useThemeStore } from '../stores/themeStore'
import { drawPatternBackground } from '../utils/reportExport'
import './AnnualReportWindow.scss'
import './DualReportWindow.scss'
@@ -66,6 +70,12 @@ interface DualReportData {
streak?: { days: number; startDate: string; endDate: string }
}
interface SectionInfo {
id: string
name: string
ref: React.RefObject<HTMLElement | null>
}
function DualReportWindow() {
const [reportData, setReportData] = useState<DualReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -75,6 +85,29 @@ function DualReportWindow() {
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared')
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState('')
const [showExportModal, setShowExportModal] = useState(false)
const [selectedSections, setSelectedSections] = useState<Set<string>>(new Set())
const [fabOpen, setFabOpen] = useState(false)
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
const { themeMode } = useThemeStore()
const sectionRefs = {
cover: useRef<HTMLElement>(null),
firstChat: useRef<HTMLElement>(null),
yearFirstChat: useRef<HTMLElement>(null),
heatmap: useRef<HTMLElement>(null),
initiative: useRef<HTMLElement>(null),
response: useRef<HTMLElement>(null),
streak: useRef<HTMLElement>(null),
wordCloud: useRef<HTMLElement>(null),
stats: useRef<HTMLElement>(null),
ending: useRef<HTMLElement>(null)
}
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
@@ -151,6 +184,351 @@ function DualReportWindow() {
void loadEmojis()
}, [reportData])
const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year))
const sanitizeFileNameSegment = (value: string) => {
const sanitized = value.replace(/[\\/:*?"<>|]/g, '_').trim()
return sanitized || '好友'
}
const getAvailableSections = (): SectionInfo[] => {
if (!reportData) return []
const sections: SectionInfo[] = [
{ id: 'cover', name: '封面', ref: sectionRefs.cover },
{ id: 'firstChat', name: '首次聊天', ref: sectionRefs.firstChat }
]
if (reportData.yearFirstChat && (!reportData.firstChat || reportData.yearFirstChat.createTime !== reportData.firstChat.createTime)) {
sections.push({ id: 'yearFirstChat', name: '第一段对话', ref: sectionRefs.yearFirstChat })
}
if (reportData.heatmap) {
sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap })
}
if (reportData.initiative) {
sections.push({ id: 'initiative', name: '主动性', ref: sectionRefs.initiative })
}
if (reportData.response) {
sections.push({ id: 'response', name: '回应速度', ref: sectionRefs.response })
}
if (reportData.streak) {
sections.push({ id: 'streak', name: '最长连续聊天', ref: sectionRefs.streak })
}
sections.push({ id: 'wordCloud', name: '常用语', ref: sectionRefs.wordCloud })
sections.push({ id: 'stats', name: '年度统计', ref: sectionRefs.stats })
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
return sections
}
const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => {
const element = section.ref.current
if (!element) {
return null
}
const OUTPUT_WIDTH = 1920
const OUTPUT_HEIGHT = 1080
let wordCloudInner: HTMLElement | null = null
let wordTags: NodeListOf<HTMLElement> | null = null
let wordCloudOriginalStyle = ''
const wordTagOriginalStyles: string[] = []
const originalStyle = element.style.cssText
try {
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
const activeEl = document.activeElement as HTMLElement | null
activeEl?.blur?.()
document.body.classList.add('exporting-snapshot')
document.documentElement.classList.add('exporting-snapshot')
element.style.minHeight = 'auto'
element.style.padding = '40px 20px'
element.style.background = 'transparent'
element.style.backgroundColor = 'transparent'
element.style.boxShadow = 'none'
wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement | null
wordTags = element.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
if (wordCloudInner) {
wordCloudOriginalStyle = wordCloudInner.style.cssText
wordCloudInner.style.transform = 'none'
}
wordTags.forEach((tag, index) => {
wordTagOriginalStyles[index] = tag.style.cssText
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
tag.style.animation = 'none'
})
await new Promise((resolve) => setTimeout(resolve, 50))
const computedStyle = getComputedStyle(document.documentElement)
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
const canvas = await html2canvas(element, {
backgroundColor: 'transparent',
scale: 2,
useCORS: true,
allowTaint: true,
logging: false,
onclone: (clonedDoc) => {
clonedDoc.body.classList.add('exporting-snapshot')
clonedDoc.documentElement.classList.add('exporting-snapshot')
clonedDoc.getSelection?.()?.removeAllRanges()
}
})
const outputCanvas = document.createElement('canvas')
outputCanvas.width = OUTPUT_WIDTH
outputCanvas.height = OUTPUT_HEIGHT
const ctx = outputCanvas.getContext('2d')
if (!ctx) {
return null
}
const isDark = themeMode === 'dark'
await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark)
const PADDING = 80
const contentWidth = OUTPUT_WIDTH - PADDING * 2
const contentHeight = OUTPUT_HEIGHT - PADDING * 2
const srcRatio = canvas.width / canvas.height
const dstRatio = contentWidth / contentHeight
let drawWidth: number
let drawHeight: number
let drawX: number
let drawY: number
if (srcRatio > dstRatio) {
drawWidth = contentWidth
drawHeight = contentWidth / srcRatio
drawX = PADDING
drawY = PADDING + (contentHeight - drawHeight) / 2
} else {
drawHeight = contentHeight
drawWidth = contentHeight * srcRatio
drawX = PADDING + (contentWidth - drawWidth) / 2
drawY = PADDING
}
ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight)
return { name: section.name, data: outputCanvas.toDataURL('image/png') }
} catch {
return null
} finally {
element.style.cssText = originalStyle
if (wordCloudInner) {
wordCloudInner.style.cssText = wordCloudOriginalStyle
}
wordTags?.forEach((tag, index) => {
tag.style.cssText = wordTagOriginalStyles[index]
})
document.body.classList.remove('exporting-snapshot')
document.documentElement.classList.remove('exporting-snapshot')
}
}
const exportFullReport = async (filterIds?: Set<string>) => {
if (!containerRef.current || !reportData) {
return
}
setIsExporting(true)
setExportProgress('正在生成长图...')
let wordCloudInner: HTMLElement | null = null
let wordTags: NodeListOf<HTMLElement> | null = null
let wordCloudOriginalStyle = ''
const wordTagOriginalStyles: string[] = []
const container = containerRef.current
const sections = Array.from(container.querySelectorAll('.section')) as HTMLElement[]
const originalStyles = sections.map((section) => section.style.cssText)
try {
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
const activeEl = document.activeElement as HTMLElement | null
activeEl?.blur?.()
document.body.classList.add('exporting-snapshot')
document.documentElement.classList.add('exporting-snapshot')
sections.forEach((section) => {
section.style.minHeight = 'auto'
section.style.padding = '40px 0'
})
if (filterIds) {
getAvailableSections().forEach((section) => {
if (!filterIds.has(section.id) && section.ref.current) {
section.ref.current.style.display = 'none'
}
})
}
wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement | null
wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
if (wordCloudInner) {
wordCloudOriginalStyle = wordCloudInner.style.cssText
wordCloudInner.style.transform = 'none'
}
wordTags.forEach((tag, index) => {
wordTagOriginalStyles[index] = tag.style.cssText
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
tag.style.animation = 'none'
})
await new Promise((resolve) => setTimeout(resolve, 100))
const computedStyle = getComputedStyle(document.documentElement)
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
const canvas = await html2canvas(container, {
backgroundColor: 'transparent',
scale: 2,
useCORS: true,
allowTaint: true,
logging: false,
onclone: (clonedDoc) => {
clonedDoc.body.classList.add('exporting-snapshot')
clonedDoc.documentElement.classList.add('exporting-snapshot')
clonedDoc.getSelection?.()?.removeAllRanges()
}
})
const outputCanvas = document.createElement('canvas')
outputCanvas.width = canvas.width
outputCanvas.height = canvas.height
const ctx = outputCanvas.getContext('2d')
if (!ctx) {
throw new Error('无法创建导出画布')
}
const isDark = themeMode === 'dark'
await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark)
ctx.drawImage(canvas, 0, 0)
const yearFilePrefix = formatFileYearLabel(reportData.year)
const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername)
const link = document.createElement('a')
link.download = `${yearFilePrefix}双人年度报告_${friendFileSegment}${filterIds ? '_自定义' : ''}.png`
link.href = outputCanvas.toDataURL('image/png')
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (e) {
alert('导出失败: ' + String(e))
} finally {
sections.forEach((section, index) => {
section.style.cssText = originalStyles[index]
})
if (wordCloudInner) {
wordCloudInner.style.cssText = wordCloudOriginalStyle
}
wordTags?.forEach((tag, index) => {
tag.style.cssText = wordTagOriginalStyles[index]
})
document.body.classList.remove('exporting-snapshot')
document.documentElement.classList.remove('exporting-snapshot')
setIsExporting(false)
setExportProgress('')
}
}
const exportSelectedSections = async () => {
if (!reportData) return
const sections = getAvailableSections().filter((section) => selectedSections.has(section.id))
if (sections.length === 0) {
alert('请至少选择一个板块')
return
}
if (exportMode === 'long') {
setShowExportModal(false)
await exportFullReport(selectedSections)
setSelectedSections(new Set())
return
}
setIsExporting(true)
setShowExportModal(false)
const exportedImages: Array<{ name: string; data: string }> = []
for (let index = 0; index < sections.length; index++) {
const section = sections[index]
setExportProgress(`正在导出: ${section.name} (${index + 1}/${sections.length})`)
const result = await exportSection(section)
if (result) {
exportedImages.push(result)
}
}
if (exportedImages.length === 0) {
alert('导出失败')
setIsExporting(false)
setExportProgress('')
return
}
const dirResult = await window.electronAPI.dialog.openDirectory({
title: '选择导出文件夹',
properties: ['openDirectory', 'createDirectory']
})
if (dirResult.canceled || !dirResult.filePaths?.[0]) {
setIsExporting(false)
setExportProgress('')
return
}
setExportProgress('正在写入文件...')
const yearFilePrefix = formatFileYearLabel(reportData.year)
const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername)
const exportResult = await window.electronAPI.annualReport.exportImages({
baseDir: dirResult.filePaths[0],
folderName: `${yearFilePrefix}双人年度报告_${friendFileSegment}_分模块`,
images: exportedImages.map((image) => ({
name: `${yearFilePrefix}双人年度报告_${friendFileSegment}_${image.name}.png`,
dataUrl: image.data
}))
})
if (!exportResult.success) {
alert('导出失败: ' + (exportResult.error || '未知错误'))
}
setIsExporting(false)
setExportProgress('')
setSelectedSections(new Set())
}
const toggleSection = (id: string) => {
const next = new Set(selectedSections)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
setSelectedSections(next)
}
const toggleAll = () => {
const sections = getAvailableSections()
if (selectedSections.size === sections.length) {
setSelectedSections(new Set())
return
}
setSelectedSections(new Set(sections.map((section) => section.id)))
}
if (isLoading) {
return (
<div className="annual-report-window loading">
@@ -305,7 +683,7 @@ function DualReportWindow() {
if (emojiUrl) {
return (
<div className="report-emoji-container">
<img src={emojiUrl} alt="表情" className="report-emoji-img" onError={(e) => {
<img src={emojiUrl} alt="表情" className="report-emoji-img" crossOrigin="anonymous" onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
}} />
@@ -356,7 +734,7 @@ function DualReportWindow() {
if (avatarUrl) {
return (
<div className="scene-avatar with-image">
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} />
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} crossOrigin="anonymous" />
</div>
)
}
@@ -419,9 +797,99 @@ function DualReportWindow() {
<div className="deco-circle c5" />
</div>
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
<button
className="fab-item"
onClick={() => {
setFabOpen(false)
setExportMode('separate')
setShowExportModal(true)
}}
title="分模块导出"
>
<Image size={18} />
</button>
<button
className="fab-item"
onClick={() => {
setFabOpen(false)
setExportMode('long')
setShowExportModal(true)
}}
title="自定义导出长图"
>
<SlidersHorizontal size={18} />
</button>
<button
className="fab-item"
onClick={() => {
setFabOpen(false)
void exportFullReport()
}}
title="导出长图"
>
<Download size={18} />
</button>
<button className="fab-main" onClick={() => setFabOpen(!fabOpen)}>
{fabOpen ? <X size={22} /> : <Download size={22} />}
</button>
</div>
{isExporting && (
<div className="export-overlay">
<div className="export-progress-modal">
<div className="export-spinner">
<div className="spinner-ring"></div>
<Download size={24} className="spinner-icon" />
</div>
<p className="export-title"></p>
<p className="export-status">{exportProgress}</p>
</div>
</div>
)}
{showExportModal && (
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
<div className="export-modal section-selector" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
<button className="close-btn" onClick={() => setShowExportModal(false)}>
<X size={20} />
</button>
</div>
<div className="section-grid">
{getAvailableSections().map((section) => (
<div
key={section.id}
className={`section-card ${selectedSections.has(section.id) ? 'selected' : ''}`}
onClick={() => toggleSection(section.id)}
>
<div className="card-check">
{selectedSections.has(section.id) && <Check size={14} />}
</div>
<span>{section.name}</span>
</div>
))}
</div>
<div className="modal-footer">
<button className="select-all-btn" onClick={toggleAll}>
{selectedSections.size === getAvailableSections().length ? '取消全选' : '全选'}
</button>
<button
className="confirm-btn"
onClick={() => void exportSelectedSections()}
disabled={selectedSections.size === 0}
>
{exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
</button>
</div>
</div>
</div>
)}
<div className="report-scroll-view">
<div className="report-container">
<section className="section">
<div className="report-container" ref={containerRef}>
<section className="section" ref={sectionRefs.cover}>
<div className="label-text">WEFLOW · DUAL REPORT</div>
<h1 className="hero-title dual-cover-title">{yearTitle}<br /></h1>
<hr className="divider" />
@@ -433,7 +901,7 @@ function DualReportWindow() {
<p className="hero-desc"></p>
</section>
<section className="section">
<section className="section" ref={sectionRefs.firstChat}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{firstChat ? (
@@ -457,7 +925,7 @@ function DualReportWindow() {
</section>
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? (
<section className="section">
<section className="section" ref={sectionRefs.yearFirstChat}>
<div className="label-text"></div>
<h2 className="hero-title">
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
@@ -473,7 +941,7 @@ function DualReportWindow() {
) : null}
{reportData.heatmap && (
<section className="section">
<section className="section" ref={sectionRefs.heatmap}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{mostActive && (
@@ -486,14 +954,14 @@ function DualReportWindow() {
)}
{reportData.initiative && (
<section className="section">
<section className="section" ref={sectionRefs.initiative}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="initiative-container">
<div className="initiative-bar-wrapper">
<div className="initiative-side">
<div className="avatar-placeholder">
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" /> : '我'}
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" crossOrigin="anonymous" /> : '我'}
</div>
<div className="count">{reportData.initiative.initiated}</div>
<div className="percent">{initiatedPercent.toFixed(1)}%</div>
@@ -507,7 +975,7 @@ function DualReportWindow() {
</div>
<div className="initiative-side">
<div className="avatar-placeholder">
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" /> : reportData.friendName.substring(0, 1)}
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" crossOrigin="anonymous" /> : reportData.friendName.substring(0, 1)}
</div>
<div className="count">{reportData.initiative.received}</div>
<div className="percent">{receivedPercent.toFixed(1)}%</div>
@@ -521,7 +989,7 @@ function DualReportWindow() {
)}
{reportData.response && (
<section className="section">
<section className="section" ref={sectionRefs.response}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="response-pulse-container">
@@ -558,7 +1026,7 @@ function DualReportWindow() {
)}
{reportData.streak && (
<section className="section">
<section className="section" ref={sectionRefs.streak}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="streak-spark-visual premium">
@@ -596,7 +1064,7 @@ function DualReportWindow() {
</section>
)}
<section className="section word-cloud-section">
<section className="section word-cloud-section" ref={sectionRefs.wordCloud}>
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
@@ -640,7 +1108,7 @@ function DualReportWindow() {
</div>
</section>
<section className="section">
<section className="section" ref={sectionRefs.stats}>
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
<div className="dual-stat-grid">
@@ -664,7 +1132,7 @@ function DualReportWindow() {
<div className="emoji-card">
<div className="emoji-title"></div>
{myEmojiUrl ? (
<img src={myEmojiUrl} alt="my-emoji" onError={(e) => {
<img src={myEmojiUrl} alt="my-emoji" crossOrigin="anonymous" onError={(e) => {
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
(e.target as HTMLImageElement).style.display = 'none';
}} />
@@ -677,7 +1145,7 @@ function DualReportWindow() {
<div className="emoji-card">
<div className="emoji-title">{reportData.friendName}</div>
{friendEmojiUrl ? (
<img src={friendEmojiUrl} alt="friend-emoji" onError={(e) => {
<img src={friendEmojiUrl} alt="friend-emoji" crossOrigin="anonymous" onError={(e) => {
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
(e.target as HTMLImageElement).style.display = 'none';
}} />
@@ -690,7 +1158,7 @@ function DualReportWindow() {
</div>
</section>
<section className="section">
<section className="section" ref={sectionRefs.ending}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<p className="hero-desc"></p>

View File

@@ -1401,6 +1401,220 @@
}
}
// 版本更新页面
.updates-tab {
display: flex;
flex-direction: column;
gap: 16px;
}
.updates-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--primary) 18%, var(--border-color));
background:
linear-gradient(110deg, color-mix(in srgb, var(--bg-primary) 98%, #ffffff 2%), color-mix(in srgb, var(--bg-primary) 97%, var(--primary) 3%));
box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 8%, transparent);
}
.updates-hero-main {
min-width: 0;
h2 {
margin: 10px 0 6px;
font-size: 30px;
line-height: 1;
letter-spacing: -0.03em;
color: var(--text-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
}
.updates-chip {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: var(--primary);
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
.updates-hero-action {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
.updates-card {
padding: 18px;
border-radius: 16px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 96%, var(--bg-secondary));
}
.updates-progress-card {
padding: 16px 18px;
border-radius: 16px;
border: 1px solid color-mix(in srgb, var(--primary) 34%, var(--border-color));
background: color-mix(in srgb, var(--bg-primary) 95%, var(--primary) 4%);
}
.updates-progress-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
h3 {
margin: 0;
font-size: 15px;
color: var(--text-primary);
}
strong {
color: var(--primary);
font-size: 14px;
font-variant-numeric: tabular-nums;
}
span {
font-size: 12px;
color: var(--text-secondary);
}
}
.updates-card-header {
margin-bottom: 12px;
h3 {
margin: 0 0 4px;
font-size: 16px;
color: var(--text-primary);
}
span {
font-size: 12px;
color: var(--text-tertiary);
}
}
.update-channel-grid {
display: grid;
gap: 10px;
}
.update-channel-card {
width: 100%;
text-align: left;
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 12px 14px;
background: var(--bg-primary);
color: var(--text-primary);
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
&:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
transform: translateY(-1px);
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 12%, transparent);
}
&:disabled {
cursor: default;
}
&.active {
border-color: color-mix(in srgb, var(--primary) 60%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
}
}
.update-channel-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
.title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
svg {
color: var(--primary);
}
}
.update-channel-card .desc {
display: block;
font-size: 12px;
color: var(--text-secondary);
}
.updates-progress-track {
height: 10px;
border-radius: 999px;
background: var(--bg-tertiary);
overflow: hidden;
}
.updates-progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 72%, #ffffff));
transition: width 0.3s ease;
position: relative;
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.26), transparent);
animation: updatesShimmer 1.7s linear infinite;
}
}
.updates-ignore-btn {
margin-top: 12px;
}
@media (max-width: 920px) {
.updates-hero {
flex-direction: column;
align-items: flex-start;
}
.updates-hero-action {
align-items: flex-start;
}
}
@keyframes updatesShimmer {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
// 关于页面
.about-tab {

View File

@@ -15,7 +15,7 @@ import {
import { Avatar } from '../components/Avatar'
import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
@@ -24,9 +24,9 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'models', label: '模型管理', icon: Mic },
{ id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'api', label: 'API 服务', icon: Globe },
{ id: 'analytics', label: '分析', icon: BarChart2 },
{ id: 'security', label: '安全', icon: ShieldCheck },
{ id: 'updates', label: '版本更新', icon: RefreshCw },
{ id: 'about', label: '关于', icon: Info }
]
@@ -68,7 +68,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setDownloadProgress,
showUpdateDialog,
setShowUpdateDialog,
setUpdateError
} = useAppStore()
const resetChatStore = useChatStore((state) => state.reset)
@@ -139,8 +138,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
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 [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
const [launchAtStartup, setLaunchAtStartup] = useState(false)
const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac)
const [launchAtStartupReason, setLaunchAtStartupReason] = useState('')
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
@@ -162,6 +165,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
const [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = useState(false)
const [appVersion, setAppVersion] = useState('')
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
const [showDecryptKey, setShowDecryptKey] = useState(false)
@@ -337,8 +341,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
const savedNotificationFilterList = await configService.getNotificationFilterList()
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
const savedQuoteLayout = await configService.getQuoteLayout()
const savedUpdateChannel = await configService.getUpdateChannel()
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
const savedAuthUseHello = await configService.getAuthUseHello()
@@ -385,8 +391,23 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setNotificationFilterMode(savedNotificationFilterMode)
setNotificationFilterList(savedNotificationFilterList)
setMessagePushEnabled(savedMessagePushEnabled)
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
setWindowCloseBehavior(savedWindowCloseBehavior)
setQuoteLayout(savedQuoteLayout)
if (savedUpdateChannel) {
setUpdateChannel(savedUpdateChannel)
} else {
const currentVersion = await window.electronAPI.app.getVersion()
if (/^0\.\d{2}\.\d+$/i.test(currentVersion) || /-preview\.\d+\.\d+$/i.test(currentVersion)) {
setUpdateChannel('preview')
} else if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(currentVersion) || /-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) {
setUpdateChannel('dev')
} else {
setUpdateChannel('stable')
}
}
const savedExcludeWords = await configService.getWordCloudExcludeWords()
setWordCloudExcludeWords(savedExcludeWords)
@@ -415,6 +436,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const handleLaunchAtStartupChange = async (enabled: boolean) => {
if (isUpdatingLaunchAtStartup) return
try {
setIsUpdatingLaunchAtStartup(true)
const result = await window.electronAPI.app.setLaunchAtStartup(enabled)
setLaunchAtStartup(result.enabled)
setLaunchAtStartupSupported(result.supported)
setLaunchAtStartupReason(result.reason || '')
if (result.success) {
showMessage(enabled ? '已开启开机自启动' : '已关闭开机自启动', true)
return
}
showMessage(result.error || result.reason || '设置开机自启动失败', false)
} catch (e: any) {
showMessage(`设置开机自启动失败: ${e?.message || String(e)}`, false)
} finally {
setIsUpdatingLaunchAtStartup(false)
}
}
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
try {
const result = await window.electronAPI.whisper?.getModelStatus()
@@ -512,7 +556,22 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
}
const handleUpdateChannelChange = async (channel: configService.UpdateChannel) => {
if (channel === updateChannel) return
try {
setUpdateChannel(channel)
await configService.setUpdateChannel(channel)
await configService.setIgnoredUpdateVersion('')
setUpdateInfo(null)
setShowUpdateDialog(false)
const channelLabel = channel === 'stable' ? '稳定版' : channel === 'preview' ? '预览版' : '开发版'
showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true)
await handleCheckUpdate()
} catch (e: any) {
showMessage(`切换更新渠道失败: ${e}`, false)
}
}
const showMessage = (text: string, success: boolean) => {
setMessage({ text, success })
@@ -1171,6 +1230,39 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">
{launchAtStartupSupported
? '开启后,登录系统时会自动启动 WeFlow。'
: launchAtStartupReason || '当前环境暂不支持开机自启动。'}
</span>
<div className="log-toggle-line">
<span className="log-status">
{isUpdatingLaunchAtStartup
? '保存中...'
: launchAtStartupSupported
? (launchAtStartup ? '已开启' : '已关闭')
: '当前不可用'}
</span>
<label className="switch" htmlFor="launch-at-startup-toggle">
<input
id="launch-at-startup-toggle"
className="switch-input"
type="checkbox"
checked={launchAtStartup}
disabled={!launchAtStartupSupported || isUpdatingLaunchAtStartup}
onChange={(e) => {
void handleLaunchAtStartupChange(e.target.checked)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint"></span>
@@ -1474,6 +1566,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const renderDatabaseTab = () => (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
</button>
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">64</span>
@@ -2400,35 +2502,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<img src="./logo.png" alt="WeFlow" />
</div>
<h2 className="about-name">WeFlow</h2>
<p className="about-slogan">WeFlow</p>
<p className="about-version">v{appVersion || '...'}</p>
<div className="about-update">
{updateInfo?.hasUpdate ? (
<>
<p className="update-hint"> v{updateInfo.version} </p>
{isDownloading ? (
<div className="update-progress">
<div className="progress-bar">
<div className="progress-inner" style={{ width: `${(downloadProgress?.percent || 0)}%` }} />
</div>
<span>{(downloadProgress?.percent || 0).toFixed(0)}%</span>
</div>
) : (
<button className="btn btn-primary" onClick={() => setShowUpdateDialog(true)}>
<Download size={16} />
</button>
)}
</>
) : (
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
{isCheckingUpdate ? '检查中...' : '检查更新'}
</button>
</div>
)}
</div>
</div>
<div className="about-footer">
@@ -2440,7 +2514,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span>·</span>
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.window.openAgreementWindow() }}></a>
</div>
<p className="copyright">© 2025 WeFlow. All rights reserved.</p>
<p className="copyright">© 2026 WeFlow. All rights reserved.</p>
<div className="log-toggle-line" style={{ marginTop: '16px', justifyContent: 'center' }}>
<span style={{ fontSize: '13px', opacity: 0.7 }}></span>
@@ -2463,6 +2537,82 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
)
const renderUpdatesTab = () => {
const downloadPercent = Math.max(0, Math.min(100, Number(downloadProgress?.percent || 0)))
const channelCards: { id: configService.UpdateChannel; title: string; desc: string }[] = [
{ id: 'stable', title: '稳定版', desc: '正式发布的版本,适合日常使用' },
{ id: 'preview', title: '预览版', desc: '正式发布前的预览体验版本' },
{ id: 'dev', title: '开发版', desc: '即刻体验我们的屎山代码' }
]
return (
<div className="tab-content updates-tab">
<div className="updates-hero">
<div className="updates-hero-main">
<span className="updates-chip"></span>
<h2>{appVersion || '...'}</h2>
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更新'}</p>
</div>
<div className="updates-hero-action">
{updateInfo?.hasUpdate ? (
<button className="btn btn-primary" onClick={() => setShowUpdateDialog(true)}>
<Download size={16} />
</button>
) : (
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
{isCheckingUpdate ? '检查中...' : '检查更新'}
</button>
)}
</div>
</div>
{(isDownloading || updateInfo?.hasUpdate) && (
<div className="updates-progress-card">
<div className="updates-progress-header">
<h3>{isDownloading ? `正在下载 v${updateInfo?.version || ''}` : `新版本 v${updateInfo?.version} 已就绪`}</h3>
{isDownloading ? <strong>{downloadPercent.toFixed(0)}%</strong> : <span></span>}
</div>
<div className="updates-progress-track">
<div className="updates-progress-fill" style={{ width: `${isDownloading ? downloadPercent : 100}%` }} />
</div>
{updateInfo?.hasUpdate && !isDownloading && (
<button className="btn btn-secondary updates-ignore-btn" onClick={handleIgnoreUpdate}>
</button>
)}
</div>
)}
<div className="updates-card">
<div className="updates-card-header">
<h3></h3>
<span></span>
</div>
<div className="update-channel-grid">
{channelCards.map((channel) => {
const active = updateChannel === channel.id
return (
<button
key={channel.id}
className={`update-channel-card ${active ? 'active' : ''}`}
onClick={() => void handleUpdateChannelChange(channel.id)}
disabled={active}
>
<div className="update-channel-title-row">
<span className="title">{channel.title}</span>
{active && <Check size={16} />}
</div>
<span className="desc">{channel.desc}</span>
</button>
)
})}
</div>
</div>
</div>
)
}
return (
<div className={`settings-modal-overlay ${isClosing ? 'closing' : ''}`} onClick={handleClose}>
<div className={`settings-page ${isClosing ? 'closing' : ''}`} onClick={(event) => event.stopPropagation()}>
@@ -2510,9 +2660,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<h1></h1>
</div>
<div className="settings-actions">
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
</button>
{onClose && (
<button type="button" className="settings-close-btn" onClick={handleClose} aria-label="关闭设置">
<X size={18} />
@@ -2542,6 +2689,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{activeTab === 'models' && renderModelsTab()}
{activeTab === 'cache' && renderCacheTab()}
{activeTab === 'api' && renderApiTab()}
{activeTab === 'updates' && renderUpdatesTab()}
{activeTab === 'analytics' && renderAnalyticsTab()}
{activeTab === 'security' && renderSecurityTab()}
{activeTab === 'about' && renderAboutTab()}

View File

@@ -13,6 +13,7 @@ export const CONFIG_KEYS = {
LAST_SESSION: 'lastSession',
WINDOW_BOUNDS: 'windowBounds',
CACHE_PATH: 'cachePath',
LAUNCH_AT_STARTUP: 'launchAtStartup',
EXPORT_PATH: 'exportPath',
AGREEMENT_ACCEPTED: 'agreementAccepted',
@@ -58,6 +59,7 @@ export const CONFIG_KEYS = {
// 更新
IGNORED_UPDATE_VERSION: 'ignoredUpdateVersion',
UPDATE_CHANNEL: 'updateChannel',
// 通知
NOTIFICATION_ENABLED: 'notificationEnabled',
@@ -96,6 +98,7 @@ export interface ExportDefaultMediaConfig {
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
export type QuoteLayout = 'quote-top' | 'quote-bottom'
export type UpdateChannel = 'stable' | 'preview' | 'dev'
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
images: true,
@@ -256,6 +259,18 @@ export async function setLogEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
}
// 获取开机自启动偏好
export async function getLaunchAtStartup(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.LAUNCH_AT_STARTUP)
if (typeof value === 'boolean') return value
return null
}
// 设置开机自启动偏好
export async function setLaunchAtStartup(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled)
}
// 获取 LLM 模型路径
export async function getLlmModelPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
@@ -1381,6 +1396,18 @@ export async function setIgnoredUpdateVersion(version: string): Promise<void> {
await config.set(CONFIG_KEYS.IGNORED_UPDATE_VERSION, version)
}
// 获取更新渠道(空值/auto 视为未显式设置,交由安装包类型决定默认渠道)
export async function getUpdateChannel(): Promise<UpdateChannel | null> {
const value = await config.get(CONFIG_KEYS.UPDATE_CHANNEL)
if (value === 'stable' || value === 'preview' || value === 'dev') return value
return null
}
// 设置更新渠道
export async function setUpdateChannel(channel: UpdateChannel): Promise<void> {
await config.set(CONFIG_KEYS.UPDATE_CHANNEL, channel)
}
// 获取通知开关
export async function getNotificationEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.NOTIFICATION_ENABLED)

View File

@@ -56,6 +56,14 @@ export interface ElectronAPI {
app: {
getDownloadsPath: () => Promise<string>
getVersion: () => Promise<string>
getLaunchAtStartupStatus: () => Promise<{ enabled: boolean; supported: boolean; reason?: string }>
setLaunchAtStartup: (enabled: boolean) => Promise<{
success: boolean
enabled: boolean
supported: boolean
reason?: string
error?: string
}>
checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }>
downloadAndInstall: () => Promise<void>
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
@@ -326,6 +334,11 @@ export interface ElectronAPI {
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
}
biz: {
listAccounts: (account?: string) => Promise<any[]>
listMessages: (username: string, account?: string, limit?: number, offset?: number) => Promise<any[]>
listPayRecords: (account?: string, limit?: number, offset?: number) => Promise<any[]>
}
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>

36
src/utils/reportExport.ts Normal file
View File

@@ -0,0 +1,36 @@
const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
export const drawPatternBackground = async (
ctx: CanvasRenderingContext2D,
width: number,
height: number,
bgColor: string,
isDark: boolean
) => {
ctx.fillStyle = bgColor
ctx.fillRect(0, 0, width, height)
const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG
const blob = new Blob([svgString], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
return new Promise<void>((resolve) => {
const img = new window.Image()
img.onload = () => {
const pattern = ctx.createPattern(img, 'repeat')
if (pattern) {
ctx.fillStyle = pattern
ctx.fillRect(0, 0, width, height)
}
URL.revokeObjectURL(url)
resolve()
}
img.onerror = () => {
URL.revokeObjectURL(url)
resolve()
}
img.src = url
})
}

BIN
temp_assets.json Normal file

Binary file not shown.

View File

@@ -17,8 +17,7 @@
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@@ -15,6 +15,7 @@ export default defineConfig({
strictPort: false // 如果3000被占用自动尝试下一个
},
build: {
chunkSizeWarningLimit: 900,
commonjsOptions: {
ignoreDynamicRequires: true
}
@@ -171,6 +172,7 @@ export default defineConfig({
renderer()
],
resolve: {
dedupe: ['react', 'react-dom'],
alias: {
'@': resolve(__dirname, 'src')
}