Compare commits

..

77 Commits
v3.2.1 ... main

Author SHA1 Message Date
cc
cfa335564a Merge pull request #549 from hicccc77/dev
Dev
2026-03-25 20:02:58 +08:00
cc
3d1493b0a6 Merge pull request #548 from Leoluis0705/perf/optimize-group-analytics
Perf/optimize group analytics
2026-03-25 19:51:08 +08:00
Leoluis0705
a46b52e603 fix(analytics): 完善群成员分析失败时的错误边界处理与UI展示 2026-03-25 19:15:12 +08:00
Leoluis0705
3c0683b9f8 perf(core): 为底层提取器引入 isSend 标识智能判断,解决大量本地消息及富文本消息引发的性能退化问题 2026-03-25 18:30:24 +08:00
Leoluis0705
3214c2804e feat(group-analytics): 新增并极致优化群成员详细分析与图表呈现功能 2026-03-25 18:24:05 +08:00
hicccc77
83f50cbaee fix: support configurable bind host for HTTP API and fix Windows sherpa-onnx PATH
- fix(#547): HTTP API server now supports configurable bind host (default 127.0.0.1)
  Docker/N8N users can set host to 0.0.0.0 in settings to allow container access.
  Adds httpApiHost config key, UI input in settings, and passes host through
  IPC chain (preload -> main -> httpService).

- fix(#546): Add Windows PATH injection for sherpa-onnx native module
  buildTranscribeWorkerEnv() now adds the sherpa-onnx-win-x64 directory to
  PATH on Windows, fixing 'Could not find sherpa-onnx-node' errors caused
  by missing DLL search path in forked worker processes.
2026-03-25 15:10:16 +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
H3CoF6
acec2e95a2 Merge pull request #540 from H3CoF6/main
Dev
2026-03-24 04:45:13 +08:00
H3CoF6
d26e7e78a1 支持appimage,添加安装脚本,更新文档 2026-03-24 04:33:17 +08:00
H3CoF6
77e5c44673 feat: 保存api服务的配置,实现随weflow静默启动 2026-03-24 04:11:34 +08:00
H3CoF6
619cc84d15 feat: api接口新增access_token校验 2026-03-24 03:55:37 +08:00
H3CoF6
22b85439d3 chore: 向下兼容低版本linux 2026-03-24 03:14:44 +08:00
cc
64995c25a8 支持联系人签名、标签分组、地区获取;优化导出效果 2026-03-23 21:46:15 +08:00
cc
1655b5ae78 Merge pull request #528 from BeiChen-CN/main
feat: 导出联系人标签和详细描述
2026-03-23 19:34:57 +08:00
cc
3d3f6d058e Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-22 23:34:57 +08:00
cc
104a04c5de 修复Windows hello部分情况下设置失败的问题 2026-03-22 23:34:48 +08:00
姜北尘
e12193aa40 feat: 导出联系人标签和详细描述
扩展联系人读取与导出链路,新增 labels 和 detailDescription 字段的兼容提取,并同步更新通讯录缓存、详情展示与
  JSON/CSV/VCF 导出。
  Close #402
2026-03-22 14:17:19 +08:00
hicccc77
51101387f7 资源文件同步 2026-03-22 11:31:27 +08:00
cc
641a3bf2ab 修复群导出时的错误昵称判定;修复引用样式的一些错误;修复打包问题 2026-03-22 11:25:59 +08:00
cc
58f22f4bb2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-22 10:32:47 +08:00
cc
562eac4249 修复release问题 2026-03-22 10:32:42 +08:00
xuncha
2e1c0e6c54 Merge pull request #524 from hicccc77/dev
Dev
2026-03-22 09:38:40 +08:00
xuncha
7759868664 Merge pull request #523 from xunchahaha:dev
Dev
2026-03-22 09:37:56 +08:00
xuncha
e92df66bef 修复导出页头像缺失 2026-03-22 09:37:19 +08:00
xuncha
354f3fd8e2 修复图片解密失败 2026-03-22 09:18:57 +08:00
xuncha
1201ea33db Merge pull request #521 from BeiChen-CN/main
feat: 支持自定义引用消息样式
2026-03-21 23:00:23 +08:00
姜北尘
f8e99a34c7 feat: 支持自定义引用消息样式
允许用户在设置中切换引用消息与正文的上下顺序,并使聊天页中的引用回复即时按所选样式展示。
  Close#510
2026-03-21 22:26:09 +08:00
hicccc77
1cef17174b chore: 更新资源文件 2026-03-21 21:45:53 +08:00
cc
73cabf2acd 修复闪退问题 2026-03-21 21:41:32 +08:00
cc
49770f9e8d Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-21 19:49:50 +08:00
cc
e32261d274 修复闪退问题 2026-03-21 19:49:38 +08:00
hicccc77
3c7a63e616 chore: update wcdb_api related resources 2026-03-21 16:45:35 +08:00
hicccc77
ab7a487e78 fix: escape artifactName template vars in PowerShell for arm64 job 2026-03-21 16:31:09 +08:00
hicccc77
f01e2efd3f fix: arm64 Windows installer distinct filename, fix x64 exe asset filter 2026-03-21 16:18:38 +08:00
cc
3f4a4f7581 修复mac端打包 2026-03-21 16:03:58 +08:00
hicccc77
7f78925bd7 fix: correct module filename for linux/darwin in afterPack sign script 2026-03-21 15:57:33 +08:00
cc
d16423818d Merge pull request #518 from hicccc77/dev
Dev
2026-03-21 15:53:54 +08:00
cc
8cbd3b9625 Merge branch 'main' into dev 2026-03-21 15:53:45 +08:00
hicccc77
9fac12ce3c feat: add Windows arm64 support (wcdb_api + WCDB DLLs, getDllPath arch detection, release CI) 2026-03-21 15:49:44 +08:00
cc
ee050aa5fa 一些修复与优化 2026-03-21 15:39:35 +08:00
cc
a179f13031 更新弹窗自动过滤下载字段 2026-03-21 15:17:41 +08:00
cc
f3fc5760fc 修复一些打包问题 2026-03-21 15:04:48 +08:00
cc
d4e04a003c Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-21 14:50:43 +08:00
cc
2604be38f0 朋友圈支持定位解析;导出时表情包支持语义化补充导出 2026-03-21 14:50:40 +08:00
H3CoF6
06a10f77ae Merge pull request #514 from H3CoF6/dev
linux版本增加wayland说明
优化一点点页面显示
---

我发现appimage可以用,之前觉得FUSE导致难以操控微信进程的
重新支持appimage,放弃对deb的打包(等appimage的-1006报错修好后彻底放弃)
2026-03-21 03:45:12 +08:00
H3CoF6
73f1355011 feat: 更新action,放弃deb打包转为更方便和兼容的appimage 2026-03-21 03:15:31 +08:00
H3CoF6
659b9f9680 feat: 设置页面wayland说明和缓存目录展示 2026-03-21 03:05:18 +08:00
H3CoF6
539f854dbf feat: 添加wayland检查和消息弹窗位置失效说明 2026-03-21 02:53:03 +08:00
H3CoF6
45d4e74c98 fix: 修复linux打包后无法正常操作进程的问题 2026-03-21 02:14:38 +08:00
H3CoF6
1d0b101352 Merge pull request #511 from H3CoF6/main
fix:修复linux的一些问题
2026-03-21 00:45:50 +08:00
H3CoF6
ed96eeccee Merge remote-tracking branch 'upstream/dev' 2026-03-21 00:27:33 +08:00
H3CoF6
29d49360f5 feat: 新增语音转文字段错误修复提示 2026-03-21 00:17:43 +08:00
cc
849cac6a40 Merge pull request #509 from hicccc77/dev
Dev
2026-03-20 22:40:09 +08:00
cc
262b3622dd 更新文档描述 2026-03-20 22:39:39 +08:00
xuncha
2692ac2408 Merge pull request #507 from BeiChen-CN/main
fix: 修复 HTTP API 导出 Type 49 链接消息异常
2026-03-20 22:35:23 +08:00
cc
c2502a09a9 优化导出速度,提供可选项优化 2026-03-20 21:43:29 +08:00
姜北尘
2ea7c72fc6 fix: 修复 HTTP API 导出 Type 49 链接消息异常
为 HTTP API 导出重新解析 appmsg 子类型,修复公众号链接被误判为 OTHER 的问题,并补齐导出内容中的 `[链接]` 前缀。

Fixes #300
2026-03-20 21:13:25 +08:00
cc
42aafae29b Merge pull request #506 from hicccc77/dev
Dev
2026-03-20 20:40:08 +08:00
cc
61101382d1 Merge pull request #505 from hicccc77/main
dev
2026-03-20 20:39:38 +08:00
cc
ba5a791b2d Mac密钥日志服务修复 2026-03-20 20:38:30 +08:00
xuncha
ba189aec6f Merge pull request #503 from xunchahaha/dev
增加引用消息导出 优化了线程相关 导出选择时间优化
2026-03-20 17:13:18 +08:00
xuncha
4b17d20325 weclone导出不再有引用消息 2026-03-20 17:11:28 +08:00
xuncha
b52bdcf4b3 补齐别的格式 2026-03-20 17:03:48 +08:00
xuncha
8e8c14a51f 导出chatlab的时候有引用消息 2026-03-20 16:42:01 +08:00
xuncha
80786c572a 引用消息支持 2026-03-20 16:15:58 +08:00
xuncha
a331f45f87 修复导出时的日期选择问题 2026-03-20 16:01:31 +08:00
xuncha
4c70ebcaf9 修复朋友圈联系人重复加载的问题 2026-03-20 15:29:47 +08:00
xuncha
7760358c02 优化选择 2026-03-20 15:19:10 +08:00
xuncha
a163ea377c 导出时 日历只有一个 2026-03-20 15:12:13 +08:00
xuncha
3fabf961e5 修复html导出问题 2026-03-20 14:57:45 +08:00
H3CoF6
6f3b60ef2c fix: 修复linux打包后无法拉起wechat的bug 2026-03-20 06:44:03 +08:00
H3CoF6
4a27653039 Merge pull request #498 from H3CoF6/feat/linux
fix: 删除pacman打包
2026-03-20 01:01:10 +08:00
H3CoF6
d5b1f5fb1c fix: 删除pacman打包 2026-03-20 00:56:41 +08:00
cc
8dfd39810d Merge pull request #497 from hicccc77/dev
Dev
2026-03-20 00:43:06 +08:00
xuncha
b5a371da87 Merge pull request #349 from hicccc77/dev
Dev
2026-03-13 08:55:32 +03:00
56 changed files with 15003 additions and 797 deletions

View File

@@ -119,7 +119,43 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --publish always
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
release-windows-arm64:
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: 'npm'
- name: Install Dependencies
run: npm install
- name: Sync version with tag
shell: bash
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
npm version $VERSION --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package and Publish 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}'
update-release-notes:
runs-on: ubuntu-latest
@@ -127,6 +163,7 @@ jobs:
- release-mac-arm64
- release-linux
- release
- release-windows-arm64
steps:
- name: Generate release notes with platform download links
@@ -147,11 +184,14 @@ jobs:
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
}
WINDOWS_ASSET="$(pick_asset "\\.exe$")"
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')"
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="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
MAC_ASSET="$(pick_asset "\\.dmg$")"
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
LINUX_PACMAN_ASSET="$(pick_asset "\\.pacman$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
build_link() {
local name="$1"
@@ -161,10 +201,10 @@ jobs:
}
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_PACMAN_URL="$(build_link "$LINUX_PACMAN_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
cat > release_notes.md <<EOF
## 更新日志
@@ -174,11 +214,17 @@ jobs:
[点击加入 Telegram 频道](https://t.me/weflow_cc)
## 下载
- Windows Win10+: ${WINDOWS_URL:-$RELEASE_PAGE}
- Windows x64Win10+: ${WINDOWS_URL:-$RELEASE_PAGE}
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
- macOSM系列芯片: ${MAC_URL:-$RELEASE_PAGE}
- Linux (.deb): ${LINUX_DEB_URL:-$RELEASE_PAGE}
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
- Linux (pacman): ${LINUX_PACMAN_URL:-$RELEASE_PAGE}
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
## macOS 安装提示(未知来源)
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
- `xattr -dr com.apple.quarantine "/Applications/WeFlow.app"`
- 执行后重新打开 WeFlow。
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
EOF

1
.gitignore vendored
View File

@@ -70,3 +70,4 @@ resources/wx_send
概述.md
pnpm-lock.yaml
/pnpm-workspace.yaml
wechat-research-site

View File

@@ -43,9 +43,21 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
- HTTP API 接口(供开发者集成)
- 查看完整能力清单:[详细功能](#详细功能清单)
## 支持平台与设备
| 平台 | 设备/架构 | 安装包 |
|------|----------|--------|
| Windows | Windows10+、x64amd64 | `.exe` |
| macOS | Apple SiliconM 系列arm64 | `.dmg` |
| Linux | x64 设备amd64 | `.AppImage``.tar.gz` |
## 快速开始
若你只想使用成品版本,可前往 Release 下载并安装。
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
## 详细功能清单
@@ -94,14 +106,8 @@ npm install
# 3. 运行应用(开发模式)
npm run dev
# 4. 打包可执行文件
npm run build
```
打包产物在 `release` 目录下。
## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架

View File

@@ -1,6 +1,6 @@
# WeFlow HTTP API / Push 文档
WeFlow 提供本地 HTTP API便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
WeFlow 提供本地 HTTP API已支持GET 和 POST请求,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
## 启用方式
@@ -11,17 +11,27 @@ WeFlow 提供本地 HTTP API便于外部脚本或工具读取聊天记录、
- 基础地址:`http://127.0.0.1:5031`
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
**状态记忆**API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
## 鉴权规范
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
2. **Query 参数**: `?access_token=<您的Token>`SSE 长连接推荐此方式)
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
## 接口列表
- `GET /health`
- `GET /api/v1/health`
- `GET /api/v1/push/messages`
- `GET /api/v1/messages`
- `GET /api/v1/messages/new`
- `GET /api/v1/sessions`
- `GET /api/v1/contacts`
- `GET /api/v1/group-members`
- `GET /api/v1/media/*`
- `GET|POST /health`
- `GET|POST /api/v1/health`
- `GET|POST /api/v1/push/messages`
- `GET|POST /api/v1/messages`
- `GET|POST /api/v1/messages/new`
- `GET|POST /api/v1/sessions`
- `GET|POST /api/v1/contacts`
- `GET|POST /api/v1/group-members`
- `GET|POST /api/v1/media/*`
---
@@ -80,7 +90,7 @@ GET /api/v1/push/messages
### 示例
```bash
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
```
示例事件:
@@ -94,6 +104,8 @@ data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123
## 3. 获取消息
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
**请求**
@@ -231,6 +243,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
## 4. 获取会话列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求**
```http
@@ -276,6 +290,8 @@ GET /api/v1/sessions
## 5. 获取联系人列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求**
```http
@@ -325,6 +341,8 @@ GET /api/v1/contacts
## 6. 获取群成员列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
**请求**
@@ -417,6 +435,8 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
## 7. 访问导出媒体
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
**请求**
@@ -461,19 +481,23 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
### PowerShell
```powershell
Invoke-RestMethod http://127.0.0.1:5031/health
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
```
### cURL
```bash
curl http://127.0.0.1:5031/health
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三"
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
# GET 带 Token Header
curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
# POST 带 JSON Body
curl -X POST http://127.0.0.1:5031/api/v1/messages \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"talker": "xxx@chatroom", "chatlab": true}'
```
### Python
@@ -482,19 +506,21 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
import requests
BASE_URL = "http://127.0.0.1:5031"
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
messages = requests.get(
f"{BASE_URL}/api/v1/messages",
params={"talker": "xxx@chatroom", "limit": 50}
# POST 方式获取消息
messages = requests.post(
f"{BASE_URL}/api/v1/messages",
json={"talker": "xxx@chatroom", "limit": 50},
headers=headers
).json()
# GET 方式获取群成员
members = requests.get(
f"{BASE_URL}/api/v1/group-members",
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
headers=headers
).json()
print(messages)
print(members)
```
---

View File

@@ -36,6 +36,10 @@ import { messagePushService } from './services/messagePushService'
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'
}
const AUTO_UPDATE_ENABLED =
process.env.AUTO_UPDATE_ENABLED === 'true' ||
process.env.AUTO_UPDATE_ENABLED === '1' ||
@@ -122,6 +126,81 @@ let isDownloadInProgress = false
let downloadProgressHandler: ((progress: any) => void) | null = null
let downloadedHandler: (() => void) | null = null
const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => {
const merged = (() => {
if (typeof rawReleaseNotes === 'string') {
return rawReleaseNotes
}
if (Array.isArray(rawReleaseNotes)) {
return rawReleaseNotes
.map((item) => {
if (!item || typeof item !== 'object') return ''
const note = (item as { note?: unknown }).note
return typeof note === 'string' ? note : ''
})
.filter(Boolean)
.join('\n\n')
}
return ''
})()
if (!merged.trim()) return ''
const shouldStripReleaseSection = (headingRaw: string): boolean => {
const heading = headingRaw.trim().toLowerCase()
if (heading === '下载' || heading === 'download') return true
const compactHeading = heading.replace(/\s+/g, '')
if (compactHeading.startsWith('macos安装提示')) return true
if (compactHeading.startsWith('mac安装提示')) return true
return false
}
// 兼容 electron-updater 直接返回 HTML 的场景
const removeDownloadSectionFromHtml = (input: string): string => {
return input
.replace(
/<h[1-6][^>]*>\s*(?:下载|download)\s*<\/h[1-6]>\s*[\s\S]*?(?=<h[1-6]\b|$)/gi,
''
)
.replace(
/<h[1-6][^>]*>\s*(?:mac\s*os|mac)\s*安装提示(?:\s*[(]\s*未知来源\s*[)])?\s*<\/h[1-6]>\s*[\s\S]*?(?=<h[1-6]\b|$)/gi,
''
)
}
// 兼容 Markdown 场景Action 最终 release note 模板)
const removeDownloadSectionFromMarkdown = (input: string): string => {
const lines = input.split(/\r?\n/)
const output: string[] = []
let skipSection = false
for (const line of lines) {
const headingMatch = line.match(/^\s*#{1,6}\s*(.+?)\s*$/)
if (headingMatch) {
if (shouldStripReleaseSection(headingMatch[1])) {
skipSection = true
continue
}
if (skipSection) {
skipSection = false
}
}
if (!skipSection) {
output.push(line)
}
}
return output.join('\n')
}
const cleaned = removeDownloadSectionFromMarkdown(removeDownloadSectionFromHtml(merged))
.replace(/\n{3,}/g, '\n\n')
.trim()
return cleaned
}
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
@@ -1043,6 +1122,13 @@ function registerIpcHandlers() {
return app.getVersion()
})
ipcMain.handle('app:checkWayland', async () => {
if (process.platform !== 'linux') return false;
const sessionType = process.env.XDG_SESSION_TYPE?.toLowerCase();
return Boolean(process.env.WAYLAND_DISPLAY || sessionType === 'wayland');
})
ipcMain.handle('log:getPath', async () => {
return join(app.getPath('userData'), 'logs', 'wcdb.log')
})
@@ -1114,7 +1200,7 @@ function registerIpcHandlers() {
return {
hasUpdate: true,
version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes as string || ''
releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
}
}
}
@@ -1459,8 +1545,8 @@ function registerIpcHandlers() {
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
})
ipcMain.handle('chat:getContacts', async () => {
return await chatService.getContacts()
ipcMain.handle('chat:getContacts', async (_, options?: { lite?: boolean }) => {
return await chatService.getContacts(options)
})
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
@@ -2071,6 +2157,13 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
})
ipcMain.handle(
'groupAnalytics:getGroupMemberAnalytics',
async (_, chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => {
return groupAnalyticsService.getGroupMemberAnalytics(chatroomId, memberUsername, startTime, endTime)
}
)
ipcMain.handle(
'groupAnalytics:getGroupMemberMessages',
async (
@@ -2519,8 +2612,9 @@ function registerIpcHandlers() {
})
// HTTP API 服务
ipcMain.handle('http:start', async (_, port?: number) => {
return httpService.start(port || 5031)
ipcMain.handle('http:start', async (_, port?: number, host?: string) => {
const bindHost = typeof host === 'string' && host.trim() ? host.trim() : '127.0.0.1'
return httpService.start(port || 5031, bindHost)
})
ipcMain.handle('http:stop', async () => {
@@ -2567,7 +2661,7 @@ function checkForUpdatesOnStartup() {
// 通知渲染进程有新版本
mainWindow.webContents.send('app:updateAvailable', {
version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes || ''
releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
})
}
}
@@ -2739,6 +2833,8 @@ app.whenReady().then(async () => {
// 启动时检测更新(不阻塞启动)
checkForUpdatesOnStartup()
await httpService.autoStart()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow()

View File

@@ -63,7 +63,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
}
},
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
},
// 日志
@@ -224,7 +225,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
},
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getContacts: (options?: { lite?: boolean }) => ipcRenderer.invoke('chat:getContacts', options),
getMessage: (sessionId: string, localId: number) =>
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
@@ -296,6 +297,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
getGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
@@ -421,7 +423,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
// HTTP API 服务
http: {
start: (port?: number) => ipcRenderer.invoke('http:start', port),
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
stop: () => ipcRenderer.invoke('http:stop'),
status: () => ipcRenderer.invoke('http:status')
}

View File

@@ -6,7 +6,7 @@ import * as https from 'https'
import * as http from 'http'
import * as fzstd from 'fzstd'
import * as crypto from 'crypto'
import { app, BrowserWindow } from 'electron'
import { app, BrowserWindow, dialog } from 'electron'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService'
@@ -16,6 +16,7 @@ import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from '
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
import { voiceTranscribeService } from './voiceTranscribeService'
import { ImageDecryptService } from './imageDecryptService'
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
import { LRUCache } from '../utils/LRUCache.js'
export interface ChatSession {
@@ -153,10 +154,17 @@ export interface ContactInfo {
remark?: string
nickname?: string
alias?: string
labels?: string[]
detailDescription?: string
region?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
interface GetContactsOptions {
lite?: boolean
}
interface ExportSessionStats {
totalMessages: number
voiceMessages: number
@@ -292,6 +300,22 @@ class ChatService {
private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000
private groupMyMessageCountCacheScope = ''
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>()
private initFailureDialogShown = false
private readonly contactExtendedFieldCandidates = [
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList',
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription', 'signature', 'sign',
'country', 'province', 'city', 'region',
'profile', 'introduction', 'phone', 'mobile', 'telephone', 'tel', 'vcard', 'card_info', 'cardInfo',
'extra_buffer', 'extraBuffer'
]
private readonly contactExtendedFieldCandidateSet = new Set(this.contactExtendedFieldCandidates.map((name) => name.toLowerCase()))
private contactExtendedSelectableColumns: string[] | null = null
private contactLabelNameMapCache: Map<number, string> | null = null
private contactLabelNameMapCacheAt = 0
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
private readonly slowGetContactsLogThresholdMs = 1200
constructor() {
this.configService = new ConfigService()
@@ -338,6 +362,55 @@ class ChatService {
return true
}
private extractErrorCode(message?: string): number | null {
const text = String(message || '').trim()
if (!text) return null
const match = text.match(/(?:错误码\s*[:]\s*|\()(-?\d{2,6})(?:\)|\b)/)
if (!match) return null
const parsed = Number(match[1])
return Number.isFinite(parsed) ? parsed : null
}
private toCodeOnlyMessage(rawMessage?: string, fallbackCode = -3999): string {
const code = this.extractErrorCode(rawMessage) ?? fallbackCode
return `错误码: ${code}`
}
private async maybeShowInitFailureDialog(errorMessage: string): Promise<void> {
if (!app.isPackaged) return
if (this.initFailureDialogShown) return
const code = this.extractErrorCode(errorMessage)
if (code === null) return
const isSecurityCode =
code === -101 ||
code === -102 ||
code === -2299 ||
code === -2301 ||
code === -2302 ||
code === -1006 ||
(code <= -2201 && code >= -2212)
if (!isSecurityCode) return
this.initFailureDialogShown = true
const detail = [
`错误码: ${code}`
].join('\n')
try {
await dialog.showMessageBox({
type: 'error',
title: 'WeFlow 启动失败',
message: '启动失败,请反馈错误码。',
detail,
buttons: ['确定'],
noLink: true
})
} catch {
// 弹窗失败不阻断主流程
}
}
/**
* 连接数据库
*/
@@ -362,7 +435,9 @@ class ChatService {
const cleanedWxid = this.cleanAccountDirName(wxid)
const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!openOk) {
return { success: false, error: 'WCDB 打开失败,请检查路径和密钥' }
const detailedError = this.toCodeOnlyMessage(await wcdbService.getLastInitError())
await this.maybeShowInitFailureDialog(detailedError)
return { success: false, error: detailedError }
}
this.connected = true
@@ -376,7 +451,7 @@ class ChatService {
return { success: true }
} catch (e) {
console.error('ChatService: 连接数据库失败:', e)
return { success: false, error: String(e) }
return { success: false, error: this.toCodeOnlyMessage(String(e), -3998) }
}
}
@@ -1215,25 +1290,61 @@ class ChatService {
/**
* 获取通讯录列表
*/
async getContacts(): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
async getContacts(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
const mode: 'lite' | 'full' = options?.lite ? 'lite' : 'full'
const inFlight = this.contactsLoadInFlight
if (inFlight && (inFlight.mode === mode || (mode === 'lite' && inFlight.mode === 'full'))) {
return await inFlight.promise
}
const promise = this.getContactsInternal(options)
this.contactsLoadInFlight = { mode, promise }
try {
return await promise
} finally {
if (this.contactsLoadInFlight?.promise === promise) {
this.contactsLoadInFlight = null
}
}
}
private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
const isLiteMode = options?.lite === true
const startedAt = Date.now()
const stageDurations: Array<{ stage: string; ms: number }> = []
const captureStage = (stage: string, stageStartedAt: number) => {
stageDurations.push({ stage, ms: Date.now() - stageStartedAt })
}
try {
const connectStartedAt = Date.now()
const connectResult = await this.ensureConnected()
captureStage('ensureConnected', connectStartedAt)
if (!connectResult.success) {
return { success: false, error: connectResult.error }
}
const contactsCompactStartedAt = Date.now()
const contactResult = await wcdbService.getContactsCompact()
captureStage('getContactsCompact', contactsCompactStartedAt)
if (!contactResult.success || !contactResult.contacts) {
console.error('查询联系人失败:', contactResult.error)
return { success: false, error: contactResult.error || '查询联系人失败' }
}
let rows = contactResult.contacts as Record<string, any>[]
if (!isLiteMode) {
const hydrateStartedAt = Date.now()
rows = await this.hydrateContactsWithExtendedFields(rows)
captureStage('hydrateContactsWithExtendedFields', hydrateStartedAt)
}
const rows = contactResult.contacts as Record<string, any>[]
// 获取会话表的最后联系时间用于排序
const sessionsStartedAt = Date.now()
const lastContactTimeMap = new Map<string, number>()
const sessionResult = await wcdbService.getSessions()
captureStage('getSessions', sessionsStartedAt)
if (sessionResult.success && sessionResult.sessions) {
for (const session of sessionResult.sessions as any[]) {
const username = session.username || session.user_name || session.userName || ''
@@ -1245,9 +1356,14 @@ class ChatService {
}
// 转换为ContactInfo
const transformStartedAt = Date.now()
const contacts: (ContactInfo & { lastContactTime: number })[] = []
const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
let contactLabelNameMap = new Map<number, string>()
if (!isLiteMode) {
const labelMapStartedAt = Date.now()
contactLabelNameMap = await this.getContactLabelNameMap()
captureStage('getContactLabelNameMap', labelMapStartedAt)
}
for (const row of rows) {
const username = String(row.username || '').trim()
@@ -1261,7 +1377,7 @@ class ChatService {
type = 'group'
} else if (username.startsWith('gh_')) {
type = 'official'
} else if (localType === 1 && !excludeNames.has(username)) {
} else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
type = 'friend'
} else if (localType === 0 && quanPin) {
type = 'former_friend'
@@ -1270,6 +1386,9 @@ class ChatService {
}
const displayName = row.remark || row.nick_name || row.alias || username
const labels = isLiteMode ? [] : this.parseContactLabels(row, contactLabelNameMap)
const detailDescription = isLiteMode ? '' : this.getContactSignature(row)
const region = isLiteMode ? '' : this.getContactRegion(row)
contacts.push({
username,
@@ -1277,16 +1396,19 @@ class ChatService {
remark: row.remark || undefined,
nickname: row.nick_name || undefined,
alias: row.alias || undefined,
labels: labels.length > 0 ? labels : undefined,
detailDescription: detailDescription || undefined,
region: region || undefined,
avatarUrl: undefined,
type,
lastContactTime: lastContactTimeMap.get(username) || 0
})
}
captureStage('transformContacts', transformStartedAt)
// 按最近联系时间排序
const sortStartedAt = Date.now()
contacts.sort((a, b) => {
const timeA = a.lastContactTime || 0
const timeB = b.lastContactTime || 0
@@ -1295,13 +1417,22 @@ class ChatService {
}
if (timeA && !timeB) return -1
if (!timeA && timeB) return 1
return a.displayName.localeCompare(b.displayName, 'zh-CN')
return this.contactDisplayNameCollator.compare(a.displayName, b.displayName)
})
captureStage('sortContacts', sortStartedAt)
// 移除临时的lastContactTime字段
const finalizeStartedAt = Date.now()
const result = contacts.map(({ lastContactTime, ...rest }) => rest)
captureStage('finalizeResult', finalizeStartedAt)
const totalMs = Date.now() - startedAt
if (totalMs >= this.slowGetContactsLogThresholdMs) {
const stageSummary = stageDurations
.map((item) => `${item.stage}=${item.ms}ms`)
.join(', ')
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`)
}
return { success: true, contacts: result }
} catch (e) {
console.error('ChatService: 获取通讯录失败:', e)
@@ -1828,6 +1959,568 @@ class ChatService {
return Number.isFinite(parsed) ? parsed : fallback
}
private hasAnyContactExtendedFieldKey(row: Record<string, any>): boolean {
for (const key of Object.keys(row || {})) {
if (this.contactExtendedFieldCandidateSet.has(String(key || '').toLowerCase())) {
return true
}
}
return false
}
private async hydrateContactsWithExtendedFields(rows: Record<string, any>[]): Promise<Record<string, any>[]> {
if (!Array.isArray(rows) || rows.length === 0) return rows
const hasAnyExtendedFieldKey = rows.some((row) => this.hasAnyContactExtendedFieldKey(row || {}))
if (hasAnyExtendedFieldKey) {
// wcdb_get_contacts_compact 可能只给“部分联系人”返回 extra_buffer。
// 只有在每一行都能拿到可解析的 extra_buffer 时才跳过补偿查询。
const allRowsHaveUsableExtraBuffer = rows.every((row) => this.toExtraBufferBytes(row || {}) !== null)
if (allRowsHaveUsableExtraBuffer) return rows
}
try {
let selectableColumns = this.contactExtendedSelectableColumns
if (!selectableColumns) {
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact)')
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows)) {
return rows
}
const availableColumns = new Map<string, string>()
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
const name = String(rawName || '').trim()
if (!name) continue
availableColumns.set(name.toLowerCase(), name)
}
const resolvedColumns: string[] = []
const seenColumns = new Set<string>()
for (const candidate of this.contactExtendedFieldCandidates) {
const actual = availableColumns.get(candidate.toLowerCase())
if (!actual) continue
const normalized = actual.toLowerCase()
if (seenColumns.has(normalized)) continue
seenColumns.add(normalized)
resolvedColumns.push(actual)
}
this.contactExtendedSelectableColumns = resolvedColumns
selectableColumns = resolvedColumns
}
if (!selectableColumns || 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 != ''`
const extendedResult = await wcdbService.execQuery('contact', null, sql)
if (!extendedResult.success || !Array.isArray(extendedResult.rows) || extendedResult.rows.length === 0) {
return rows
}
const extendedByUsername = new Map<string, Record<string, any>>()
for (const extendedRow of extendedResult.rows as Record<string, any>[]) {
const username = String(extendedRow.username || '').trim()
if (!username) continue
extendedByUsername.set(username, extendedRow)
}
if (extendedByUsername.size === 0) return rows
return rows.map((row) => {
const username = String(row.username || row.user_name || row.userName || '').trim()
if (!username) return row
const extended = extendedByUsername.get(username)
if (!extended) return row
return {
...extended,
...row
}
})
} catch (error) {
console.warn('联系人扩展字段补偿查询失败:', error)
return rows
}
}
private async getContactLabelNameMap(): Promise<Map<number, string>> {
const now = Date.now()
if (this.contactLabelNameMapCache && now - this.contactLabelNameMapCacheAt <= this.contactLabelNameMapCacheTtlMs) {
return new Map(this.contactLabelNameMapCache)
}
const labelMap = new Map<number, string>()
try {
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact_label)')
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows) || tableInfoResult.rows.length === 0) {
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return labelMap
}
const availableColumns = new Map<string, string>()
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
const name = String(rawName || '').trim()
if (!name) continue
availableColumns.set(name.toLowerCase(), name)
}
const pickColumn = (candidates: string[]): string | null => {
for (const candidate of candidates) {
const actual = availableColumns.get(candidate.toLowerCase())
if (actual) return actual
}
return null
}
const idColumn = pickColumn(['label_id_', 'label_id', 'labelId', 'labelid', 'id'])
const nameColumn = pickColumn(['label_name_', 'label_name', 'labelName', 'labelname', 'name'])
if (!idColumn || !nameColumn) {
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return labelMap
}
const sql = `SELECT ${this.quoteSqlIdentifier(idColumn)} AS label_id, ${this.quoteSqlIdentifier(nameColumn)} AS label_name FROM contact_label`
const result = await wcdbService.execQuery('contact', null, sql)
if (result.success && Array.isArray(result.rows)) {
for (const row of result.rows as Record<string, any>[]) {
const id = Number(String(row.label_id ?? row.labelId ?? '').trim())
const name = String(row.label_name ?? row.labelName ?? '').trim()
if (Number.isFinite(id) && id > 0 && name) {
labelMap.set(Math.floor(id), name)
}
}
}
} catch (error) {
console.warn('读取 contact_label 失败:', error)
}
this.contactLabelNameMapCache = labelMap
this.contactLabelNameMapCacheAt = now
return new Map(labelMap)
}
private toExtraBufferBytes(row: Record<string, any>): Buffer | null {
const raw = this.getRowField(row, ['extra_buffer', 'extraBuffer'])
if (raw === undefined || raw === null) return null
if (Buffer.isBuffer(raw)) return raw.length > 0 ? raw : null
if (raw instanceof Uint8Array) return raw.length > 0 ? Buffer.from(raw) : null
if (Array.isArray(raw)) {
const bytes = Buffer.from(raw)
return bytes.length > 0 ? bytes : null
}
const text = String(raw || '').trim()
if (!text) return null
const compact = text.replace(/\s+/g, '')
if (compact.length >= 2 && compact.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(compact)) {
try {
const bytes = Buffer.from(compact, 'hex')
return bytes.length > 0 ? bytes : null
} catch {
return null
}
}
return null
}
private readProtoVarint(buffer: Buffer, offset: number): { value: number; nextOffset: number } | null {
if (!buffer || offset < 0 || offset >= buffer.length) return null
let value = 0
let shift = 0
let index = offset
while (index < buffer.length) {
const byte = buffer[index]
index += 1
value += (byte & 0x7f) * Math.pow(2, shift)
if ((byte & 0x80) === 0) {
return { value, nextOffset: index }
}
shift += 7
if (shift > 56) return null
}
return null
}
private extractExtraBufferTopLevelFieldStrings(row: Record<string, any>, targetField: number): string[] {
const bytes = this.toExtraBufferBytes(row)
if (!bytes || !Number.isFinite(targetField) || targetField <= 0) return []
const values: string[] = []
let offset = 0
while (offset < bytes.length) {
const tagResult = this.readProtoVarint(bytes, offset)
if (!tagResult) break
offset = tagResult.nextOffset
const fieldNumber = Math.floor(tagResult.value / 8)
const wireType = tagResult.value & 0x07
if (wireType === 0) {
const varint = this.readProtoVarint(bytes, offset)
if (!varint) break
offset = varint.nextOffset
continue
}
if (wireType === 1) {
if (offset + 8 > bytes.length) break
offset += 8
continue
}
if (wireType === 2) {
const lengthResult = this.readProtoVarint(bytes, offset)
if (!lengthResult) break
const payloadLength = Math.floor(lengthResult.value)
offset = lengthResult.nextOffset
if (payloadLength < 0 || offset + payloadLength > bytes.length) break
const payload = bytes.subarray(offset, offset + payloadLength)
offset += payloadLength
if (fieldNumber === targetField) {
const text = payload.toString('utf-8').replace(/\u0000/g, '').trim()
if (text) values.push(text)
}
continue
}
if (wireType === 5) {
if (offset + 4 > bytes.length) break
offset += 4
continue
}
break
}
return values
}
private parseContactLabelsFromExtraBuffer(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
const labelNames: string[] = []
const seen = new Set<string>()
const texts = this.extractExtraBufferTopLevelFieldStrings(row, 30)
for (const text of texts) {
const matches = text.match(/\d+/g) || []
for (const match of matches) {
const id = Number(match)
if (!Number.isFinite(id) || id <= 0) continue
const labelName = labelNameMap?.get(Math.floor(id))
if (!labelName) continue
if (seen.has(labelName)) continue
seen.add(labelName)
labelNames.push(labelName)
}
}
return labelNames
}
private parseContactLabels(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
const raw = this.getRowField(row, [
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList'
])
const normalizedFromValue = (value: unknown): string[] => {
if (Array.isArray(value)) {
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
const text = String(value || '').trim()
if (!text) return []
return Array.from(new Set(
text
.replace(/[;、|]+/g, ',')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
))
}
const direct = normalizedFromValue(raw)
if (direct.length > 0) return direct
for (const [key, value] of Object.entries(row)) {
const normalizedKey = key.toLowerCase()
if (!normalizedKey.includes('label') && !normalizedKey.includes('tag')) continue
if (normalizedKey.includes('img') || normalizedKey.includes('head')) continue
const fallback = normalizedFromValue(value)
if (fallback.length > 0) return fallback
}
const extraBufferLabels = this.parseContactLabelsFromExtraBuffer(row, labelNameMap)
if (extraBufferLabels.length > 0) return extraBufferLabels
return []
}
private getContactSignature(row: Record<string, any>): string {
const normalize = (raw: unknown): string => {
const text = String(raw || '').replace(/\u0000/g, '').trim()
if (!text) return ''
const lower = text.toLowerCase()
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
return ''
}
return text
}
const value = this.getRowField(row, [
'signature', 'sign', 'personal_signature', 'personalSignature', 'profile', 'introduction',
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription'
])
const direct = normalize(value)
if (direct) return direct
for (const [key, rawValue] of Object.entries(row)) {
const normalizedKey = key.toLowerCase()
const isCandidate =
normalizedKey.includes('sign') ||
normalizedKey.includes('signature') ||
normalizedKey.includes('profile') ||
normalizedKey.includes('intro') ||
normalizedKey.includes('description') ||
normalizedKey.includes('detail') ||
normalizedKey.includes('desc')
if (!isCandidate) continue
if (
normalizedKey.includes('avatar') ||
normalizedKey.includes('img') ||
normalizedKey.includes('head') ||
normalizedKey.includes('label') ||
normalizedKey.includes('tag')
) continue
const text = normalize(rawValue)
if (text) return text
}
// contact.extra_buffer field 4: 个性签名兜底
const signatures = this.extractExtraBufferTopLevelFieldStrings(row, 4)
for (const signature of signatures) {
const text = normalize(signature)
if (!text) continue
return text
}
return ''
}
private normalizeContactRegionPart(raw: unknown): string {
const text = String(raw || '').replace(/\u0000/g, '').trim()
if (!text) return ''
const lower = text.toLowerCase()
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
return ''
}
return text
}
private normalizeRegionLookupKey(raw: string): string {
return String(raw || '')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '')
}
private buildRegionLookupCandidates(raw: string): string[] {
const normalized = this.normalizeRegionLookupKey(raw)
if (!normalized) return []
const candidates = new Set<string>([normalized])
const withoutTrailingDigits = normalized.replace(/\d+$/g, '')
if (withoutTrailingDigits) candidates.add(withoutTrailingDigits)
return Array.from(candidates)
}
private normalizeChineseProvinceName(raw: string): string {
const text = String(raw || '').trim()
if (!text) return ''
return text
.replace(/特别行政区$/g, '')
.replace(/维吾尔自治区$/g, '')
.replace(/壮族自治区$/g, '')
.replace(/回族自治区$/g, '')
.replace(/自治区$/g, '')
.replace(/省$/g, '')
.replace(/市$/g, '')
.trim()
}
private normalizeChineseCityName(raw: string): string {
const text = String(raw || '').trim()
if (!text) return ''
return text
.replace(/特别行政区$/g, '')
.replace(/自治州$/g, '')
.replace(/地区$/g, '')
.replace(/盟$/g, '')
.replace(/林区$/g, '')
.replace(/市$/g, '')
.trim()
}
private resolveProvinceLookupKey(raw: string): string {
const candidates = this.buildRegionLookupCandidates(raw)
if (candidates.length === 0) return ''
for (const candidate of candidates) {
const byName = CONTACT_REGION_LOOKUP_DATA.provinceKeyByName[candidate]
if (byName) return byName
if (CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate]) return candidate
}
return candidates[0]
}
private toChineseCountryName(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
for (const candidate of candidates) {
const mapped = CONTACT_REGION_LOOKUP_DATA.countryNameByKey[candidate]
if (mapped) return mapped
}
return text
}
private toChineseProvinceName(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
if (candidates.length === 0) return text
const provinceKey = this.resolveProvinceLookupKey(text)
const mappedFromCandidates = candidates
.map((candidate) => CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate])
.find(Boolean)
const mapped = CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[provinceKey] || mappedFromCandidates
if (mapped) return mapped
if (/[\u4e00-\u9fa5]/.test(text)) {
return this.normalizeChineseProvinceName(text) || text
}
return text
}
private toChineseCityName(raw: string, provinceRaw?: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const candidates = this.buildRegionLookupCandidates(text)
if (candidates.length === 0) return text
const provinceKey = this.resolveProvinceLookupKey(String(provinceRaw || ''))
if (provinceKey) {
const byProvince = CONTACT_REGION_LOOKUP_DATA.cityNameByProvinceKey[provinceKey]
if (byProvince) {
for (const candidate of candidates) {
const mappedInProvince = byProvince[candidate]
if (mappedInProvince) return mappedInProvince
}
}
}
for (const candidate of candidates) {
const mapped = CONTACT_REGION_LOOKUP_DATA.cityNameByKey[candidate]
if (mapped) return mapped
}
if (/[\u4e00-\u9fa5]/.test(text)) {
return this.normalizeChineseCityName(text) || text
}
return text
}
private toChineseRegionText(raw: string): string {
const text = this.normalizeContactRegionPart(raw)
if (!text) return ''
const tokens = text
.split(/[\s,,、/|·]+/)
.map((item) => this.normalizeContactRegionPart(item))
.filter(Boolean)
if (tokens.length === 0) return text
let provinceContext = ''
const mapped = tokens.map((token) => {
const country = this.toChineseCountryName(token)
if (country !== token) return country
const province = this.toChineseProvinceName(token)
if (province !== token) {
provinceContext = province
return province
}
const city = this.toChineseCityName(token, provinceContext)
if (city !== token) return city
return token
})
return mapped.join(' ').trim()
}
private shouldHideCountryInRegion(country: string, hasProvinceOrCity: boolean): boolean {
if (!country) return true
const normalized = country.toLowerCase()
if (normalized === 'cn' || normalized === 'chn' || normalized === 'china' || normalized === '中国') {
return hasProvinceOrCity
}
return false
}
private getContactRegion(row: Record<string, any>): string {
const pickByTokens = (tokens: string[]): string => {
for (const [key, value] of Object.entries(row || {})) {
const normalizedKey = String(key || '').toLowerCase()
if (!normalizedKey) continue
if (normalizedKey.includes('avatar') || normalizedKey.includes('img') || normalizedKey.includes('head')) continue
if (!tokens.some((token) => normalizedKey.includes(token))) continue
const text = this.normalizeContactRegionPart(value)
if (text) return text
}
return ''
}
const directCountry = this.normalizeContactRegionPart(this.getRowField(row, ['country', 'Country'])) || pickByTokens(['country'])
const directProvince = this.normalizeContactRegionPart(this.getRowField(row, ['province', 'Province'])) || pickByTokens(['province'])
const directCity = this.normalizeContactRegionPart(this.getRowField(row, ['city', 'City'])) || pickByTokens(['city'])
const directRegion =
this.normalizeContactRegionPart(this.getRowField(row, ['region', 'Region', 'location', 'area'])) ||
pickByTokens(['region', 'location', 'area', 'addr', 'address'])
if (directRegion) {
const normalizedRegion = this.toChineseRegionText(directRegion)
const parts = normalizedRegion
.split(/\s+/)
.map((item) => this.normalizeContactRegionPart(item))
.filter(Boolean)
if (parts.length > 1 && this.shouldHideCountryInRegion(parts[0], true)) {
return parts.slice(1).join(' ').trim()
}
return normalizedRegion
}
const fallbackCountry = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 5)[0] || '')
const fallbackProvince = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 6)[0] || '')
const fallbackCity = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 7)[0] || '')
const country = this.toChineseCountryName(directCountry || fallbackCountry)
const province = this.toChineseProvinceName(directProvince || fallbackProvince)
const city = this.toChineseCityName(directCity || fallbackCity, directProvince || fallbackProvince)
const hasProvinceOrCity = Boolean(province || city)
const parts: string[] = []
if (!this.shouldHideCountryInRegion(country, hasProvinceOrCity)) {
parts.push(country)
}
if (province) {
parts.push(province)
}
if (city && city !== province) {
parts.push(city)
}
return parts.join(' ').trim()
}
private normalizeUnsignedIntegerToken(raw: any): string | undefined {
if (raw === undefined || raw === null || raw === '') return undefined
@@ -5003,7 +5696,17 @@ class ChatService {
const contact = await this.getContact(username)
const avatarResult = await wcdbService.getAvatarUrls([username])
const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
if (!this.isValidAvatarUrl(avatarUrl)) {
avatarUrl = undefined
}
if (!avatarUrl) {
const headImageAvatars = await this.getAvatarsFromHeadImageDb([username])
const fallbackAvatarUrl = headImageAvatars[username]
if (this.isValidAvatarUrl(fallbackAvatarUrl)) {
avatarUrl = fallbackAvatarUrl
}
}
const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username
const cacheEntry: ContactCacheEntry = {
avatarUrl,
@@ -5034,14 +5737,35 @@ class ChatService {
}
// 如果是群聊,尝试获取群昵称
let groupNicknames: Record<string, string> = {}
const groupNicknames = new Map<string, string>()
if (chatroomId.endsWith('@chatroom')) {
const nickResult = await wcdbService.getGroupNicknames(chatroomId)
if (nickResult.success && nickResult.nicknames) {
groupNicknames = nickResult.nicknames
const nicknameBuckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nickResult.nicknames)) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
const slot = nicknameBuckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
nicknameBuckets.set(memberId, new Set([nickname]))
}
}
for (const [memberId, nicknameSet] of nicknameBuckets.entries()) {
if (nicknameSet.size !== 1) continue
groupNicknames.set(memberId, Array.from(nicknameSet)[0])
}
}
}
const lookupGroupNickname = (username?: string | null): string => {
const key = String(username || '').trim().toLowerCase()
if (!key) return ''
return groupNicknames.get(key) || ''
}
// 获取当前用户 wxid用于识别"自己"
const myWxid = this.configService.get('myWxid')
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
@@ -5051,7 +5775,7 @@ class ChatService {
// 特判如果是当前用户自己contact 表通常不包含自己)
if (myWxid && (username === myWxid || username === cleanedMyWxid)) {
// 先查群昵称中是否有自己
const myGroupNick = groupNicknames[username]
const myGroupNick = lookupGroupNickname(username) || lookupGroupNickname(myWxid)
if (myGroupNick) return myGroupNick
// 尝试从缓存获取自己的昵称
const cached = this.avatarCache.get(username) || this.avatarCache.get(myWxid)
@@ -5060,7 +5784,7 @@ class ChatService {
}
// 先查群昵称
const groupNick = groupNicknames[username]
const groupNick = lookupGroupNickname(username)
if (groupNick) return groupNick
// 再查联系人信息
@@ -5471,6 +6195,13 @@ class ChatService {
avatarUrl = avatarCandidate
}
}
if (!avatarUrl) {
const headImageAvatars = await this.getAvatarsFromHeadImageDb([normalizedSessionId])
const fallbackAvatarUrl = headImageAvatars[normalizedSessionId]
if (this.isValidAvatarUrl(fallbackAvatarUrl)) {
avatarUrl = fallbackAvatarUrl
}
}
if (!Number.isFinite(messageCount)) {
messageCount = messageCountResult.status === 'fulfilled' &&

View File

@@ -34,6 +34,7 @@ interface ConfigSchema {
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
exportDefaultImageDeepSearchOnMiss: boolean
analyticsExcludedUsernames: string[]
// 安全相关
@@ -51,12 +52,17 @@ interface ConfigSchema {
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
messagePushEnabled: boolean
httpApiEnabled: boolean
httpApiPort: number
httpApiHost: string
httpApiToken: string
windowCloseBehavior: 'ask' | 'tray' | 'quit'
quoteLayout: 'quote-top' | 'quote-bottom'
wordCloudExcludeWords: string[]
}
// 需要 safeStorage 加密的字段(普通模式)
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken'])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
@@ -106,6 +112,7 @@ export class ConfigService {
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
exportDefaultImageDeepSearchOnMiss: true,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
@@ -116,8 +123,13 @@ export class ConfigService {
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
httpApiToken: '',
httpApiEnabled: false,
httpApiPort: 5031,
httpApiHost: '127.0.0.1',
messagePushEnabled: false,
windowCloseBehavior: 'ask',
quoteLayout: 'quote-top',
wordCloudExcludeWords: []
}
@@ -658,11 +670,9 @@ export class ConfigService {
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
return true
}
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
return false
}
// === 工具方法 ===
@@ -688,8 +698,16 @@ export class ConfigService {
}
}
private getUserDataPath(): string {
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
if (workerUserDataPath) {
return workerUserDataPath
}
return app?.getPath?.('userData') || process.cwd()
}
getCacheBasePath(): string {
return join(app.getPath('userData'), 'cache')
return join(this.getUserDataPath(), 'cache')
}
getAll(): Partial<ConfigSchema> {

View File

@@ -93,6 +93,9 @@ class ContactExportService {
displayName: c.displayName,
remark: c.remark,
nickname: c.nickname,
alias: c.alias,
labels: Array.isArray(c.labels) ? c.labels : [],
detailDescription: c.detailDescription,
type: c.type
}))
}
@@ -103,12 +106,15 @@ class ContactExportService {
* 导出为CSV格式
*/
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
const headers = ['用户名', '显示名称', '备注', '昵称', '微信号', '标签', '详细描述', '类型']
const rows = contacts.map(c => [
c.username || '',
c.displayName || '',
c.remark || '',
c.nickname || '',
c.alias || '',
Array.isArray(c.labels) ? c.labels.join(' | ') : '',
c.detailDescription || '',
this.getTypeLabel(c.type)
])
@@ -137,9 +143,13 @@ class ContactExportService {
lines.push(`NICKNAME:${c.nickname}`)
}
// 备注
if (c.remark) {
lines.push(`NOTE:${c.remark}`)
const noteParts = [
c.remark ? String(c.remark) : '',
Array.isArray(c.labels) && c.labels.length > 0 ? `标签: ${c.labels.join(', ')}` : '',
c.detailDescription ? `详细描述: ${c.detailDescription}` : ''
].filter(Boolean)
if (noteParts.length > 0) {
lines.push(`NOTE:${noteParts.join('\\n')}`)
}
// 微信ID

File diff suppressed because it is too large Load Diff

View File

@@ -186,6 +186,33 @@ body {
word-break: break-word;
}
.quoted-message {
border-left: 3px solid rgba(79, 70, 229, 0.35);
background: rgba(79, 70, 229, 0.06);
border-radius: 12px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.message.sent .quoted-message {
background: rgba(37, 99, 235, 0.08);
border-left-color: rgba(37, 99, 235, 0.35);
}
.quoted-sender {
font-size: 12px;
color: #374151;
font-weight: 600;
}
.quoted-text {
font-size: 13px;
color: #4b5563;
word-break: break-word;
}
.message-link-card {
color: #2563eb;
text-decoration: underline;

View File

@@ -186,6 +186,33 @@ body {
word-break: break-word;
}
.quoted-message {
border-left: 3px solid rgba(79, 70, 229, 0.35);
background: rgba(79, 70, 229, 0.06);
border-radius: 12px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.message.sent .quoted-message {
background: rgba(37, 99, 235, 0.08);
border-left-color: rgba(37, 99, 235, 0.35);
}
.quoted-sender {
font-size: 12px;
color: #374151;
font-weight: 600;
}
.quoted-text {
font-size: 13px;
color: #4b5563;
word-break: break-word;
}
.message-link-card {
color: #2563eb;
text-decoration: underline;

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { chatService } from './chatService'
import type { Message } from './chatService'
import type { ChatStatistics } from './analyticsService'
export interface GroupChatInfo {
username: string
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
total: number
}
export interface GroupMemberAnalytics {
statistics: ChatStatistics
timeDistribution: Record<number, number>
commonPhrases?: Array<{ phrase: string; count: number }>
commonEmojis?: Array<{ emoji: string; count: number }>
}
export interface GroupMemberMessagesPage {
messages: Message[]
hasMore: boolean
@@ -257,34 +265,60 @@ class GroupAnalyticsService {
}
/**
* 从 DLL 获取群成员群昵称
* 从后端获取群成员群昵称,并在前端进行唯一性净化防串号。
*/
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
const nicknameMap = new Map<string, string>()
try {
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
if (dllResult.success && dllResult.nicknames) {
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
if (!dllResult.success || !dllResult.nicknames) {
return new Map<string, string>()
}
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
} catch (e) {
console.error('getGroupNicknamesForRoom dll error:', e)
return new Map<string, string>()
}
}
try {
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
if (!result.success || !result.extBuffer) {
return nicknameMap
private normalizeGroupNicknameIdentity(value: string): string {
const raw = String(value || '').trim()
if (!raw) return ''
return raw.toLowerCase()
}
private buildTrustedGroupNicknameMap(
entries: Iterable<[string, string]>,
candidates: string[] = []
): Map<string, string> {
const candidateSet = new Set(
this.buildGroupNicknameIdCandidates(candidates)
.map((id) => this.normalizeGroupNicknameIdentity(id))
.filter(Boolean)
)
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of entries) {
const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '')
if (!identity) continue
if (candidateSet.size > 0 && !candidateSet.has(identity)) continue
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
if (!nickname) continue
const slot = buckets.get(identity)
if (slot) {
slot.add(nickname)
} else {
buckets.set(identity, new Set([nickname]))
}
const extBuffer = this.decodeExtBuffer(result.extBuffer)
if (!extBuffer) return nicknameMap
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
return nicknameMap
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return nicknameMap
}
const trusted = new Map<string, string>()
for (const [identity, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted.set(identity, Array.from(nicknameSet)[0])
}
return trusted
}
private mergeGroupNicknameEntries(
@@ -475,6 +509,16 @@ class GroupAnalyticsService {
return Array.from(set)
}
private buildGroupNicknameIdCandidates(values: Array<string | undefined | null>): string[] {
const set = new Set<string>()
for (const rawValue of values) {
const raw = String(rawValue || '').trim()
if (!raw) continue
set.add(raw)
}
return Array.from(set)
}
private toNonNegativeInteger(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
@@ -663,30 +707,23 @@ class GroupAnalyticsService {
}
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
const idCandidates = this.buildIdCandidates(candidates)
const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
if (idCandidates.length === 0) return ''
let resolved = ''
for (const id of idCandidates) {
const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '')
if (exact) return exact
}
for (const id of idCandidates) {
const lower = id.toLowerCase()
let found = ''
let matched = 0
for (const [key, value] of groupNicknames.entries()) {
if (String(key || '').toLowerCase() !== lower) continue
const normalized = this.normalizeGroupNickname(value || '')
if (!normalized) continue
found = normalized
matched += 1
if (matched > 1) return ''
const normalizedId = this.normalizeGroupNicknameIdentity(id)
if (!normalizedId) continue
const candidateNickname = this.normalizeGroupNickname(groupNicknames.get(normalizedId) || '')
if (!candidateNickname) continue
if (!resolved) {
resolved = candidateNickname
continue
}
if (matched === 1 && found) return found
if (resolved !== candidateNickname) return ''
}
return ''
return resolved
}
private sanitizeWorksheetName(name: string): string {
@@ -768,7 +805,12 @@ class GroupAnalyticsService {
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
}
private extractRowSenderUsername(row: Record<string, any>): string {
private extractRowSenderUsername(row: Record<string, any>, myWxid?: string): string {
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
if (isSendRaw != null && parseInt(isSendRaw, 10) === 1 && myWxid) {
return myWxid
}
const candidates = [
row.sender_username,
row.senderUsername,
@@ -791,13 +833,33 @@ class GroupAnalyticsService {
if (normalizedValue) return normalizedValue
}
}
// Fallback: fast extract from raw content to avoid full parse
const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim()
if (rawContent) {
const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i.exec(rawContent)
if (match && match[1]) {
return match[1].trim()
}
}
return ''
}
private parseSingleMessageRow(row: Record<string, any>): Message | null {
try {
const mapped = chatService.mapRowsToMessagesForApi([row])
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
if (Array.isArray(mapped) && mapped.length > 0) {
const msg = mapped[0]
if (!msg.localType) {
msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
}
if (!msg.createTime) {
msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
}
return msg
}
return null
} catch {
return null
}
@@ -852,7 +914,7 @@ class GroupAnalyticsService {
if (rows.length === 0) break
for (const row of rows) {
const senderFromRow = this.extractRowSenderUsername(row)
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
@@ -958,7 +1020,7 @@ class GroupAnalyticsService {
const row = rows[index]
consumedRows += 1
const senderFromRow = this.extractRowSenderUsername(row)
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
@@ -1438,6 +1500,154 @@ class GroupAnalyticsService {
}
}
async getGroupMemberAnalytics(
chatroomId: string,
memberUsername: string,
startTime?: number,
endTime?: number
): Promise<{ success: boolean; data?: GroupMemberAnalytics; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const normalizedChatroomId = String(chatroomId || '').trim()
const normalizedMemberUsername = String(memberUsername || '').trim()
const batchSize = 10000
const senderMatchCache = new Map<string, boolean>()
const matchesTargetSender = (sender: string | null | undefined): boolean => {
const key = String(sender || '').trim().toLowerCase()
if (!key) return false
const cached = senderMatchCache.get(key)
if (typeof cached === 'boolean') return cached
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
senderMatchCache.set(key, matched)
return matched
}
const cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '创建游标失败' }
}
const cursor = cursorResult.cursor
const stats: ChatStatistics = {
totalMessages: 0,
textMessages: 0,
imageMessages: 0,
voiceMessages: 0,
videoMessages: 0,
emojiMessages: 0,
otherMessages: 0,
sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages
receivedMessages: 0, // No meaning here
firstMessageTime: null,
lastMessageTime: null,
activeDays: 0,
messageTypeCounts: {}
}
const hourlyDistribution: Record<number, number> = {}
for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0
const dailySet = new Set<string>()
const textTypes = [1, 244813135921]
const phraseCounts = new Map<string, number>()
const emojiCounts = new Map<string, number>()
const myWxid = String(this.configService.get('myWxid') || '').trim()
try {
while (true) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) {
return { success: false, error: batch.error || '获取分析数据失败' }
}
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length === 0) break
for (const row of rows) {
let senderFromRow = this.extractRowSenderUsername(row, myWxid)
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false
if (isSend) {
senderFromRow = myWxid
}
if (!senderFromRow || !matchesTargetSender(senderFromRow)) {
continue
}
const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '')
if (content) {
content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i, '')
}
stats.totalMessages++
if (textTypes.includes(msgType)) {
stats.textMessages++
if (content) {
const text = content.trim()
if (text && text.length <= 20) {
phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1)
}
const emojiMatches = text.match(/\[.*?\]/g)
if (emojiMatches) {
for (const em of emojiMatches) {
emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1)
}
}
}
}
else if (msgType === 3) stats.imageMessages++
else if (msgType === 34) stats.voiceMessages++
else if (msgType === 43) stats.videoMessages++
else if (msgType === 47) stats.emojiMessages++
else stats.otherMessages++
stats.sentMessages++
stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1
if (createTime > 0) {
if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime
if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime
const d = new Date(createTime * 1000)
const hour = d.getHours()
hourlyDistribution[hour]++
dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
}
}
if (!batch.hasMore) break
}
} finally {
await wcdbService.closeMessageCursor(cursor)
}
stats.activeDays = dailySet.size
const commonPhrases = Array.from(phraseCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([phrase, count]) => ({ phrase, count }))
const commonEmojis = Array.from(emojiCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([emoji, count]) => ({ emoji, count }))
return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } }
} catch (e) {
return { success: false, error: String(e) }
}
}
async exportGroupMemberMessages(
chatroomId: string,
memberUsername: string,

View File

@@ -101,6 +101,7 @@ class HttpService {
private server: http.Server | null = null
private configService: ConfigService
private port: number = 5031
private host: string = '127.0.0.1'
private running: boolean = false
private connections: Set<import('net').Socket> = new Set()
private messagePushClients: Set<http.ServerResponse> = new Set()
@@ -114,12 +115,13 @@ class HttpService {
/**
* 启动 HTTP 服务
*/
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
async start(port: number = 5031, host: string = '127.0.0.1'): Promise<{ success: boolean; port?: number; error?: string }> {
if (this.running && this.server) {
return { success: true, port: this.port }
}
this.port = port
this.host = host
return new Promise((resolve) => {
this.server = http.createServer((req, res) => this.handleRequest(req, res))
@@ -153,10 +155,10 @@ class HttpService {
}
})
this.server.listen(this.port, '127.0.0.1', () => {
this.server.listen(this.port, this.host, () => {
this.running = true
this.startMessagePushHeartbeat()
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
console.log(`[HttpService] HTTP API server started on http://${this.host}:${this.port}`)
resolve({ success: true, port: this.port })
})
})
@@ -225,7 +227,7 @@ class HttpService {
}
getMessagePushStreamUrl(): string {
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
return `http://${this.host}:${this.port}/api/v1/push/messages`
}
broadcastMessagePush(payload: Record<string, unknown>): void {
@@ -246,49 +248,116 @@ class HttpService {
}
}
/**
* 处理 HTTP 请求
*/
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// 设置 CORS 头
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
const pathname = url.pathname
try {
// 路由处理
if (pathname === '/health' || pathname === '/api/v1/health') {
this.sendJson(res, { status: 'ok' })
} else if (pathname === '/api/v1/push/messages') {
this.handleMessagePushStream(req, res)
} else if (pathname === '/api/v1/messages') {
await this.handleMessages(url, res)
} else if (pathname === '/api/v1/sessions') {
await this.handleSessions(url, res)
} else if (pathname === '/api/v1/contacts') {
await this.handleContacts(url, res)
} else if (pathname === '/api/v1/group-members') {
await this.handleGroupMembers(url, res)
} else if (pathname.startsWith('/api/v1/media/')) {
this.handleMediaRequest(pathname, res)
} else {
this.sendError(res, 404, 'Not Found')
async autoStart(): Promise<void> {
const enabled = this.configService.get('httpApiEnabled')
if (enabled) {
const port = Number(this.configService.get('httpApiPort')) || 5031
const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1'
try {
await this.start(port, host)
console.log(`[HttpService] Auto-started on port ${port}`)
} catch (err) {
console.error('[HttpService] Auto-start failed:', err)
}
} catch (error) {
console.error('[HttpService] Request error:', error)
this.sendError(res, 500, String(error))
}
}
/**
* 解析 POST 请求的 JSON Body
*/
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
if (req.method !== 'POST') return {}
return new Promise((resolve) => {
let body = ''
req.on('data', chunk => { body += chunk.toString() })
req.on('end', () => {
try {
resolve(JSON.parse(body))
} catch {
resolve({})
}
})
req.on('error', () => resolve({}))
})
}
/**
* 鉴权拦截器
*/
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
if (!expectedToken) return true
const authHeader = req.headers.authorization
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7).trim()
if (token === expectedToken) return true
}
const queryToken = url.searchParams.get('access_token')
if (queryToken && queryToken.trim() === expectedToken) return true
const bodyToken = body['access_token']
return !!(bodyToken && String(bodyToken).trim() === expectedToken);
}
/**
* 处理 HTTP 请求 (重构后)
*/
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-Headers', 'Content-Type, Authorization')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
const url = new URL(req.url || '/', `http://${this.host}:${this.port}`)
const pathname = url.pathname
try {
const bodyParams = await this.parseBody(req)
for (const [key, value] of Object.entries(bodyParams)) {
if (!url.searchParams.has(key)) {
url.searchParams.set(key, String(value))
}
}
if (pathname !== '/health' && pathname !== '/api/v1/health') {
if (!this.verifyToken(req, url, bodyParams)) {
this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token')
return
}
}
if (pathname === '/health' || pathname === '/api/v1/health') {
this.sendJson(res, { status: 'ok' })
} else if (pathname === '/api/v1/push/messages') {
this.handleMessagePushStream(req, res)
} else if (pathname === '/api/v1/messages') {
await this.handleMessages(url, res)
} else if (pathname === '/api/v1/sessions') {
await this.handleSessions(url, res)
} else if (pathname === '/api/v1/contacts') {
await this.handleContacts(url, res)
} else if (pathname === '/api/v1/group-members') {
await this.handleGroupMembers(url, res)
} else if (pathname.startsWith('/api/v1/media/')) {
this.handleMediaRequest(pathname, res)
} else {
this.sendError(res, 404, 'Not Found')
}
} catch (error) {
console.error('[HttpService] Request error:', error)
this.sendError(res, 500, String(error))
}
}
private startMessagePushHeartbeat(): void {
if (this.messagePushHeartbeatTimer) return
this.messagePushHeartbeatTimer = setInterval(() => {
@@ -895,7 +964,7 @@ class HttpService {
parsedContent: msg.parsedContent,
mediaType: media?.kind,
mediaFileName: media?.fileName,
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
mediaLocalPath: media?.fullPath
}
}
@@ -1017,13 +1086,31 @@ class HttpService {
}
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
if (!sender) return ''
const cleaned = this.normalizeAccountId(sender)
return groupNicknamesMap.get(sender)
|| groupNicknamesMap.get(sender.toLowerCase())
|| groupNicknamesMap.get(cleaned)
|| groupNicknamesMap.get(cleaned.toLowerCase())
|| ''
const key = String(sender || '').trim().toLowerCase()
if (!key) return ''
return groupNicknamesMap.get(key) || ''
}
private buildTrustedGroupNicknameMap(nicknames: Record<string, string>): Map<string, string> {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
buckets.set(memberId, new Set([nickname]))
}
}
const trusted = new Map<string, string>()
for (const [memberId, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted.set(memberId, Array.from(nicknameSet)[0])
}
return trusted
}
private resolveChatLabSenderInfo(
@@ -1094,21 +1181,7 @@ class HttpService {
try {
const result = await wcdbService.getGroupNicknames(talkerId)
if (result.success && result.nicknames) {
groupNicknamesMap = new Map()
for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) {
const memberId = String(memberIdRaw || '').trim()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
groupNicknamesMap.set(memberId, nickname)
groupNicknamesMap.set(memberId.toLowerCase(), nickname)
const cleaned = this.normalizeAccountId(memberId)
if (cleaned) {
groupNicknamesMap.set(cleaned, nickname)
groupNicknamesMap.set(cleaned.toLowerCase(), nickname)
}
}
groupNicknamesMap = this.buildTrustedGroupNicknameMap(result.nicknames)
}
} catch (e) {
console.error('[HttpService] Failed to get group nicknames:', e)
@@ -1161,7 +1234,7 @@ class HttpService {
type: this.mapMessageType(msg.localType, msg),
content: this.getMessageContent(msg),
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
}
})
@@ -1226,7 +1299,7 @@ class HttpService {
* 映射 Type 49 子类型
*/
private mapType49(msg: Message): number {
const xmlType = msg.xmlType
const xmlType = this.resolveType49Subtype(msg)
switch (xmlType) {
case '5': // 链接
@@ -1250,10 +1323,97 @@ class HttpService {
}
}
private extractType49Subtype(rawContent: string): string {
const content = String(rawContent || '')
if (!content) return ''
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
if (appmsgMatch) {
const appmsgInner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
if (typeMatch) {
return typeMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
}
const fallbackMatch = /<type>([\s\S]*?)<\/type>/i.exec(content)
if (fallbackMatch) {
return fallbackMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
return ''
}
private resolveType49Subtype(msg: Message): string {
const xmlType = String(msg.xmlType || '').trim()
if (xmlType) return xmlType
const extractedType = this.extractType49Subtype(msg.rawContent)
if (extractedType) return extractedType
switch (msg.appMsgKind) {
case 'official-link':
case 'link':
return '5'
case 'file':
return '6'
case 'chat-record':
return '19'
case 'miniapp':
return '33'
case 'quote':
return '57'
case 'transfer':
return '2000'
case 'red-packet':
return '2001'
case 'music':
return '3'
default:
if (msg.linkUrl) return '5'
if (msg.fileName) return '6'
return ''
}
}
private getType49Content(msg: Message): string {
const subtype = this.resolveType49Subtype(msg)
const title = msg.linkTitle || msg.fileName || ''
switch (subtype) {
case '5':
case '49':
return title ? `[链接] ${title}` : '[链接]'
case '6':
return title ? `[文件] ${title}` : '[文件]'
case '19':
return title ? `[聊天记录] ${title}` : '[聊天记录]'
case '33':
case '36':
return title ? `[小程序] ${title}` : '[小程序]'
case '57':
return msg.parsedContent || title || '[引用消息]'
case '2000':
return title ? `[转账] ${title}` : '[转账]'
case '2001':
return title ? `[红包] ${title}` : '[红包]'
case '3':
return title ? `[音乐] ${title}` : '[音乐]'
default:
return msg.parsedContent || title || '[消息]'
}
}
/**
* 获取消息内容
*/
private getMessageContent(msg: Message): string | null {
if (msg.localType === 49) {
return this.getType49Content(msg)
}
// 优先使用已解析的内容
if (msg.parsedContent) {
return msg.parsedContent
@@ -1276,7 +1436,7 @@ class HttpService {
case 48:
return '[位置]'
case 49:
return msg.linkTitle || msg.fileName || '[消息]'
return this.getType49Content(msg)
default:
return msg.rawContent || null
}
@@ -1302,4 +1462,3 @@ class HttpService {
}
export const httpService = new HttpService()

View File

@@ -64,6 +64,7 @@ type CachedImagePayload = {
type DecryptImagePayload = CachedImagePayload & {
force?: boolean
hardlinkOnly?: boolean
}
export class ImageDecryptService {
@@ -158,7 +159,9 @@ export class ImageDecryptService {
}
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
await this.ensureCacheIndexed()
if (!payload.hardlinkOnly) {
await this.ensureCacheIndexed()
}
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
if (!cacheKey) {
@@ -180,14 +183,16 @@ export class ImageDecryptService {
}
}
for (const key of cacheKeys) {
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
if (!existingHd || this.isThumbnailPath(existingHd)) continue
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
return { success: true, localPath }
if (!payload.hardlinkOnly) {
for (const key of cacheKeys) {
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
if (!existingHd || this.isThumbnailPath(existingHd)) continue
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
return { success: true, localPath }
}
}
}
@@ -255,7 +260,7 @@ export class ImageDecryptService {
payload: DecryptImagePayload,
cacheKey: string
): Promise<DecryptResult> {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
try {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
@@ -275,7 +280,11 @@ export class ImageDecryptService {
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
{ allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) }
{
allowThumbnail: !payload.force,
skipResolvedCache: Boolean(payload.force),
hardlinkOnly: payload.hardlinkOnly === true
}
)
// 如果要求高清图但没找到,直接返回提示
@@ -298,18 +307,20 @@ export class ImageDecryptService {
return { success: true, localPath, isThumb }
}
// 查找已缓存的解密文件
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
if (existing) {
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
const isHd = this.isHdPath(existing)
// 如果要求高清但找到的是缩略图,继续解密高清图
if (!(payload.force && !isHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
return { success: true, localPath, isThumb }
// 查找已缓存的解密文件hardlink-only 模式下跳过全缓存目录扫描)
if (!payload.hardlinkOnly) {
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
if (existing) {
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
const isHd = this.isHdPath(existing)
// 如果要求高清但找到的是缩略图,继续解密高清图
if (!(payload.force && !isHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
return { success: true, localPath, isThumb }
}
}
}
@@ -467,15 +478,17 @@ export class ImageDecryptService {
imageMd5?: string,
imageDatName?: string,
sessionId?: string,
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean }
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean }
): Promise<string | null> {
const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false
const hardlinkOnly = options?.hardlinkOnly ?? false
this.logInfo('[ImageDecrypt] resolveDatPath', {
imageMd5,
imageDatName,
allowThumbnail,
skipResolvedCache
skipResolvedCache,
hardlinkOnly
})
if (!skipResolvedCache) {
@@ -500,7 +513,7 @@ export class ImageDecryptService {
}
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
if (imageMd5) {
if (!hardlinkOnly && allowThumbnail && imageMd5) {
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
if (res) return res
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
@@ -510,7 +523,7 @@ export class ImageDecryptService {
}
// 2. 如果 imageDatName 看起来像 MD5也尝试快速定位
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
if (!hardlinkOnly && allowThumbnail && !imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
if (res) return res
}
@@ -587,6 +600,11 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
if (hardlinkOnly) {
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName })
return null
}
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
if (!allowThumbnail) {
return null

View File

@@ -1,7 +1,7 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { execFile, exec } from 'child_process'
import { execFile, exec, spawn } from 'child_process'
import { promisify } from 'util'
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
@@ -45,33 +45,104 @@ export class KeyServiceLinux {
onStatus?: (message: string, level: number) => void
): Promise<DbKeyResult> {
try {
// 1. 构造一个包含常用系统命令路径的环境变量,防止打包后找不到命令
const envWithPath = {
...process.env,
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
};
onStatus?.('正在尝试结束当前微信进程...', 0)
await execAsync('killall -9 wechat wechat-bin xwechat').catch(() => {})
console.log('[Debug] 开始执行进程清理逻辑...');
try {
const { stdout, stderr } = await execAsync('killall -9 wechat wechat-bin xwechat', { env: envWithPath });
console.log(`[Debug] killall 成功退出. stdout: ${stdout}, stderr: ${stderr}`);
} catch (err: any) {
// 命令如果没找到进程通常会返回 code 1这也是正常的但我们需要记录下来
console.log(`[Debug] killall 报错或未找到进程: ${err.message}`);
// Fallback: 尝试使用 pkill 兜底
try {
console.log('[Debug] 尝试使用备用命令 pkill...');
await execAsync('pkill -9 -x "wechat|wechat-bin|xwechat"', { env: envWithPath });
console.log('[Debug] pkill 执行完成');
} catch (e: any) {
console.log(`[Debug] pkill 报错或未找到进程: ${e.message}`);
}
}
// 稍微等待进程完全退出
await new Promise(r => setTimeout(r, 1000))
onStatus?.('正在尝试拉起微信...', 0)
const startCmds = [
'nohup wechat >/dev/null 2>&1 &',
'nohup wechat-bin >/dev/null 2>&1 &',
'nohup xwechat >/dev/null 2>&1 &'
const cleanEnv = { ...process.env };
delete cleanEnv.ELECTRON_RUN_AS_NODE;
delete cleanEnv.ELECTRON_NO_ATTACH_CONSOLE;
delete cleanEnv.APPDIR;
delete cleanEnv.APPIMAGE;
const wechatBins = [
'wechat',
'wechat-bin',
'xwechat',
'/opt/wechat/wechat',
'/usr/bin/wechat',
'/opt/apps/com.tencent.wechat/files/wechat'
]
for (const cmd of startCmds) execAsync(cmd).catch(() => {})
for (const binName of wechatBins) {
try {
const child = spawn(binName, [], {
detached: true,
stdio: 'ignore',
env: cleanEnv
});
child.on('error', (err) => {
console.log(`[Debug] 拉起 ${binName} 失败:`, err.message);
});
child.unref();
console.log(`[Debug] 尝试拉起 ${binName} 完毕`);
} catch (e: any) {
console.log(`[Debug] 尝试拉起 ${binName} 发生异常:`, e.message);
}
}
onStatus?.('等待微信进程出现...', 0)
let pid = 0
for (let i = 0; i < 15; i++) { // 最多等 15 秒
await new Promise(r => setTimeout(r, 1000))
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
const pids = stdout.trim().split(/\s+/).filter(p => p)
if (pids.length > 0) {
pid = parseInt(pids[0], 10)
break
try {
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat', { env: envWithPath });
const pids = stdout.trim().split(/\s+/).filter(p => p);
if (pids.length > 0) {
pid = parseInt(pids[0], 10);
console.log(`[Debug] 第 ${i + 1} 秒,通过 pidof 成功获取 PID: ${pid}`);
break;
}
} catch (err: any) {
console.log(`[Debug] 第 ${i + 1}pidof 失败: ${err.message.split('\n')[0]}`);
// Fallback: 使用 pgrep 兜底
try {
const { stdout: pgrepOut } = await execAsync('pgrep -x "wechat|wechat-bin|xwechat"', { env: envWithPath });
const pids = pgrepOut.trim().split(/\s+/).filter(p => p);
if (pids.length > 0) {
pid = parseInt(pids[0], 10);
console.log(`[Debug] 第 ${i + 1} 秒,通过 pgrep 成功获取 PID: ${pid}`);
break;
}
} catch (e: any) {
console.log(`[Debug] 第 ${i + 1}pgrep 也失败: ${e.message.split('\n')[0]}`);
}
}
}
if (!pid) {
const err = '未能自动启动微信,手动启动并登录。'
const err = '未能自动启动微信,或获取PID失败请查看控制台日志或手动启动并登录。'
onStatus?.(err, 2)
return { success: false, error: err }
}
@@ -82,6 +153,7 @@ export class KeyServiceLinux {
return await this.getDbKey(pid, onStatus)
} catch (err: any) {
console.error('[Debug] 自动获取流程彻底崩溃:', err);
const errMsg = '自动获取微信 PID 失败: ' + err.message
onStatus?.(errMsg, 2)
return { success: false, error: errMsg }

View File

@@ -262,6 +262,7 @@ export class KeyServiceMac {
): Promise<string> {
const helperPath = this.getHelperPath()
const waitMs = Math.max(timeoutMs, 30_000)
const timeoutSec = Math.ceil(waitMs / 1000) + 30
const pid = await this.getWeChatPid()
onStatus?.(`已找到微信进程 PID=${pid},正在定位目标函数...`, 0)
// 最佳努力清理同路径残留 helper普通权限
@@ -378,12 +379,22 @@ export class KeyServiceMac {
): Promise<string> {
const helperPath = this.getHelperPath()
const waitMs = Math.max(timeoutMs, 30_000)
const timeoutSec = Math.ceil(waitMs / 1000) + 30
const pid = await this.getWeChatPid()
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
// 通过 try/on error 回传详细错误,避免只看到 "Command failed"
const scriptLines = [
`set helperPath to ${JSON.stringify(helperPath)}`,
`set cmd to quoted form of helperPath & " ${pid} ${waitMs} 2>&1"`,
'do shell script cmd with administrator privileges'
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
`set timeoutSec to ${timeoutSec}`,
'try',
'with timeout of timeoutSec seconds',
'set outText to do shell script cmd with administrator privileges',
'end timeout',
'return "WF_OK::" & outText',
'on error errMsg number errNum partial result pr',
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
'end try'
]
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
@@ -400,6 +411,16 @@ export class KeyServiceMac {
const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean)
if (!lines.length) throw new Error('elevated helper returned empty output')
const joined = lines.join('\n')
if (joined.startsWith('WF_ERR::')) {
const parts = joined.split('::')
const errNum = parts[1] || 'unknown'
const errMsg = parts[2] || 'unknown'
const partial = parts.slice(3).join('::')
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`)
}
const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined
// 从所有行里提取所有 JSON 对象(同一行可能有多个拼接),找含 key/result 的那个
const extractJsonObjects = (s: string): any[] => {
@@ -411,7 +432,7 @@ export class KeyServiceMac {
}
return results
}
const fullOutput = lines.join('\n')
const fullOutput = normalizedOutput
const allJson = extractJsonObjects(fullOutput)
// 优先找 success=true && key 字段
const successPayload = allJson.find(p => p?.success === true && typeof p?.key === 'string')

View File

@@ -304,11 +304,8 @@ class MessagePushService {
}
const groupNicknames = await this.getGroupNicknames(chatroomId)
const normalizedSender = this.normalizeAccountId(senderUsername)
const nickname = groupNicknames[senderUsername]
|| groupNicknames[senderUsername.toLowerCase()]
|| groupNicknames[normalizedSender]
|| groupNicknames[normalizedSender.toLowerCase()]
const senderKey = senderUsername.toLowerCase()
const nickname = groupNicknames[senderKey]
if (nickname) {
return nickname
@@ -328,22 +325,33 @@ class MessagePushService {
}
const result = await wcdbService.getGroupNicknames(cacheKey)
const nicknames = result.success && result.nicknames ? result.nicknames : {}
const nicknames = result.success && result.nicknames
? this.sanitizeGroupNicknames(result.nicknames)
: {}
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
return nicknames
}
private normalizeAccountId(value: string): string {
const trimmed = String(value || '').trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match ? match[1] : trimmed
private sanitizeGroupNicknames(nicknames: Record<string, string>): Record<string, string> {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
buckets.set(memberId, new Set([nickname]))
}
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
const trusted: Record<string, string> = {}
for (const [memberId, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted[memberId] = Array.from(nicknameSet)[0]
}
return trusted
}
private isRecentMessage(messageKey: string): boolean {

View File

@@ -27,6 +27,17 @@ export interface SnsMedia {
livePhoto?: SnsLivePhoto
}
export interface SnsLocation {
latitude?: number
longitude?: number
city?: string
country?: string
poiName?: string
poiAddress?: string
poiAddressName?: string
label?: string
}
export interface SnsPost {
id: string
tid?: string // 数据库主键(雪花 ID用于精确删除
@@ -39,6 +50,7 @@ export interface SnsPost {
media: SnsMedia[]
likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[]
location?: SnsLocation
rawXml?: string
linkTitle?: string
linkUrl?: string
@@ -287,6 +299,17 @@ function parseCommentsFromXml(xml: string): ParsedCommentItem[] {
return comments
}
const decodeXmlText = (text: string): string => {
if (!text) return ''
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
}
class SnsService {
private configService: ConfigService
private contactCache: ContactCacheService
@@ -647,6 +670,110 @@ class SnsService {
return { media, videoKey }
}
private toOptionalNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
const parsed = Number.parseFloat(trimmed)
return Number.isFinite(parsed) ? parsed : undefined
}
private normalizeLocation(input: unknown): SnsLocation | undefined {
if (!input || typeof input !== 'object') return undefined
const row = input as Record<string, unknown>
const normalizeText = (value: unknown): string | undefined => {
if (typeof value !== 'string') return undefined
return this.toOptionalString(decodeXmlText(value))
}
const location: SnsLocation = {}
const latitude = this.toOptionalNumber(row.latitude ?? row.lat ?? row.x)
const longitude = this.toOptionalNumber(row.longitude ?? row.lng ?? row.y)
const city = normalizeText(row.city)
const country = normalizeText(row.country)
const poiName = normalizeText(row.poiName ?? row.poiname)
const poiAddress = normalizeText(row.poiAddress ?? row.poiaddress)
const poiAddressName = normalizeText(row.poiAddressName ?? row.poiaddressname)
const label = normalizeText(row.label)
if (latitude !== undefined) location.latitude = latitude
if (longitude !== undefined) location.longitude = longitude
if (city) location.city = city
if (country) location.country = country
if (poiName) location.poiName = poiName
if (poiAddress) location.poiAddress = poiAddress
if (poiAddressName) location.poiAddressName = poiAddressName
if (label) location.label = label
return Object.keys(location).length > 0 ? location : undefined
}
private parseLocationFromXml(xml: string): SnsLocation | undefined {
if (!xml) return undefined
try {
const locationTagMatch = xml.match(/<location\b([^>]*)>/i)
const locationAttrs = locationTagMatch?.[1] || ''
const readAttr = (name: string): string | undefined => {
if (!locationAttrs) return undefined
const match = locationAttrs.match(new RegExp(`${name}\\s*=\\s*["']([\\s\\S]*?)["']`, 'i'))
if (!match?.[1]) return undefined
return this.toOptionalString(decodeXmlText(match[1]))
}
const readTag = (name: string): string | undefined => {
const match = xml.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i'))
if (!match?.[1]) return undefined
return this.toOptionalString(decodeXmlText(match[1]))
}
const location: SnsLocation = {}
const latitude = this.toOptionalNumber(readAttr('latitude') || readAttr('x') || readTag('latitude') || readTag('x'))
const longitude = this.toOptionalNumber(readAttr('longitude') || readAttr('y') || readTag('longitude') || readTag('y'))
const city = readAttr('city') || readTag('city')
const country = readAttr('country') || readTag('country')
const poiName = readAttr('poiName') || readAttr('poiname') || readTag('poiName') || readTag('poiname')
const poiAddress = readAttr('poiAddress') || readAttr('poiaddress') || readTag('poiAddress') || readTag('poiaddress')
const poiAddressName = readAttr('poiAddressName') || readAttr('poiaddressname') || readTag('poiAddressName') || readTag('poiaddressname')
const label = readAttr('label') || readTag('label')
if (latitude !== undefined) location.latitude = latitude
if (longitude !== undefined) location.longitude = longitude
if (city) location.city = city
if (country) location.country = country
if (poiName) location.poiName = poiName
if (poiAddress) location.poiAddress = poiAddress
if (poiAddressName) location.poiAddressName = poiAddressName
if (label) location.label = label
return Object.keys(location).length > 0 ? location : undefined
} catch (e) {
console.error('[SnsService] 解析位置 XML 失败:', e)
return undefined
}
}
private mergeLocation(primary?: SnsLocation, fallback?: SnsLocation): SnsLocation | undefined {
if (!primary && !fallback) return undefined
const merged: SnsLocation = {}
const setValue = <K extends keyof SnsLocation>(key: K, value: SnsLocation[K] | undefined) => {
if (value !== undefined) merged[key] = value
}
setValue('latitude', primary?.latitude ?? fallback?.latitude)
setValue('longitude', primary?.longitude ?? fallback?.longitude)
setValue('city', primary?.city ?? fallback?.city)
setValue('country', primary?.country ?? fallback?.country)
setValue('poiName', primary?.poiName ?? fallback?.poiName)
setValue('poiAddress', primary?.poiAddress ?? fallback?.poiAddress)
setValue('poiAddressName', primary?.poiAddressName ?? fallback?.poiAddressName)
setValue('label', primary?.label ?? fallback?.label)
return Object.keys(merged).length > 0 ? merged : undefined
}
private getSnsCacheDir(): string {
const cachePath = this.configService.getCacheBasePath()
const snsCacheDir = join(cachePath, 'sns_cache')
@@ -948,7 +1075,12 @@ class SnsService {
const enrichedTimeline = result.timeline.map((post: any) => {
const contact = this.contactCache.get(post.username)
const isVideoPost = post.type === 15
const videoKey = extractVideoKey(post.rawXml || '')
const rawXml = post.rawXml || ''
const videoKey = extractVideoKey(rawXml)
const location = this.mergeLocation(
this.normalizeLocation((post as { location?: unknown }).location),
this.parseLocationFromXml(rawXml)
)
const fixedMedia = (post.media || []).map((m: any) => ({
url: fixSnsUrl(m.url, m.token, isVideoPost),
@@ -971,7 +1103,6 @@ class SnsService {
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
const dllComments: any[] = post.comments || []
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
const rawXml = post.rawXml || ''
let finalComments: any[]
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
@@ -990,7 +1121,8 @@ class SnsService {
avatarUrl: contact?.avatarUrl,
nickname: post.nickname || contact?.displayName || post.username,
media: fixedMedia,
comments: finalComments
comments: finalComments,
location
}
})
@@ -1346,6 +1478,7 @@ class SnsService {
})),
likes: p.likes,
comments: p.comments,
location: p.location,
linkTitle: (p as any).linkTitle,
linkUrl: (p as any).linkUrl
}))
@@ -1397,6 +1530,7 @@ class SnsService {
})),
likes: post.likes,
comments: post.comments,
location: post.location,
likesDetail,
commentsDetail,
linkTitle: (post as any).linkTitle,
@@ -1479,6 +1613,27 @@ class SnsService {
const ch = name.charAt(0)
return escapeHtml(ch || '?')
}
const normalizeLocationText = (value?: string): string => (
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
)
const resolveLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const primaryCandidates = [
normalizeLocationText(location.poiName),
normalizeLocationText(location.poiAddressName),
normalizeLocationText(location.label),
normalizeLocationText(location.poiAddress)
].filter(Boolean)
const primary = primaryCandidates[0] || ''
const region = [
normalizeLocationText(location.country),
normalizeLocationText(location.city)
].filter(Boolean).join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
let filterInfo = ''
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
@@ -1502,6 +1657,10 @@ class SnsService {
const linkHtml = post.linkTitle && post.linkUrl
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a"></span></a>`
: ''
const locationText = resolveLocationText(post.location)
const locationHtml = locationText
? `<div class="loc"><span class="loc-i">📍</span><span class="loc-t">${escapeHtml(locationText)}</span></div>`
: ''
const likesHtml = post.likes.length > 0
? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>`
@@ -1524,6 +1683,7 @@ ${avatarHtml}
<div class="body">
<div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div>
${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''}
${locationHtml}
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
${linkHtml}
${likesHtml}
@@ -1559,6 +1719,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hira
.nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px}
.tm{font-size:12px;color:var(--t3)}
.txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px}
.loc{display:flex;align-items:flex-start;gap:6px;font-size:13px;color:var(--t2);margin:-4px 0 12px}
.loc-i{line-height:1.3}
.loc-t{line-height:1.45;word-break:break-word}
/* 媒体网格 */
.mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px}

View File

@@ -75,6 +75,14 @@ export class VoiceTranscribeService {
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
} else if (process.platform === 'win32') {
// Windows: 把 sherpa-onnx DLL 所在目录加到 PATH否则 native module 找不到依赖
const existing = env['PATH'] || ''
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
env['PATH'] = Array.from(new Set(merged)).join(';')
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
}
return env
@@ -273,8 +281,20 @@ export class VoiceTranscribeService {
})
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
worker.on('exit', (code: number) => {
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` })
worker.on('exit', (code: number | null, signal: string | null) => {
if (code === null || signal === 'SIGSEGV') {
console.error(`[VoiceTranscribe] Worker 异常崩溃,信号: ${signal}。可能是由于底层 C++ 运行库在当前系统上发生段错误。`);
resolve({
success: false,
error: 'SEGFAULT_ERROR'
});
return;
}
if (code !== 0) {
resolve({ success: false, error: `Worker exited with code ${code}` });
}
})
} catch (error) {

View File

@@ -68,6 +68,8 @@ export class WcdbCore {
private wcdbListMediaDbs: any = null
private wcdbGetMessageById: any = null
private wcdbGetEmoticonCdnUrl: any = null
private wcdbGetEmoticonCaption: any = null
private wcdbGetEmoticonCaptionStrict: any = null
private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null
private wcdbGetVoiceDataBatch: any = null
@@ -124,6 +126,10 @@ export class WcdbCore {
this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true)
}
getLastInitError(): string | null {
return lastDllInitError
}
setLogEnabled(enabled: boolean): void {
this.logEnabled = enabled
this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true)
@@ -264,8 +270,9 @@ export class WcdbCore {
private getDllPath(): string {
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
const isArm64 = process.arch === 'arm64'
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
const subDir = isMac ? 'macos' : isLinux ? 'linux' : ''
const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
const envDllPath = process.env.WCDB_DLL_PATH
if (envDllPath && envDllPath.length > 0) {
@@ -296,6 +303,10 @@ export class WcdbCore {
return candidates[0] || libName
}
private formatInitProtectionError(code: number): string {
return `错误码: ${code}`
}
private isLogEnabled(): boolean {
// 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志
if (process.env.WCDB_LOG_ENABLED === '1') return true
@@ -617,11 +628,13 @@ export class WcdbCore {
}
}
this.writeLog(`[bootstrap] koffi.load begin path=${dllPath}`, true)
this.lib = this.koffi.load(dllPath)
this.writeLog('[bootstrap] koffi.load ok', true)
// InitProtection (Added for security)
try {
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)')
// 尝试多个可能的资源路径
const resourcePaths = [
@@ -634,26 +647,40 @@ export class WcdbCore {
].filter(Boolean)
let protectionOk = false
let protectionCode = -1
let bestFailCode: number | null = null
const scoreFailCode = (code: number): number => {
if (code >= -2212 && code <= -2201) return 0 // manifest/signature/hash failures
if (code === -102 || code === -101 || code === -1006) return 1
return 2
}
for (const resPath of resourcePaths) {
try {
//
protectionOk = this.wcdbInitProtection(resPath)
if (protectionOk) {
//
this.writeLog(`[bootstrap] InitProtection call path=${resPath}`, true)
protectionCode = Number(this.wcdbInitProtection(resPath))
if (protectionCode === 0) {
protectionOk = true
break
}
if (bestFailCode === null || scoreFailCode(protectionCode) < scoreFailCode(bestFailCode)) {
bestFailCode = protectionCode
}
this.writeLog(`[bootstrap] InitProtection rc=${protectionCode} path=${resPath}`, true)
} catch (e) {
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
this.writeLog(`[bootstrap] InitProtection exception path=${resPath}: ${String(e)}`, true)
}
}
if (!protectionOk) {
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
// this.writeLog('InitProtection 失败,继续运行')
// 不返回 false允许继续运行
const finalCode = bestFailCode ?? protectionCode
lastDllInitError = this.formatInitProtectionError(finalCode)
this.writeLog(`[bootstrap] InitProtection failed finalCode=${finalCode}`, true)
return false
}
} catch (e) {
// console.warn('InitProtection symbol not found:', e)
lastDllInitError = this.formatInitProtectionError(-2301)
this.writeLog(`[bootstrap] InitProtection symbol load failed: ${String(e)}`, true)
return false
}
// 定义类型
@@ -852,6 +879,22 @@ export class WcdbCore {
// wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url)
this.wcdbGetEmoticonCdnUrl = this.lib.func('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)')
// wcdb_status wcdb_get_emoticon_caption(wcdb_handle handle, const char* db_path, const char* md5, char** out_caption)
try {
this.wcdbGetEmoticonCaption = this.lib.func('int32 wcdb_get_emoticon_caption(int64 handle, const char* dbPath, const char* md5, _Out_ void** outCaption)')
} catch (e) {
this.wcdbGetEmoticonCaption = null
this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption: ${String(e)}`, true)
}
// wcdb_status wcdb_get_emoticon_caption_strict(wcdb_handle handle, const char* md5, char** out_caption)
try {
this.wcdbGetEmoticonCaptionStrict = this.lib.func('int32 wcdb_get_emoticon_caption_strict(int64 handle, const char* md5, _Out_ void** outCaption)')
} catch (e) {
this.wcdbGetEmoticonCaptionStrict = null
this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption_strict: ${String(e)}`, true)
}
// wcdb_status wcdb_list_message_dbs(wcdb_handle handle, char** out_json)
this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)')
@@ -1055,7 +1098,7 @@ export class WcdbCore {
const initResult = this.wcdbInit()
if (initResult !== 0) {
console.error('WCDB 初始化失败:', initResult)
lastDllInitError = `初始化失败(错误码: ${initResult}`
lastDllInitError = this.formatInitProtectionError(initResult)
return false
}
@@ -1066,14 +1109,7 @@ export class WcdbCore {
const errorMsg = e instanceof Error ? e.message : String(e)
console.error('WCDB 初始化异常:', errorMsg)
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
lastDllInitError = errorMsg
// 检查是否是常见的 VC++ 运行时缺失错误
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
errorMsg.includes('The specified module could not be found')) {
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
}
lastDllInitError = this.formatInitProtectionError(-2302)
return false
}
}
@@ -1100,8 +1136,7 @@ export class WcdbCore {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) {
// 返回更详细的错误信息,帮助用户诊断问题
const detailedError = lastDllInitError || 'WCDB 初始化失败'
const detailedError = lastDllInitError || this.formatInitProtectionError(-2303)
return { success: false, error: detailedError }
}
}
@@ -1111,7 +1146,7 @@ export class WcdbCore {
this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
if (!dbStoragePath || !existsSync(dbStoragePath)) {
return { success: false, error: `数据库目录不存在: ${dbPath}` }
return { success: false, error: this.formatInitProtectionError(-3001) }
}
// 递归查找 session.db
@@ -1119,7 +1154,7 @@ export class WcdbCore {
this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`)
if (!sessionDbPath) {
return { success: false, error: `未找到 session.db 文件` }
return { success: false, error: this.formatInitProtectionError(-3002) }
}
// 分配输出参数内存
@@ -1128,17 +1163,13 @@ export class WcdbCore {
if (result !== 0) {
await this.printLogs()
let errorMsg = '数据库打开失败'
if (result === -1) errorMsg = '参数错误'
else if (result === -2) errorMsg = '密钥错误'
else if (result === -3) errorMsg = '数据库打开失败'
this.writeLog(`testConnection openAccount failed code=${result}`)
return { success: false, error: `${errorMsg} (错误码: ${result})` }
return { success: false, error: this.formatInitProtectionError(result) }
}
const tempHandle = handleOut[0]
if (tempHandle <= 0) {
return { success: false, error: '无效的数据库句柄' }
return { success: false, error: this.formatInitProtectionError(-3003) }
}
// 测试成功:使用 shutdown 清理资源(包括测试句柄)
@@ -1167,7 +1198,7 @@ export class WcdbCore {
} catch (e) {
console.error('测试连接异常:', e)
this.writeLog(`testConnection exception: ${String(e)}`)
return { success: false, error: String(e) }
return { success: false, error: this.formatInitProtectionError(-3004) }
}
}
@@ -1359,6 +1390,7 @@ export class WcdbCore {
*/
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
try {
lastDllInitError = null
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return false
@@ -1386,6 +1418,7 @@ export class WcdbCore {
if (!dbStoragePath || !existsSync(dbStoragePath)) {
console.error('数据库目录不存在:', dbPath)
this.writeLog(`open failed: dbStorage not found for ${dbPath}`)
lastDllInitError = this.formatInitProtectionError(-3001)
return false
}
@@ -1394,6 +1427,7 @@ export class WcdbCore {
if (!sessionDbPath) {
console.error('未找到 session.db 文件')
this.writeLog('open failed: session.db not found')
lastDllInitError = this.formatInitProtectionError(-3002)
return false
}
@@ -1404,11 +1438,13 @@ export class WcdbCore {
console.error('打开数据库失败:', result)
await this.printLogs()
this.writeLog(`open failed: openAccount code=${result}`)
lastDllInitError = this.formatInitProtectionError(result)
return false
}
const handle = handleOut[0]
if (handle <= 0) {
lastDllInitError = this.formatInitProtectionError(-3003)
return false
}
@@ -1418,6 +1454,7 @@ export class WcdbCore {
this.currentWxid = wxid
this.currentDbStoragePath = dbStoragePath
this.initialized = true
lastDllInitError = null
if (this.wcdbSetMyWxid && wxid) {
try {
this.wcdbSetMyWxid(this.handle, wxid)
@@ -1435,6 +1472,7 @@ export class WcdbCore {
} catch (e) {
console.error('打开数据库异常:', e)
this.writeLog(`open exception: ${String(e)}`)
lastDllInitError = this.formatInitProtectionError(-3004)
return false
}
}
@@ -2700,6 +2738,48 @@ export class WcdbCore {
}
}
async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetEmoticonCaption) {
return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetEmoticonCaption(this.handle, dbPath || '', md5, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取表情释义失败: ${result}` }
}
const captionStr = this.decodeJsonPtr(outPtr[0])
if (captionStr === null) return { success: false, error: '解析表情释义失败' }
return { success: true, caption: captionStr || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetEmoticonCaptionStrict) {
return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption_strict' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetEmoticonCaptionStrict(this.handle, md5, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取表情释义失败(strict): ${result}` }
}
const captionStr = this.decodeJsonPtr(outPtr[0])
if (captionStr === null) return { success: false, error: '解析表情释义失败(strict)' }
return { success: true, caption: captionStr || undefined }
} catch (e) {
return { success: false, error: String(e) }
}
}
async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
try {

View File

@@ -164,6 +164,10 @@ export class WcdbService {
return this.callWorker('open', { dbPath, hexKey, wxid })
}
async getLastInitError(): Promise<string | null> {
return this.callWorker('getLastInitError')
}
/**
* 关闭数据库连接
*/
@@ -455,6 +459,20 @@ export class WcdbService {
return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 })
}
/**
* 获取表情包释义
*/
async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
return this.callWorker('getEmoticonCaption', { dbPath, md5 })
}
/**
* 获取表情包释义(严格 DLL 接口)
*/
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
return this.callWorker('getEmoticonCaptionStrict', { md5 })
}
/**
* 列出消息数据库
*/

View File

@@ -37,6 +37,9 @@ if (parentPort) {
case 'open':
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
break
case 'getLastInitError':
result = core.getLastInitError()
break
case 'close':
core.close()
result = { success: true }
@@ -170,6 +173,12 @@ if (parentPort) {
case 'getEmoticonCdnUrl':
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
break
case 'getEmoticonCaption':
result = await core.getEmoticonCaption(payload.dbPath, payload.md5)
break
case 'getEmoticonCaptionStrict':
result = await core.getEmoticonCaptionStrict(payload.md5)
break
case 'listMessageDbs':
result = await core.listMessageDbs()
break

View File

@@ -20,7 +20,8 @@
"build": "tsc && vite build && electron-builder",
"preview": "vite preview",
"electron:dev": "vite --mode electron",
"electron:build": "npm run build"
"electron:build": "npm run build",
"preinstall": "node preinstall.js"
},
"dependencies": {
"echarts": "^5.5.1",
@@ -95,12 +96,18 @@
"linux": {
"icon": "public/icon.png",
"target": [
"deb",
"appimage",
"tar.gz"
],
"category": "Utility",
"executableName": "weflow",
"synopsis": "WeFlow for Linux"
"synopsis": "WeFlow for Linux",
"extraFiles": [
{
"from": "resources/linux/install.sh",
"to": "install.sh"
}
]
},
"nsis": {
"oneClick": false,
@@ -172,4 +179,4 @@
],
"icon": "resources/icon.icns"
}
}
}

20
preinstall.js Normal file

File diff suppressed because one or more lines are too long

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

BIN
resources/arm64/WCDB.dll Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,59 @@
#!/bin/bash
set -e
APP_NAME="weflow"
APP_EXEC="weflow"
OPT_DIR="/opt/$APP_NAME"
BIN_LINK="/usr/bin/$APP_NAME"
DESKTOP_DIR="/usr/share/applications"
ICON_DIR="/usr/share/pixmaps"
if [ "$EUID" -ne 0 ]; then
echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)"
exit 1
fi
echo "🚀 开始安装 $APP_NAME..."
echo "📦 正在复制文件到 $OPT_DIR..."
rm -rf "$OPT_DIR"
mkdir -p "$OPT_DIR"
cp -r ./* "$OPT_DIR/"
chmod -R 755 "$OPT_DIR"
chmod +x "$OPT_DIR/$APP_EXEC"
echo "🔗 正在创建软链接 $BIN_LINK..."
ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK"
echo "📝 正在创建桌面快捷方式..."
cat <<EOF >"$DESKTOP_DIR/${APP_NAME}.desktop"
[Desktop Entry]
Name=WeFlow
Exec=$OPT_DIR/$APP_EXEC %U
Terminal=false
Type=Application
Icon=$APP_NAME
StartupWMClass=WeFlow
Comment=A local WeChat database decryption and analysis tool
Categories=Utility;
EOF
chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop"
echo "🖼️ 正在安装图标..."
if [ -f "$OPT_DIR/resources/icon.png" ]; then
cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png"
chmod 644 "$ICON_DIR/${APP_NAME}.png"
elif [ -f "$OPT_DIR/icon.png" ]; then
cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png"
chmod 644 "$ICON_DIR/${APP_NAME}.png"
else
echo "⚠️ 警告: 未找到图标文件,跳过图标安装。"
fi
if command -v update-desktop-database >/dev/null 2>&1; then
echo "🔄 更新桌面数据库..."
update-desktop-database "$DESKTOP_DIR"
fi
echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow或者在终端输入 'weflow' 启动。"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -104,6 +104,44 @@ function App() {
// 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
useEffect(() => {
const checkWaylandStatus = async () => {
try {
// 防止在非客户端环境报错,先检查 API 是否存在
if (!window.electronAPI?.app?.checkWayland) return
// 通过 configService 检查是否已经弹过窗
const hasWarned = await window.electronAPI.config.get('waylandWarningShown')
if (!hasWarned) {
const isWayland = await window.electronAPI.app.checkWayland()
if (isWayland) {
setShowWaylandWarning(true)
}
}
} catch (e) {
console.error('检查 Wayland 状态失败:', e)
}
}
// 只有在协议同意之后并且已经进入主应用流程才检查
if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) {
checkWaylandStatus()
}
}, [isAgreementWindow, isOnboardingWindow, agreementLoading])
const handleDismissWaylandWarning = async () => {
try {
// 记录到本地配置中,下次不再提示
await window.electronAPI.config.set('waylandWarningShown', true)
} catch (e) {
console.error('保存 Wayland 提示状态失败:', e)
}
setShowWaylandWarning(false)
}
useEffect(() => {
if (location.pathname !== '/settings') {
settingsBackgroundRef.current = location
@@ -432,6 +470,8 @@ function App() {
checkLock()
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
// 独立协议窗口
if (isAgreementWindow) {
return <AgreementPage />
@@ -614,6 +654,33 @@ function App() {
</div>
)}
{showWaylandWarning && (
<div className="agreement-overlay">
<div className="agreement-modal">
<div className="agreement-header">
<Shield size={32} />
<h2> (Wayland)</h2>
</div>
<div className="agreement-content">
<div className="agreement-text">
<p>使 <strong>Wayland</strong> </p>
<p> Wayland <strong></strong></p>
<p></p>
<br />
<p>使</p>
<p>1. <strong>X11 (Xorg)</strong> </p>
<p>2. (WM/DE) </p>
</div>
</div>
<div className="agreement-footer">
<div className="agreement-actions">
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}></button>
</div>
</div>
</div>
</div>
)}
{/* 更新提示对话框 */}
<UpdateDialog
open={showUpdateDialog}

View File

@@ -13,13 +13,14 @@
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow-y: auto;
border-radius: 12px;
border-radius: 16px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, var(--bg-primary));
padding: 12px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16);
}
.export-date-range-dialog-header {
@@ -83,8 +84,8 @@
}
.export-date-range-mode-banner {
border-radius: 8px;
padding: 6px 8px;
border-radius: 10px;
padding: 7px 10px;
font-size: 11px;
line-height: 1.4;
border: 1px solid var(--border-color);
@@ -98,47 +99,92 @@
}
}
.export-date-range-calendar-grid {
.export-date-range-boundary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.export-date-range-boundary-card {
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
padding: 8px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
.boundary-label {
font-size: 11px;
color: var(--text-secondary);
}
}
.export-date-range-selection-hint {
font-size: 11px;
color: var(--text-secondary);
padding: 0 2px;
}
.export-date-range-calendar-panel {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
padding: 7px;
border-radius: 12px;
background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.04), transparent 28%), var(--bg-secondary);
padding: 10px;
&.single {
width: 100%;
}
}
.export-date-range-calendar-panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-items: center;
gap: 8px;
}
.export-date-range-calendar-date-label {
display: flex;
flex-direction: column;
gap: 2px;
gap: 3px;
span {
font-size: 11px;
color: var(--text-secondary);
}
strong {
font-size: 13px;
color: var(--text-primary);
}
}
.export-date-range-date-input {
width: 100%;
min-width: 0;
border-radius: 6px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
height: 24px;
padding: 0 7px;
font-size: 11px;
height: 30px;
padding: 0 9px;
font-size: 12px;
&:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
&.invalid {
border-color: #e84d4d;
@@ -149,28 +195,36 @@
.export-date-range-calendar-nav {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 6px;
font-size: 11px;
color: var(--text-primary);
button {
width: 20px;
height: 20px;
border-radius: 5px;
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
}
.export-date-range-calendar-weekdays {
margin-top: 6px;
margin-top: 10px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
gap: 4px;
span {
text-align: center;
@@ -180,32 +234,61 @@
}
.export-date-range-calendar-days {
margin-top: 4px;
margin-top: 6px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
gap: 4px;
}
.export-date-range-calendar-day {
border: 1px solid transparent;
border-radius: 6px;
min-height: 20px;
border-radius: 10px;
min-height: 34px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 10px;
font-size: 12px;
cursor: pointer;
padding: 0;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease;
&:hover {
border-color: rgba(var(--primary-rgb), 0.28);
transform: translateY(-1px);
}
&:disabled:hover {
border-color: transparent;
transform: none;
}
&.outside {
color: var(--text-quaternary);
opacity: 0.75;
opacity: 0.72;
}
&.selected {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.14);
&.disabled {
cursor: not-allowed;
opacity: 0.35;
transform: none;
border-color: transparent;
}
&.in-range {
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
&.range-start,
&.range-end {
border-color: var(--primary);
background: var(--primary);
color: #fff;
font-weight: 600;
opacity: 1;
}
&.active-boundary {
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.22);
}
}
@@ -247,8 +330,8 @@
}
}
@media (max-width: 860px) {
.export-date-range-calendar-grid {
@media (max-width: 640px) {
.export-date-range-boundary-row {
grid-template-columns: 1fr;
}
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, X } from 'lucide-react'
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
import {
EXPORT_DATE_RANGE_PRESETS,
WEEKDAY_SHORT_LABELS,
@@ -25,29 +25,78 @@ interface ExportDateRangeDialogProps {
open: boolean
value: ExportDateRangeSelection
title?: string
minDate?: Date | null
maxDate?: Date | null
onClose: () => void
onConfirm: (value: ExportDateRangeSelection) => void
}
type ActiveBoundary = 'start' | 'end'
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
startPanelMonth: Date
endPanelMonth: Date
panelMonth: Date
}
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({
...cloneExportDateRangeSelection(value),
startPanelMonth: toMonthStart(value.dateRange.start),
endPanelMonth: toMonthStart(value.dateRange.end)
})
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
const normalizedMin = startOfDay(minDate)
const normalizedMax = endOfDay(maxDate)
if (normalizedMin.getTime() > normalizedMax.getTime()) return null
return {
minDate: normalizedMin,
maxDate: normalizedMax
}
}
const clampSelectionToBounds = (
value: ExportDateRangeSelection,
minDate?: Date | null,
maxDate?: Date | null
): ExportDateRangeSelection => {
const bounds = resolveBounds(minDate, maxDate)
if (!bounds) return cloneExportDateRangeSelection(value)
const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start)
const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end)
const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime()
return {
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
useAllTime: value.useAllTime,
dateRange: {
start: nextStart,
end: nextEnd
}
}
}
const buildDialogDraft = (
value: ExportDateRangeSelection,
minDate?: Date | null,
maxDate?: Date | null
): ExportDateRangeDialogDraft => {
const nextValue = clampSelectionToBounds(value, minDate, maxDate)
return {
...nextValue,
panelMonth: toMonthStart(nextValue.dateRange.start)
}
}
export function ExportDateRangeDialog({
open,
value,
title = '时间范围设置',
minDate,
maxDate,
onClose,
onConfirm
}: ExportDateRangeDialogProps) {
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value))
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
const [dateInput, setDateInput] = useState({
start: formatDateInputValue(value.dateRange.start),
end: formatDateInputValue(value.dateRange.end)
@@ -56,14 +105,15 @@ export function ExportDateRangeDialog({
useEffect(() => {
if (!open) return
const nextDraft = buildDialogDraft(value)
const nextDraft = buildDialogDraft(value, minDate, maxDate)
setDraft(nextDraft)
setActiveBoundary('start')
setDateInput({
start: formatDateInputValue(nextDraft.dateRange.start),
end: formatDateInputValue(nextDraft.dateRange.end)
})
setDateInputError({ start: false, end: false })
}, [open, value])
}, [maxDate, minDate, open, value])
useEffect(() => {
if (!open) return
@@ -74,33 +124,24 @@ export function ExportDateRangeDialog({
setDateInputError({ start: false, end: false })
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
if (preset === 'all') {
const previewRange = createDefaultDateRange()
setDraft(prev => ({
...prev,
preset,
useAllTime: true,
dateRange: previewRange,
startPanelMonth: toMonthStart(previewRange.start),
endPanelMonth: toMonthStart(previewRange.end)
}))
return
}
const range = createDateRangeByPreset(preset)
setDraft(prev => ({
...prev,
preset,
useAllTime: false,
dateRange: range,
startPanelMonth: toMonthStart(range.start),
endPanelMonth: toMonthStart(range.end)
}))
}, [])
const updateDraftStart = useCallback((targetDate: Date) => {
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
const clampStartDate = useCallback((targetDate: Date) => {
const start = startOfDay(targetDate)
if (!bounds) return start
if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate
if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate)
return start
}, [bounds])
const clampEndDate = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate)
if (!bounds) return end
if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate)
if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate
return end
}, [bounds])
const setRangeStart = useCallback((targetDate: Date) => {
const start = clampStartDate(targetDate)
setDraft(prev => {
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
return {
@@ -111,16 +152,15 @@ export function ExportDateRangeDialog({
start,
end: nextEnd
},
startPanelMonth: toMonthStart(start),
endPanelMonth: toMonthStart(nextEnd)
panelMonth: toMonthStart(start)
}
})
}, [])
}, [clampStartDate])
const updateDraftEnd = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate)
const setRangeEnd = useCallback((targetDate: Date) => {
const end = clampEndDate(targetDate)
setDraft(prev => {
const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
return {
...prev,
@@ -130,11 +170,41 @@ export function ExportDateRangeDialog({
start: nextStart,
end: nextEnd
},
startPanelMonth: toMonthStart(nextStart),
endPanelMonth: toMonthStart(nextEnd)
panelMonth: toMonthStart(targetDate)
}
})
}, [])
}, [clampEndDate, clampStartDate])
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
if (preset === 'all') {
const previewRange = bounds
? { start: bounds.minDate, end: bounds.maxDate }
: createDefaultDateRange()
setDraft(prev => ({
...prev,
preset,
useAllTime: true,
dateRange: previewRange,
panelMonth: toMonthStart(previewRange.start)
}))
setActiveBoundary('start')
return
}
const range = clampSelectionToBounds({
preset,
useAllTime: false,
dateRange: createDateRangeByPreset(preset)
}, minDate, maxDate).dateRange
setDraft(prev => ({
...prev,
preset,
useAllTime: false,
dateRange: range,
panelMonth: toMonthStart(range.start)
}))
setActiveBoundary('start')
}, [bounds, maxDate, minDate])
const commitStartFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.start)
@@ -143,8 +213,8 @@ export function ExportDateRangeDialog({
return
}
setDateInputError(prev => ({ ...prev, start: false }))
updateDraftStart(parsed)
}, [dateInput.start, updateDraftStart])
setRangeStart(parsed)
}, [dateInput.start, setRangeStart])
const commitEndFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.end)
@@ -153,29 +223,81 @@ export function ExportDateRangeDialog({
return
}
setDateInputError(prev => ({ ...prev, end: false }))
updateDraftEnd(parsed)
}, [dateInput.end, updateDraftEnd])
setRangeEnd(parsed)
}, [dateInput.end, setRangeEnd])
const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
setDraft(prev => (
panel === 'start'
? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) }
: { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) }
))
const shiftPanelMonth = useCallback((delta: number) => {
setDraft(prev => ({
...prev,
panelMonth: addMonths(prev.panelMonth, delta)
}))
}, [])
const handleCalendarSelect = useCallback((targetDate: Date) => {
if (activeBoundary === 'start') {
setRangeStart(targetDate)
setActiveBoundary('end')
return
}
setDraft(prev => {
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
const pickedStart = startOfDay(targetDate)
const nextStart = pickedStart <= start ? pickedStart : start
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
},
panelMonth: toMonthStart(targetDate)
}
})
setActiveBoundary('start')
}, [activeBoundary, setRangeEnd, setRangeStart])
const isRangeModeActive = !draft.useAllTime
const modeText = isRangeModeActive
? '当前导出模式:按时间范围导出'
: '当前导出模式:全部时间导出选择下方日期切换为时间范围导出)'
: '当前导出模式:全部时间导出选择下方日期切换为自定义时间范围'
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
if (preset === 'all') return draft.useAllTime
return !draft.useAllTime && draft.preset === preset
}, [draft])
const startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth])
const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth])
const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth])
const minPanelMonth = bounds ? toMonthStart(bounds.minDate) : null
const maxPanelMonth = bounds ? toMonthStart(bounds.maxDate) : null
const canShiftPrev = !minPanelMonth || draft.panelMonth.getTime() > minPanelMonth.getTime()
const canShiftNext = !maxPanelMonth || draft.panelMonth.getTime() < maxPanelMonth.getTime()
const isStartSelected = useCallback((date: Date) => (
!draft.useAllTime && isSameDay(date, draft.dateRange.start)
), [draft])
const isEndSelected = useCallback((date: Date) => (
!draft.useAllTime && isSameDay(date, draft.dateRange.end)
), [draft])
const isDateInRange = useCallback((date: Date) => (
!draft.useAllTime &&
startOfDay(date).getTime() >= startOfDay(draft.dateRange.start).getTime() &&
startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime()
), [draft])
const isDateSelectable = useCallback((date: Date) => {
if (!bounds) return true
const target = startOfDay(date).getTime()
return target >= startOfDay(bounds.minDate).getTime() && target <= startOfDay(bounds.maxDate).getTime()
}, [bounds])
const hintText = draft.useAllTime
? '选择开始或结束日期后,会自动切换为自定义时间范围'
: (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期')
if (!open) return null
@@ -215,112 +337,115 @@ export function ExportDateRangeDialog({
{modeText}
</div>
<div className="export-date-range-calendar-grid">
<section className="export-date-range-calendar-panel">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
value={dateInput.start}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, start: nextValue }))
if (dateInputError.start) {
setDateInputError(prev => ({ ...prev, start: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitStartFromInput()
}}
onBlur={commitStartFromInput}
/>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth('start', -1)} aria-label="上个月"></button>
<span>{formatCalendarMonthTitle(draft.startPanelMonth)}</span>
<button type="button" onClick={() => shiftPanelMonth('start', 1)} aria-label="下个月"></button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`start-weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{startPanelCells.map((cell) => {
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start)
return (
<button
key={`start-${cell.date.getTime()}`}
type="button"
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
onClick={() => updateDraftStart(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
<section className="export-date-range-calendar-panel">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
value={dateInput.end}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, end: nextValue }))
if (dateInputError.end) {
setDateInputError(prev => ({ ...prev, end: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitEndFromInput()
}}
onBlur={commitEndFromInput}
/>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth('end', -1)} aria-label="上个月"></button>
<span>{formatCalendarMonthTitle(draft.endPanelMonth)}</span>
<button type="button" onClick={() => shiftPanelMonth('end', 1)} aria-label="下个月"></button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`end-weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{endPanelCells.map((cell) => {
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end)
return (
<button
key={`end-${cell.date.getTime()}`}
type="button"
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
onClick={() => updateDraftEnd(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
<div className="export-date-range-boundary-row">
<div
className={`export-date-range-boundary-card ${activeBoundary === 'start' ? 'active' : ''}`}
onClick={() => setActiveBoundary('start')}
>
<span className="boundary-label"></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
value={dateInput.start}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, start: nextValue }))
if (dateInputError.start) {
setDateInputError(prev => ({ ...prev, start: false }))
}
}}
onFocus={() => setActiveBoundary('start')}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitStartFromInput()
}}
onBlur={commitStartFromInput}
/>
</div>
<div
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
onClick={() => setActiveBoundary('end')}
>
<span className="boundary-label"></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
value={dateInput.end}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, end: nextValue }))
if (dateInputError.end) {
setDateInputError(prev => ({ ...prev, end: false }))
}
}}
onFocus={() => setActiveBoundary('end')}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitEndFromInput()
}}
onBlur={commitEndFromInput}
/>
</div>
</div>
<div className="export-date-range-selection-hint">{hintText}</div>
<section className="export-date-range-calendar-panel single">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<strong>{formatCalendarMonthTitle(draft.panelMonth)}</strong>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth(-1)} aria-label="上个月" disabled={!canShiftPrev}>
<ChevronLeft size={14} />
</button>
<button type="button" onClick={() => shiftPanelMonth(1)} aria-label="下个月" disabled={!canShiftNext}>
<ChevronRight size={14} />
</button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{calendarCells.map((cell) => {
const startSelected = isStartSelected(cell.date)
const endSelected = isEndSelected(cell.date)
const inRange = isDateInRange(cell.date)
const selectable = isDateSelectable(cell.date)
return (
<button
key={cell.date.getTime()}
type="button"
disabled={!selectable}
className={[
'export-date-range-calendar-day',
cell.inCurrentMonth ? '' : 'outside',
selectable ? '' : 'disabled',
inRange ? 'in-range' : '',
startSelected ? 'range-start' : '',
endSelected ? 'range-end' : '',
activeBoundary === 'start' && startSelected ? 'active-boundary' : '',
activeBoundary === 'end' && endSelected ? 'active-boundary' : ''
].filter(Boolean).join(' ')}
onClick={() => handleCalendarSelect(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
<div className="export-date-range-dialog-actions">
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react'
import { SnsPost, SnsLinkCardData } from '../../types/sns'
import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
import { Avatar } from '../Avatar'
import { SnsMediaGrid } from './SnsMediaGrid'
import { getEmojiPath } from 'wechat-emojis'
@@ -134,6 +134,30 @@ const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
}
}
const buildLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const normalize = (value?: string): string => (
decodeHtmlEntities(String(value || '')).replace(/\s+/g, ' ').trim()
)
const primary = [
normalize(location.poiName),
normalize(location.poiAddressName),
normalize(location.label),
normalize(location.poiAddress)
].find(Boolean) || ''
const region = [normalize(location.country), normalize(location.city)]
.filter(Boolean)
.join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
const [thumbFailed, setThumbFailed] = useState(false)
const hostname = useMemo(() => {
@@ -254,6 +278,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const linkCard = buildLinkCardData(post)
const locationText = useMemo(() => buildLocationText(post.location), [post.location])
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
const showMediaGrid = post.media.length > 0 && !showLinkCard
@@ -379,6 +404,13 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
)}
{locationText && (
<div className="post-location" title={locationText}>
<MapPin size={14} />
<span className="post-location-text">{locationText}</span>
</div>
)}
{showLinkCard && linkCard && (
<SnsLinkCard card={linkCard} />
)}

View File

@@ -625,7 +625,7 @@
.bubble-content {
background: var(--primary-gradient);
color: #fff;
color: var(--on-primary);
border-radius: 18px 18px 4px 18px;
padding: 10px 14px;
font-size: 14px;
@@ -1962,7 +1962,7 @@
.bubble-content {
background: var(--primary);
color: white;
color: var(--on-primary);
border-radius: 18px 18px 4px 18px;
}
}
@@ -2420,7 +2420,6 @@
background: rgba(0, 0, 0, 0.04);
border-left: 2px solid var(--primary);
padding: 6px 10px;
margin-bottom: 8px;
border-radius: 4px;
font-size: 13px;
@@ -2454,15 +2453,15 @@
// 自己发送的消息中的引用样式
.message-bubble.sent .quoted-message {
background: rgba(255, 255, 255, 0.15);
border-left-color: rgba(255, 255, 255, 0.5);
background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
border-left-color: color-mix(in srgb, var(--on-primary) 36%, var(--primary));
.quoted-sender {
color: rgba(255, 255, 255, 0.9);
color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
}
.quoted-text {
color: rgba(255, 255, 255, 0.8);
color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
}
}
@@ -2482,6 +2481,14 @@
.bubble-content {
-webkit-app-region: no-drag;
&.quote-layout-top .quoted-message {
margin-bottom: 8px;
}
&.quote-layout-bottom .quoted-message {
margin-top: 8px;
}
}
// 时间分隔

View File

@@ -52,6 +52,8 @@ interface GlobalMsgPrefixCacheEntry {
completed: boolean
}
type QuoteLayout = configService.QuoteLayout
const GLOBAL_MSG_PER_SESSION_LIMIT = 10
const GLOBAL_MSG_SEED_LIMIT = 120
const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3
@@ -585,6 +587,263 @@ interface GroupPanelMember {
messageCountStatus: GroupMessageCountStatus
}
const QUOTED_SENDER_CACHE_TTL_MS = 10 * 60 * 1000
const quotedSenderDisplayCache = new Map<string, { displayName: string; updatedAt: number }>()
const quotedSenderDisplayLoading = new Map<string, Promise<string | undefined>>()
const quotedGroupMembersCache = new Map<string, { members: GroupPanelMember[]; updatedAt: number }>()
const quotedGroupMembersLoading = new Map<string, Promise<GroupPanelMember[]>>()
function buildQuotedSenderCacheKey(
sessionId: string,
senderUsername: string,
isGroupChat: boolean
): string {
const normalizedSessionId = normalizeSearchIdentityText(sessionId) || String(sessionId || '').trim()
const normalizedSender = normalizeSearchIdentityText(senderUsername) || String(senderUsername || '').trim()
return `${isGroupChat ? 'group' : 'direct'}::${normalizedSessionId}::${normalizedSender}`
}
function isSameQuotedSenderIdentity(left?: string | null, right?: string | null): boolean {
const leftCandidates = buildSearchIdentityCandidates(left)
const rightCandidates = buildSearchIdentityCandidates(right)
if (leftCandidates.length === 0 || rightCandidates.length === 0) {
return false
}
for (const leftCandidate of leftCandidates) {
for (const rightCandidate of rightCandidates) {
if (leftCandidate === rightCandidate) return true
if (leftCandidate.startsWith(rightCandidate + '_')) return true
if (rightCandidate.startsWith(leftCandidate + '_')) return true
}
}
return false
}
function normalizeQuotedGroupMember(member: Partial<GroupPanelMember> | null | undefined): GroupPanelMember | null {
const username = String(member?.username || '').trim()
if (!username) return null
const displayName = String(member?.displayName || '').trim()
const nickname = String(member?.nickname || '').trim()
const remark = String(member?.remark || '').trim()
const alias = String(member?.alias || '').trim()
const groupNickname = String(member?.groupNickname || '').trim()
return {
username,
displayName: displayName || groupNickname || remark || nickname || alias || username,
avatarUrl: member?.avatarUrl,
nickname,
alias,
remark,
groupNickname,
isOwner: Boolean(member?.isOwner),
isFriend: Boolean(member?.isFriend),
messageCount: Number.isFinite(member?.messageCount) ? Math.max(0, Math.floor(member?.messageCount as number)) : 0,
messageCountStatus: 'ready'
}
}
function resolveQuotedSenderFallbackDisplayName(
sessionId: string,
senderUsername?: string | null,
fallbackDisplayName?: string | null
): string | undefined {
const resolved = resolveSearchSenderDisplayName(fallbackDisplayName, senderUsername, sessionId)
if (resolved) return resolved
return resolveSearchSenderUsernameFallback(senderUsername)
}
function resolveQuotedSenderUsername(
fromusr?: string | null,
chatusr?: string | null
): string {
const normalizedChatUsr = String(chatusr || '').trim()
const normalizedFromUsr = String(fromusr || '').trim()
if (normalizedChatUsr) {
return normalizedChatUsr
}
if (normalizedFromUsr.endsWith('@chatroom')) {
return ''
}
return normalizedFromUsr
}
function resolveQuotedGroupMemberDisplayName(member: GroupPanelMember): string | undefined {
const remark = normalizeSearchIdentityText(member.remark)
if (remark) return remark
const groupNickname = normalizeSearchIdentityText(member.groupNickname)
if (groupNickname) return groupNickname
const nickname = normalizeSearchIdentityText(member.nickname)
if (nickname) return nickname
const displayName = resolveSearchSenderDisplayName(member.displayName, member.username)
if (displayName) return displayName
const alias = normalizeSearchIdentityText(member.alias)
if (alias) return alias
return resolveSearchSenderUsernameFallback(member.username)
}
function resolveQuotedPrivateDisplayName(contact: any): string | undefined {
const remark = normalizeSearchIdentityText(contact?.remark)
if (remark) return remark
const nickname = normalizeSearchIdentityText(
contact?.nickName || contact?.nick_name || contact?.nickname
)
if (nickname) return nickname
const alias = normalizeSearchIdentityText(contact?.alias)
if (alias) return alias
return undefined
}
async function getQuotedGroupMembers(chatroomId: string): Promise<GroupPanelMember[]> {
const normalizedChatroomId = String(chatroomId || '').trim()
if (!normalizedChatroomId || !normalizedChatroomId.includes('@chatroom')) {
return []
}
const cached = quotedGroupMembersCache.get(normalizedChatroomId)
if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) {
return cached.members
}
const pending = quotedGroupMembersLoading.get(normalizedChatroomId)
if (pending) return pending
const request = window.electronAPI.groupAnalytics.getGroupMembersPanelData(
normalizedChatroomId,
{ forceRefresh: false, includeMessageCounts: false }
).then((result) => {
const members = Array.isArray(result.data)
? result.data
.map((member) => normalizeQuotedGroupMember(member as Partial<GroupPanelMember>))
.filter((member): member is GroupPanelMember => Boolean(member))
: []
if (members.length > 0) {
quotedGroupMembersCache.set(normalizedChatroomId, {
members,
updatedAt: Date.now()
})
return members
}
return cached?.members || []
}).catch(() => cached?.members || []).finally(() => {
quotedGroupMembersLoading.delete(normalizedChatroomId)
})
quotedGroupMembersLoading.set(normalizedChatroomId, request)
return request
}
async function resolveQuotedSenderDisplayName(options: {
sessionId: string
senderUsername?: string | null
fallbackDisplayName?: string | null
isGroupChat?: boolean
myWxid?: string | null
}): Promise<string | undefined> {
const normalizedSessionId = String(options.sessionId || '').trim()
const normalizedSender = String(options.senderUsername || '').trim()
const fallbackDisplayName = resolveQuotedSenderFallbackDisplayName(
normalizedSessionId,
normalizedSender,
options.fallbackDisplayName
)
if (!normalizedSender) {
return fallbackDisplayName
}
const cacheKey = buildQuotedSenderCacheKey(normalizedSessionId, normalizedSender, Boolean(options.isGroupChat))
const cached = quotedSenderDisplayCache.get(cacheKey)
if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) {
return cached.displayName
}
const pending = quotedSenderDisplayLoading.get(cacheKey)
if (pending) return pending
const request = (async (): Promise<string | undefined> => {
if (options.isGroupChat) {
const members = await getQuotedGroupMembers(normalizedSessionId)
const matchedMember = members.find((member) => isSameQuotedSenderIdentity(member.username, normalizedSender))
const groupDisplayName = matchedMember ? resolveQuotedGroupMemberDisplayName(matchedMember) : undefined
if (groupDisplayName) {
quotedSenderDisplayCache.set(cacheKey, {
displayName: groupDisplayName,
updatedAt: Date.now()
})
return groupDisplayName
}
}
if (isCurrentUserSearchIdentity(normalizedSender, options.myWxid)) {
const selfDisplayName = fallbackDisplayName || '我'
quotedSenderDisplayCache.set(cacheKey, {
displayName: selfDisplayName,
updatedAt: Date.now()
})
return selfDisplayName
}
try {
const contact = await window.electronAPI.chat.getContact(normalizedSender)
const contactDisplayName = resolveQuotedPrivateDisplayName(contact)
if (contactDisplayName) {
quotedSenderDisplayCache.set(cacheKey, {
displayName: contactDisplayName,
updatedAt: Date.now()
})
return contactDisplayName
}
} catch {
// ignore contact lookup failures and fall back below
}
try {
const profile = await window.electronAPI.chat.getContactAvatar(normalizedSender)
const profileDisplayName = normalizeSearchIdentityText(profile?.displayName)
if (profileDisplayName && !isWxidLikeSearchIdentity(profileDisplayName)) {
quotedSenderDisplayCache.set(cacheKey, {
displayName: profileDisplayName,
updatedAt: Date.now()
})
return profileDisplayName
}
} catch {
// ignore avatar lookup failures and keep fallback usable
}
if (fallbackDisplayName) {
quotedSenderDisplayCache.set(cacheKey, {
displayName: fallbackDisplayName,
updatedAt: Date.now()
})
}
return fallbackDisplayName
})().finally(() => {
quotedSenderDisplayLoading.delete(cacheKey)
})
quotedSenderDisplayLoading.set(cacheKey, request)
return request
}
interface SessionListCachePayload {
updatedAt: number
sessions: ChatSession[]
@@ -2394,6 +2653,10 @@ function ChatPage(props: ChatPageProps) {
const handleAccountChanged = useCallback(async () => {
senderAvatarCache.clear()
senderAvatarLoading.clear()
quotedSenderDisplayCache.clear()
quotedSenderDisplayLoading.clear()
quotedGroupMembersCache.clear()
quotedGroupMembersLoading.clear()
sessionContactProfileCacheRef.current.clear()
pendingSessionContactEnrichRef.current.clear()
sessionContactEnrichAttemptAtRef.current.clear()
@@ -5660,6 +5923,7 @@ function ChatPage(props: ChatPageProps) {
session={currentSession!}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
myWxid={myWxid}
isGroupChat={isCurrentSessionGroup}
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
onRequireModelDownload={handleRequireModelDownload}
@@ -5678,6 +5942,7 @@ function ChatPage(props: ChatPageProps) {
formatDateDivider,
currentSession,
myAvatarUrl,
myWxid,
isCurrentSessionGroup,
autoTranscribeVoiceEnabled,
handleRequireModelDownload,
@@ -7258,6 +7523,7 @@ function MessageBubble({
session,
showTime,
myAvatarUrl,
myWxid,
isGroupChat,
autoTranscribeVoiceEnabled,
onRequireModelDownload,
@@ -7271,6 +7537,7 @@ function MessageBubble({
session: ChatSession;
showTime?: boolean;
myAvatarUrl?: string;
myWxid?: string;
isGroupChat?: boolean;
autoTranscribeVoiceEnabled?: boolean;
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
@@ -7290,6 +7557,8 @@ function MessageBubble({
const isSent = message.isSend === 1
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
const [senderName, setSenderName] = useState<string | undefined>(undefined)
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
const [quoteLayout, setQuoteLayout] = useState<QuoteLayout>('quote-top')
const senderProfileRequestSeqRef = useRef(0)
const [emojiError, setEmojiError] = useState(false)
const [emojiLoading, setEmojiLoading] = useState(false)
@@ -7345,6 +7614,12 @@ function MessageBubble({
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
const voiceAutoDecryptTriggered = useRef(false)
const [systemAlert, setSystemAlert] = useState<{
title: string;
message: React.ReactNode;
} | null>(null)
// 转账消息双方名称
const [transferPayerName, setTransferPayerName] = useState<string | undefined>(undefined)
const [transferReceiverName, setTransferReceiverName] = useState<string | undefined>(undefined)
@@ -8024,9 +8299,9 @@ function MessageBubble({
}
const result = await window.electronAPI.chat.getVoiceTranscript(
session.username,
String(message.localId),
message.createTime
session.username,
String(message.localId),
message.createTime
)
if (result.success) {
@@ -8034,6 +8309,21 @@ function MessageBubble({
voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText)
setVoiceTranscript(transcriptText)
} else {
if (result.error === 'SEGFAULT_ERROR') {
console.warn('[ChatPage] 捕获到语音引擎底层段错误');
setSystemAlert({
title: '引擎崩溃提示',
message: (
<>
(Segmentation Fault)<br /><br />
使 Linux <code>sherpa-onnx</code> ( glibc )
</>
)
});
}
setVoiceTranscriptError(true)
voiceTranscriptRequestedRef.current = false
}
@@ -8214,6 +8504,65 @@ function MessageBubble({
appMsgTextCache.set(selector, value)
return value
}, [appMsgDoc, appMsgTextCache])
const quotedSenderUsername = resolveQuotedSenderUsername(
queryAppMsgText('refermsg > fromusr'),
queryAppMsgText('refermsg > chatusr')
)
const quotedContent = message.quotedContent || queryAppMsgText('refermsg > content') || ''
const quotedSenderFallbackName = useMemo(
() => resolveQuotedSenderFallbackDisplayName(
session.username,
quotedSenderUsername,
message.quotedSender || queryAppMsgText('refermsg > displayname') || ''
),
[message.quotedSender, queryAppMsgText, quotedSenderUsername, session.username]
)
useEffect(() => {
let cancelled = false
const nextFallbackName = quotedSenderFallbackName || undefined
setQuotedSenderName(nextFallbackName)
if (!quotedContent || !quotedSenderUsername) {
return () => {
cancelled = true
}
}
void resolveQuotedSenderDisplayName({
sessionId: session.username,
senderUsername: quotedSenderUsername,
fallbackDisplayName: nextFallbackName,
isGroupChat,
myWxid
}).then((resolvedName) => {
if (cancelled) return
setQuotedSenderName(resolvedName || nextFallbackName)
})
return () => {
cancelled = true
}
}, [
quotedContent,
quotedSenderFallbackName,
quotedSenderUsername,
session.username,
isGroupChat,
myWxid
])
useEffect(() => {
let cancelled = false
void configService.getQuoteLayout().then((layout) => {
if (!cancelled) setQuoteLayout(layout)
}).catch(() => {
if (!cancelled) setQuoteLayout('quote-top')
})
return () => {
cancelled = true
}
}, [])
const locationMessageMeta = useMemo(() => {
if (message.localType !== 48) return null
@@ -8248,7 +8597,33 @@ function MessageBubble({
: (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl)
// 是否有引用消息
const hasQuote = message.quotedContent && message.quotedContent.length > 0
const hasQuote = quotedContent.length > 0
const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName
const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => {
const quoteFirst = quoteLayout !== 'quote-bottom'
return (
<div className={`bubble-content ${quoteFirst ? 'quote-layout-top' : 'quote-layout-bottom'}`}>
{quoteFirst ? (
<>
{quotedNode}
{messageNode}
</>
) : (
<>
{messageNode}
{quotedNode}
</>
)}
</div>
)
}, [quoteLayout])
const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => (
<div className="quoted-message">
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{contentNode}</span>
</div>
), [displayQuotedSenderName])
const handlePlayVideo = useCallback(async () => {
if (!videoInfo?.videoUrl) return
@@ -8659,7 +9034,6 @@ function MessageBubble({
if (xmlType === '57') {
const replyText = q('title') || cleanedParsedContent || ''
const referContent = q('refermsg > content') || ''
const referSender = q('refermsg > displayname') || ''
const referType = q('refermsg > type') || ''
// 根据被引用消息类型渲染对应内容
@@ -8689,13 +9063,10 @@ function MessageBubble({
}
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderReferContent()}</span>
</div>
renderBubbleWithQuote(
renderQuotedMessageBlock(renderReferContent()),
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
)
}
@@ -8787,15 +9158,11 @@ function MessageBubble({
// 引用回复消息appMsgKind='quote'xmlType=57
const replyText = message.linkTitle || q('title') || cleanedParsedContent || ''
const referContent = message.quotedContent || q('refermsg > content') || ''
const referSender = message.quotedSender || q('refermsg > displayname') || ''
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
</div>
renderBubbleWithQuote(
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))),
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
)
}
@@ -8982,7 +9349,6 @@ function MessageBubble({
if (appMsgType === '57') {
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || ''
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || ''
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
const renderReferContent2 = () => {
@@ -9006,13 +9372,10 @@ function MessageBubble({
}
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderReferContent2()}</span>
</div>
renderBubbleWithQuote(
renderQuotedMessageBlock(renderReferContent2()),
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
)
}
@@ -9291,14 +9654,9 @@ function MessageBubble({
// 带引用的消息
if (hasQuote) {
return (
<div className="bubble-content">
<div className="quoted-message">
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanedParsedContent)}</div>
</div>
return renderBubbleWithQuote(
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))),
<div className="message-text">{renderTextWithEmoji(cleanedParsedContent)}</div>
)
}
@@ -9388,6 +9746,31 @@ function MessageBubble({
{isSelected && <Check size={14} strokeWidth={3} />}
</div>
)}
{systemAlert && createPortal(
<div className="modal-overlay" onClick={() => setSystemAlert(null)} style={{ zIndex: 99999 }}>
<div className="delete-confirm-card" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<div className="confirm-icon">
<AlertCircle size={32} color="var(--danger)" />
</div>
<div className="confirm-content">
<h3>{systemAlert.title}</h3>
<p style={{ marginTop: '12px', lineHeight: '1.6', fontSize: '14px', color: 'var(--text-secondary)' }}>
{systemAlert.message}
</p>
</div>
<div className="confirm-actions" style={{ justifyContent: 'center', marginTop: '24px' }}>
<button
className="btn-primary"
onClick={() => setSystemAlert(null)}
style={{ padding: '8px 32px' }}
>
</button>
</div>
</div>
</div>,
document.body
)}
</div>
</>
)
@@ -9398,6 +9781,7 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
if (prevProps.messageKey !== nextProps.messageKey) return false
if (prevProps.showTime !== nextProps.showTime) return false
if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false
if (prevProps.myWxid !== nextProps.myWxid) return false
if (prevProps.isGroupChat !== nextProps.isGroupChat) return false
if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false
if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false

View File

@@ -18,7 +18,7 @@ const AVATAR_ENRICH_BATCH_SIZE = 80
const SEARCH_DEBOUNCE_MS = 120
const VIRTUAL_ROW_HEIGHT = 76
const VIRTUAL_OVERSCAN = 10
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
interface ContactsLoadSession {
@@ -397,6 +397,10 @@ function ContactsPage() {
displayName: contact.displayName,
remark: contact.remark,
nickname: contact.nickname,
alias: contact.alias,
labels: contact.labels,
detailDescription: contact.detailDescription,
region: contact.region,
type: contact.type
}))
).catch((error) => {
@@ -1110,6 +1114,16 @@ function ContactsPage() {
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.username}</span></div>
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
{selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>}
{selectedContact.alias && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.alias}</span></div>}
{selectedContact.labels && selectedContact.labels.length > 0 && (
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.labels.join('、')}</span></div>
)}
{selectedContact.detailDescription && (
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.detailDescription}</span></div>
)}
{selectedContact.region && (
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.region}</span></div>
)}
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
{selectedContactSupportsSns && (
<div className="detail-row">

View File

@@ -49,13 +49,17 @@ import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm'
import { Avatar } from '../components/Avatar'
import type { SnsPost } from '../types/sns'
import {
cloneExportDateRange,
cloneExportDateRangeSelection,
createDefaultDateRange,
createDefaultExportDateRangeSelection,
getExportDateRangeLabel,
resolveExportDateRangeConfig,
startOfDay,
endOfDay,
type ExportDateRangeSelection
} from '../utils/exportDateRange'
import './ExportPage.scss'
@@ -89,6 +93,7 @@ interface ExportOptions {
txtColumns: string[]
displayNamePreference: DisplayNamePreference
exportConcurrency: number
imageDeepSearchOnMiss: boolean
}
interface SessionRow extends AppChatSession {
@@ -534,6 +539,14 @@ const getAvatarLetter = (name: string): string => {
return [...name][0] || '?'
}
const normalizeExportAvatarUrl = (value?: string | null): string | undefined => {
const normalized = String(value || '').trim()
if (!normalized) return undefined
const lower = normalized.toLowerCase()
if (lower === 'null' || lower === 'undefined') return undefined
return normalized
}
const toComparableNameSet = (values: Array<string | undefined | null>): Set<string> => {
const set = new Set<string>()
for (const value of values) {
@@ -555,7 +568,7 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(
const CONTACT_ENRICH_TIMEOUT_MS = 7000
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
@@ -830,6 +843,13 @@ interface SessionContentMetric {
transferMessages?: number
redPacketMessages?: number
callMessages?: number
firstTimestamp?: number
lastTimestamp?: number
}
interface TimeRangeBounds {
minDate: Date
maxDate: Date
}
interface SessionExportCacheMeta {
@@ -1016,7 +1036,7 @@ const toSessionRowsWithContacts = (
kind: toKindByContact(contact),
wechatId: contact.username,
displayName: contact.displayName || session?.displayName || contact.username,
avatarUrl: contact.avatarUrl || session?.avatarUrl,
avatarUrl: session?.avatarUrl || contact.avatarUrl,
hasSession: Boolean(session)
} as SessionRow
})
@@ -1036,7 +1056,7 @@ const toSessionRowsWithContacts = (
kind: toKindByContactType(session, contact),
wechatId: contact?.username || session.username,
displayName: contact?.displayName || session.displayName || session.username,
avatarUrl: contact?.avatarUrl || session.avatarUrl,
avatarUrl: session.avatarUrl || contact?.avatarUrl,
hasSession: true
} as SessionRow
})
@@ -1049,27 +1069,74 @@ const normalizeMessageCount = (value: unknown): number | undefined => {
return Math.floor(parsed)
}
const normalizeTimestampSeconds = (value: unknown): number | undefined => {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
return Math.floor(parsed)
}
const clampExportSelectionToBounds = (
selection: ExportDateRangeSelection,
bounds: TimeRangeBounds | null
): ExportDateRangeSelection => {
if (!bounds) return cloneExportDateRangeSelection(selection)
const boundedStart = startOfDay(bounds.minDate)
const boundedEnd = endOfDay(bounds.maxDate)
const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start)
const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end)
const nextStart = new Date(Math.min(Math.max(originalStart.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
const nextEndCandidate = new Date(Math.min(Math.max(originalEnd.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
const rangeChanged = nextStart.getTime() !== originalStart.getTime() || nextEnd.getTime() !== originalEnd.getTime()
return {
preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset),
useAllTime: selection.useAllTime,
dateRange: {
start: nextStart,
end: nextEnd
}
}
}
const areExportSelectionsEqual = (left: ExportDateRangeSelection, right: ExportDateRangeSelection): boolean => (
left.preset === right.preset &&
left.useAllTime === right.useAllTime &&
left.dateRange.start.getTime() === right.dateRange.start.getTime() &&
left.dateRange.end.getTime() === right.dateRange.end.getTime()
)
const pickSessionMediaMetric = (
metricRaw: SessionExportMetric | SessionContentMetric | undefined
): SessionContentMetric | null => {
if (!metricRaw) return null
const totalMessages = normalizeMessageCount(metricRaw.totalMessages)
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
const firstTimestamp = normalizeTimestampSeconds(metricRaw.firstTimestamp)
const lastTimestamp = normalizeTimestampSeconds(metricRaw.lastTimestamp)
if (
typeof totalMessages !== 'number' &&
typeof voiceMessages !== 'number' &&
typeof imageMessages !== 'number' &&
typeof videoMessages !== 'number' &&
typeof emojiMessages !== 'number'
typeof emojiMessages !== 'number' &&
typeof firstTimestamp !== 'number' &&
typeof lastTimestamp !== 'number'
) {
return null
}
return {
totalMessages,
voiceMessages,
imageMessages,
videoMessages,
emojiMessages
emojiMessages,
firstTimestamp,
lastTimestamp
}
}
@@ -1520,6 +1587,8 @@ function ExportPage() {
const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false)
const [snsExportVideos, setSnsExportVideos] = useState(false)
const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false)
const [isResolvingTimeRangeBounds, setIsResolvingTimeRangeBounds] = useState(false)
const [timeRangeBounds, setTimeRangeBounds] = useState<TimeRangeBounds | null>(null)
const [isExportDefaultsModalOpen, setIsExportDefaultsModalOpen] = useState(false)
const [timeRangeSelection, setTimeRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
@@ -1534,6 +1603,7 @@ function ExportPage() {
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
const [options, setOptions] = useState<ExportOptions>({
format: 'json',
@@ -1552,7 +1622,8 @@ function ExportPage() {
excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark',
exportConcurrency: 2
exportConcurrency: 2,
imageDeepSearchOnMiss: true
})
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
@@ -1651,6 +1722,7 @@ function ExportPage() {
startIndex: 0,
endIndex: -1
})
const avatarHydrationRequestedRef = useRef<Set<string>>(new Set())
const sessionMutualFriendsMetricsRef = useRef<Record<string, SessionMutualFriendsMetric>>({})
const sessionMutualFriendsDirectMetricsRef = useRef<Record<string, SessionMutualFriendsMetric>>({})
const sessionMutualFriendsQueueRef = useRef<string[]>([])
@@ -1856,7 +1928,7 @@ function ExportPage() {
setIsContactsListLoading(true)
try {
const contactsResult = await window.electronAPI.chat.getContacts()
const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
if (contactsLoadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) {
@@ -1895,6 +1967,7 @@ function ExportPage() {
displayName: contact.displayName,
remark: contact.remark,
nickname: contact.nickname,
alias: contact.alias,
type: contact.type
}))
).catch((error) => {
@@ -1936,6 +2009,94 @@ function ExportPage() {
}
}, [ensureExportCacheScope, syncContactTypeCounts])
const hydrateVisibleContactAvatars = useCallback(async (usernames: string[]) => {
const targets = Array.from(new Set(
(usernames || [])
.map((username) => String(username || '').trim())
.filter(Boolean)
)).filter((username) => {
if (avatarHydrationRequestedRef.current.has(username)) return false
const contact = contactsList.find((item) => item.username === username)
const session = sessions.find((item) => item.username === username)
const existingAvatarUrl = normalizeExportAvatarUrl(contact?.avatarUrl || session?.avatarUrl)
return !existingAvatarUrl
})
if (targets.length === 0) return
targets.forEach((username) => avatarHydrationRequestedRef.current.add(username))
const settled = await Promise.allSettled(
targets.map(async (username) => {
const profile = await window.electronAPI.chat.getContactAvatar(username)
return {
username,
avatarUrl: normalizeExportAvatarUrl(profile?.avatarUrl),
displayName: profile?.displayName ? String(profile.displayName).trim() : undefined
}
})
)
const avatarPatches = new Map<string, { avatarUrl?: string; displayName?: string }>()
for (const item of settled) {
if (item.status !== 'fulfilled') continue
const { username, avatarUrl, displayName } = item.value
if (!avatarUrl && !displayName) continue
avatarPatches.set(username, { avatarUrl, displayName })
}
if (avatarPatches.size === 0) return
const now = Date.now()
setContactsList((prev) => prev.map((contact) => {
const patch = avatarPatches.get(contact.username)
if (!patch) return contact
return {
...contact,
displayName: patch.displayName || contact.displayName,
avatarUrl: patch.avatarUrl || contact.avatarUrl
}
}))
setSessions((prev) => prev.map((session) => {
const patch = avatarPatches.get(session.username)
if (!patch) return session
return {
...session,
displayName: patch.displayName || session.displayName,
avatarUrl: patch.avatarUrl || session.avatarUrl
}
}))
setSessionDetail((prev) => {
if (!prev) return prev
const patch = avatarPatches.get(prev.wxid)
if (!patch) return prev
return {
...prev,
displayName: patch.displayName || prev.displayName,
avatarUrl: patch.avatarUrl || prev.avatarUrl
}
})
let avatarCacheChanged = false
for (const [username, patch] of avatarPatches.entries()) {
if (!patch.avatarUrl) continue
const previous = contactsAvatarCacheRef.current[username]
if (previous?.avatarUrl === patch.avatarUrl) continue
contactsAvatarCacheRef.current[username] = {
avatarUrl: patch.avatarUrl,
updatedAt: now,
checkedAt: now
}
avatarCacheChanged = true
}
if (avatarCacheChanged) {
setAvatarCacheUpdatedAt(now)
const scopeKey = exportCacheScopeRef.current
if (scopeKey) {
void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch(() => {})
}
}
}, [contactsList, sessions])
useEffect(() => {
if (!isExportRoute) return
let cancelled = false
@@ -2079,7 +2240,7 @@ function ExportPage() {
setIsBaseConfigLoading(true)
let isReady = true
try {
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
configService.getExportPath(),
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
@@ -2088,6 +2249,7 @@ function ExportPage() {
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency(),
configService.getExportDefaultImageDeepSearchOnMiss(),
configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(),
configService.getExportSessionRecordMap(),
@@ -2124,6 +2286,7 @@ function ExportPage() {
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2)
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
setTimeRangeSelection(resolvedDefaultDateRange)
@@ -2156,7 +2319,8 @@ function ExportPage() {
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
txtColumns,
exportConcurrency: savedConcurrency ?? prev.exportConcurrency
exportConcurrency: savedConcurrency ?? prev.exportConcurrency,
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
}))
} catch (error) {
isReady = false
@@ -2686,7 +2850,9 @@ function ExportPage() {
typeof emojiMessages !== 'number' &&
typeof transferMessages !== 'number' &&
typeof redPacketMessages !== 'number' &&
typeof callMessages !== 'number'
typeof callMessages !== 'number' &&
typeof normalizeTimestampSeconds(metricRaw.firstTimestamp) !== 'number' &&
typeof normalizeTimestampSeconds(metricRaw.lastTimestamp) !== 'number'
) {
continue
}
@@ -2699,7 +2865,9 @@ function ExportPage() {
emojiMessages,
transferMessages,
redPacketMessages,
callMessages
callMessages,
firstTimestamp: normalizeTimestampSeconds(metricRaw.firstTimestamp),
lastTimestamp: normalizeTimestampSeconds(metricRaw.lastTimestamp)
}
if (typeof totalMessages === 'number') {
nextMessageCounts[sessionId] = totalMessages
@@ -2733,7 +2901,9 @@ function ExportPage() {
emojiMessages: typeof metric.emojiMessages === 'number' ? metric.emojiMessages : previous.emojiMessages,
transferMessages: typeof metric.transferMessages === 'number' ? metric.transferMessages : previous.transferMessages,
redPacketMessages: typeof metric.redPacketMessages === 'number' ? metric.redPacketMessages : previous.redPacketMessages,
callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages
callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages,
firstTimestamp: typeof metric.firstTimestamp === 'number' ? metric.firstTimestamp : previous.firstTimestamp,
lastTimestamp: typeof metric.lastTimestamp === 'number' ? metric.lastTimestamp : previous.lastTimestamp
}
if (
previous.totalMessages === nextMetric.totalMessages &&
@@ -2743,7 +2913,9 @@ function ExportPage() {
previous.emojiMessages === nextMetric.emojiMessages &&
previous.transferMessages === nextMetric.transferMessages &&
previous.redPacketMessages === nextMetric.redPacketMessages &&
previous.callMessages === nextMetric.callMessages
previous.callMessages === nextMetric.callMessages &&
previous.firstTimestamp === nextMetric.firstTimestamp &&
previous.lastTimestamp === nextMetric.lastTimestamp
) {
continue
}
@@ -3610,7 +3782,7 @@ function ExportPage() {
if (isStale()) return
if (detailStatsPriorityRef.current) return
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts({ lite: true }), CONTACT_ENRICH_TIMEOUT_MS)
if (isStale()) return
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
@@ -3751,10 +3923,12 @@ function ExportPage() {
displayName: contact.displayName || contact.username,
remark: contact.remark,
nickname: contact.nickname,
alias: contact.alias,
type: contact.type
}))
const persistAt = Date.now()
setContactsList(contactsForPersist)
setSessions(nextSessions)
sessionsHydratedAtRef.current = persistAt
if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) {
@@ -3898,6 +4072,7 @@ function ExportPage() {
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
setExportDialog({ open: true, ...payload })
setIsTimeRangeDialogOpen(false)
setTimeRangeBounds(null)
setTimeRangeSelection(exportDefaultDateRangeSelection)
setOptions(prev => {
@@ -3921,7 +4096,8 @@ function ExportPage() {
exportEmojis: exportDefaultMedia.emojis,
exportVoiceAsText: exportDefaultVoiceAsText,
excelCompactColumns: exportDefaultExcelCompactColumns,
exportConcurrency: exportDefaultConcurrency
exportConcurrency: exportDefaultConcurrency,
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
}
if (payload.scope === 'sns') {
@@ -3954,17 +4130,150 @@ function ExportPage() {
exportDefaultAvatars,
exportDefaultMedia,
exportDefaultVoiceAsText,
exportDefaultConcurrency
exportDefaultConcurrency,
exportDefaultImageDeepSearchOnMiss
])
const closeExportDialog = useCallback(() => {
setExportDialog(prev => ({ ...prev, open: false }))
setIsTimeRangeDialogOpen(false)
setTimeRangeBounds(null)
}, [])
const resolveChatExportTimeRangeBounds = useCallback(async (sessionIds: string[]): Promise<TimeRangeBounds | null> => {
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean)))
if (normalizedSessionIds.length === 0) return null
const sessionRowMap = new Map<string, SessionRow>()
for (const session of sessions) {
sessionRowMap.set(session.username, session)
}
let minTimestamp: number | undefined
let maxTimestamp: number | undefined
const resolvedSessionBounds = new Map<string, { hasMin: boolean; hasMax: boolean }>()
const absorbMetric = (sessionId: string, metric?: { firstTimestamp?: number; lastTimestamp?: number } | null) => {
if (!metric) return
const firstTimestamp = normalizeTimestampSeconds(metric.firstTimestamp)
const lastTimestamp = normalizeTimestampSeconds(metric.lastTimestamp)
if (typeof firstTimestamp !== 'number' && typeof lastTimestamp !== 'number') return
const previous = resolvedSessionBounds.get(sessionId) || { hasMin: false, hasMax: false }
const nextState = {
hasMin: previous.hasMin || typeof firstTimestamp === 'number',
hasMax: previous.hasMax || typeof lastTimestamp === 'number'
}
resolvedSessionBounds.set(sessionId, nextState)
if (typeof firstTimestamp === 'number' && (minTimestamp === undefined || firstTimestamp < minTimestamp)) {
minTimestamp = firstTimestamp
}
if (typeof lastTimestamp === 'number' && (maxTimestamp === undefined || lastTimestamp > maxTimestamp)) {
maxTimestamp = lastTimestamp
}
}
for (const sessionId of normalizedSessionIds) {
const sessionRow = sessionRowMap.get(sessionId)
absorbMetric(sessionId, {
firstTimestamp: undefined,
lastTimestamp: sessionRow?.sortTimestamp || sessionRow?.lastTimestamp
})
absorbMetric(sessionId, sessionContentMetrics[sessionId])
if (sessionDetail?.wxid === sessionId) {
absorbMetric(sessionId, {
firstTimestamp: sessionDetail.firstMessageTime,
lastTimestamp: sessionDetail.latestMessageTime
})
}
}
const applyStatsResult = (result?: {
success: boolean
data?: Record<string, SessionExportMetric>
} | null) => {
if (!result?.success || !result.data) return
applySessionMediaMetricsFromStats(result.data)
for (const sessionId of normalizedSessionIds) {
absorbMetric(sessionId, result.data[sessionId])
}
}
const missingSessionIds = () => normalizedSessionIds.filter(sessionId => {
const resolved = resolvedSessionBounds.get(sessionId)
return !resolved?.hasMin || !resolved?.hasMax
})
const staleSessionIds = new Set<string>()
if (missingSessionIds().length > 0) {
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
missingSessionIds(),
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
)
applyStatsResult(cacheResult)
for (const sessionId of cacheResult?.needsRefresh || []) {
staleSessionIds.add(String(sessionId || '').trim())
}
}
const sessionsNeedingFreshStats = Array.from(new Set([
...missingSessionIds(),
...Array.from(staleSessionIds).filter(Boolean)
]))
if (sessionsNeedingFreshStats.length > 0) {
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
sessionsNeedingFreshStats,
{ includeRelations: false }
))
}
if (missingSessionIds().length > 0) {
return null
}
if (typeof minTimestamp !== 'number' || typeof maxTimestamp !== 'number') {
return null
}
return {
minDate: new Date(minTimestamp * 1000),
maxDate: new Date(maxTimestamp * 1000)
}
}, [applySessionMediaMetricsFromStats, sessionContentMetrics, sessionDetail, sessions])
const openTimeRangeDialog = useCallback(() => {
setIsTimeRangeDialogOpen(true)
}, [])
void (async () => {
if (isResolvingTimeRangeBounds) return
setIsResolvingTimeRangeBounds(true)
try {
let nextBounds: TimeRangeBounds | null = null
if (exportDialog.scope !== 'sns') {
nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds)
}
setTimeRangeBounds(nextBounds)
if (nextBounds) {
const nextSelection = clampExportSelectionToBounds(timeRangeSelection, nextBounds)
if (!areExportSelectionsEqual(nextSelection, timeRangeSelection)) {
setTimeRangeSelection(nextSelection)
setOptions(prev => ({
...prev,
useAllTime: nextSelection.useAllTime,
dateRange: cloneExportDateRange(nextSelection.dateRange)
}))
}
}
setIsTimeRangeDialogOpen(true)
} catch (error) {
console.error('导出页解析时间范围边界失败', error)
setTimeRangeBounds(null)
setIsTimeRangeDialogOpen(true)
} finally {
setIsResolvingTimeRangeBounds(false)
}
})()
}, [exportDialog.scope, exportDialog.sessionIds, isResolvingTimeRangeBounds, resolveChatExportTimeRangeBounds, timeRangeSelection])
const closeTimeRangeDialog = useCallback(() => {
setIsTimeRangeDialogOpen(false)
@@ -4041,6 +4350,7 @@ function ExportPage() {
txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
sessionLayout,
sessionNameWithTypePrefix,
dateRange: options.useAllTime
@@ -4633,6 +4943,8 @@ function ExportPage() {
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
await configService.setExportDefaultTxtColumns(options.txtColumns)
await configService.setExportDefaultConcurrency(options.exportConcurrency)
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
}
const openSingleExport = useCallback((session: SessionRow) => {
@@ -5169,6 +5481,11 @@ function ExportPage() {
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex }
void hydrateVisibleContactAvatars(
filteredContacts
.slice(startIndex, endIndex + 1)
.map((contact) => contact.username)
)
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
if (visibleTargets.length === 0) return
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
@@ -5184,10 +5501,23 @@ function ExportPage() {
enqueueSessionMediaMetricRequests,
enqueueSessionMutualFriendsRequests,
filteredContacts,
hydrateVisibleContactAvatars,
scheduleSessionMediaMetricWorker,
scheduleSessionMutualFriendsWorker
])
useEffect(() => {
if (filteredContacts.length === 0) return
const bootstrapTargets = filteredContacts.slice(0, 24).map((contact) => contact.username)
void hydrateVisibleContactAvatars(bootstrapTargets)
}, [filteredContacts, hydrateVisibleContactAvatars])
useEffect(() => {
const sessionId = String(sessionDetail?.wxid || '').trim()
if (!sessionId) return
void hydrateVisibleContactAvatars([sessionId])
}, [hydrateVisibleContactAvatars, sessionDetail?.wxid])
useEffect(() => {
if (activeTaskCount > 0) return
if (filteredContacts.length === 0) return
@@ -5382,6 +5712,45 @@ function ExportPage() {
return map
}, [contactsList])
useEffect(() => {
if (!showSessionDetailPanel) return
const sessionId = String(sessionDetail?.wxid || '').trim()
if (!sessionId) return
const mappedSession = sessionRowByUsername.get(sessionId)
const mappedContact = contactByUsername.get(sessionId)
if (!mappedSession && !mappedContact) return
setSessionDetail((prev) => {
if (!prev || prev.wxid !== sessionId) return prev
const nextDisplayName = mappedSession?.displayName || mappedContact?.displayName || prev.displayName || sessionId
const nextRemark = mappedContact?.remark ?? prev.remark
const nextNickName = mappedContact?.nickname ?? prev.nickName
const nextAlias = mappedContact?.alias ?? prev.alias
const nextAvatarUrl = mappedSession?.avatarUrl || mappedContact?.avatarUrl || prev.avatarUrl
if (
nextDisplayName === prev.displayName &&
nextRemark === prev.remark &&
nextNickName === prev.nickName &&
nextAlias === prev.alias &&
nextAvatarUrl === prev.avatarUrl
) {
return prev
}
return {
...prev,
displayName: nextDisplayName,
remark: nextRemark,
nickName: nextNickName,
alias: nextAlias,
avatarUrl: nextAvatarUrl
}
})
}, [contactByUsername, sessionDetail?.wxid, sessionRowByUsername, showSessionDetailPanel])
const currentSessionExportRecords = useMemo(() => {
const sessionId = String(sessionDetail?.wxid || '').trim()
if (!sessionId) return [] as configService.ExportSessionRecordEntry[]
@@ -5500,7 +5869,7 @@ function ExportPage() {
displayName: mappedSession?.displayName || mappedContact?.displayName || prev?.displayName || normalizedSessionId,
remark: sameSession ? prev?.remark : mappedContact?.remark,
nickName: sameSession ? prev?.nickName : mappedContact?.nickname,
alias: sameSession ? prev?.alias : undefined,
alias: sameSession ? prev?.alias : mappedContact?.alias,
avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined),
messageCount: initialMessageCount ?? (sameSession ? prev.messageCount : Number.NaN),
voiceMessages: metricVoice ?? (sameSession ? prev?.voiceMessages : undefined),
@@ -5787,7 +6156,11 @@ function ExportPage() {
loadSnsUserPostCounts({ force: true })
])
if (String(sessionDetail?.wxid || '').trim()) {
const currentDetailSessionId = showSessionDetailPanel
? String(sessionDetail?.wxid || '').trim()
: ''
if (currentDetailSessionId) {
await loadSessionDetail(currentDetailSessionId)
void loadSessionRelationStats({ forceRefresh: true })
}
}, [
@@ -5798,11 +6171,13 @@ function ExportPage() {
filteredContacts,
isSessionCountStageReady,
loadContactsList,
loadSessionDetail,
loadSessionRelationStats,
loadSnsStats,
loadSnsUserPostCounts,
resetSessionMutualFriendsLoader,
scheduleSessionMutualFriendsWorker,
showSessionDetailPanel,
sessionDetail?.wxid
])
@@ -6070,6 +6445,10 @@ function ExportPage() {
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
(isSessionScopeDialog && options.exportImages) ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
const activeDialogFormatLabel = exportDialog.scope === 'sns'
@@ -6367,11 +6746,12 @@ function ExportPage() {
</button>
</div>
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" loading="lazy" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
<Avatar
src={normalizeExportAvatarUrl(contact.avatarUrl)}
name={contact.displayName}
size="100%"
shape="rounded"
/>
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
@@ -7254,11 +7634,12 @@ function ExportPage() {
<div className="session-mutual-friends-header">
<div className="session-mutual-friends-header-main">
<div className="session-mutual-friends-avatar">
{sessionMutualFriendsDialogTarget.avatarUrl ? (
<img src={sessionMutualFriendsDialogTarget.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(sessionMutualFriendsDialogTarget.displayName)}</span>
)}
<Avatar
src={normalizeExportAvatarUrl(sessionMutualFriendsDialogTarget.avatarUrl)}
name={sessionMutualFriendsDialogTarget.displayName}
size="100%"
shape="rounded"
/>
</div>
<div className="session-mutual-friends-meta">
<h4>{sessionMutualFriendsDialogTarget.displayName} </h4>
@@ -7339,11 +7720,12 @@ function ExportPage() {
<div className="detail-header">
<div className="detail-header-main">
<div className="detail-header-avatar">
{sessionDetail?.avatarUrl ? (
<img src={sessionDetail.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(sessionDetail?.displayName || sessionDetail?.wxid || '')}</span>
)}
<Avatar
src={normalizeExportAvatarUrl(sessionDetail?.avatarUrl)}
name={sessionDetail?.displayName || sessionDetail?.wxid || ''}
size="100%"
shape="rounded"
/>
</div>
<div className="detail-header-meta">
<h4>{sessionDetail?.displayName || '会话详情'}</h4>
@@ -7753,8 +8135,9 @@ function ExportPage() {
type="button"
className="time-range-trigger"
onClick={openTimeRangeDialog}
disabled={isResolvingTimeRangeBounds}
>
<span>{timeRangeSummaryLabel}</span>
<span>{isResolvingTimeRangeBounds ? '正在统计可选时间...' : timeRangeSummaryLabel}</span>
<span className="time-range-arrow">&gt;</span>
</button>
</div>
@@ -7785,6 +8168,26 @@ function ExportPage() {
</div>
)}
{shouldShowImageDeepSearchToggle && (
<div className="dialog-section">
<div className="dialog-switch-row">
<div className="dialog-switch-copy">
<h4></h4>
<div className="format-note"> hardlink </div>
</div>
<button
type="button"
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
aria-pressed={options.imageDeepSearchOnMiss}
aria-label="切换缺图时深度搜索"
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
>
<span className="dialog-switch-thumb" />
</button>
</div>
</div>
)}
{isSessionScopeDialog && (
<div className="dialog-section">
<div className="dialog-switch-row">
@@ -7840,6 +8243,8 @@ function ExportPage() {
<ExportDateRangeDialog
open={isTimeRangeDialogOpen}
value={timeRangeSelection}
minDate={timeRangeBounds?.minDate}
maxDate={timeRangeBounds?.maxDate}
onClose={closeTimeRangeDialog}
onConfirm={(nextSelection) => {
setTimeRangeSelection(nextSelection)

View File

@@ -834,11 +834,13 @@
}
.member-export-panel,
.member-messages-panel {
.member-messages-panel,
.member-analytics-panel {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
flex: 1;
.member-export-empty {
padding: 20px;
@@ -1521,29 +1523,73 @@
}
}
.stats-cards {
.stats-overview {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
padding-top: 10px;
}
.stat-card {
background: transparent;
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
.stat-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-light);
border-radius: 12px;
padding: 16px;
text-align: center;
color: var(--primary);
}
.value {
display: block;
.stat-info {
display: flex;
flex-direction: column;
gap: 4px;
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--primary);
margin-bottom: 4px;
color: var(--text-primary);
}
.label {
.stat-label {
font-size: 13px;
color: var(--text-secondary);
color: var(--text-tertiary);
}
}
}
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 24px;
.chart-card {
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
padding: 20px;
&.wide {
grid-column: span 2;
}
h3 {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 16px;
}
}
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare } from 'lucide-react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare, Calendar, PieChart, Hash, Smile } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker'
@@ -37,7 +37,7 @@ interface GroupMessageRank {
messageCount: number
}
type AnalysisFunction = 'members' | 'memberMessages' | 'ranking' | 'activeHours' | 'mediaStats'
type AnalysisFunction = 'members' | 'memberMessages' | 'memberAnalytics' | 'ranking' | 'activeHours' | 'mediaStats'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
interface MemberMessageExportOptions {
@@ -167,6 +167,8 @@ function GroupAnalyticsPage() {
const [isExportingMembers, setIsExportingMembers] = useState(false)
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
const [memberMessages, setMemberMessages] = useState<Message[]>([])
const [memberAnalyticsData, setMemberAnalyticsData] = useState<any | null>(null)
const [analyticsError, setAnalyticsError] = useState<string | null>(null)
const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false)
const [memberMessagesCursor, setMemberMessagesCursor] = useState(0)
const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false)
@@ -524,6 +526,7 @@ function GroupAnalyticsPage() {
break
}
case 'memberMessages': {
resetMemberMessageState(false)
updateBackgroundTask(taskId, {
detail: '正在读取成员列表与消息',
progressText: '成员消息'
@@ -566,7 +569,57 @@ function GroupAnalyticsPage() {
})
break
}
case 'memberAnalytics': {
setMemberAnalyticsData(null)
setAnalyticsError(null)
updateBackgroundTask(taskId, {
detail: '正在读取成员列表与消息分析',
progressText: '成员分析'
})
const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' })
return
}
if (!result.success || !result.data) {
finishBackgroundTask(taskId, 'failed', { detail: result.error || '获取成员列表失败' })
return
}
setMembers(result.data)
let targetMember = preferredMemberUsername
? result.data.find(m => m.username === preferredMemberUsername)
: result.data.find(m => m.username === selectedMessageMemberUsername)
if (!targetMember && result.data.length > 0) {
targetMember = result.data[0]
setSelectedMessageMemberUsername(targetMember.username)
}
if (!targetMember) {
finishBackgroundTask(taskId, 'failed', { detail: '找不到目标成员' })
return
}
updateBackgroundTask(taskId, {
detail: `正在分析 ${targetMember.displayName || targetMember.username} 的发言记录`,
progressText: '统计分析'
})
const analyticsResult = await window.electronAPI.groupAnalytics.getGroupMemberAnalytics(targetGroup.username, targetMember.username, startTime, endTime)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' })
return
}
if (analyticsResult.success && analyticsResult.data) {
setMemberAnalyticsData(analyticsResult.data)
finishBackgroundTask(taskId, 'completed', {
detail: `分析完成,共计 ${analyticsResult.data.statistics?.totalMessages || 0} 条消息`,
progressText: '已完成'
})
} else {
setAnalyticsError(analyticsResult.error || '分析失败')
finishBackgroundTask(taskId, 'failed', { detail: analyticsResult.error || '分析失败' })
}
break
}
case 'ranking': {
setRankings([])
updateBackgroundTask(taskId, {
detail: '正在计算群消息排行',
progressText: '消息排行'
@@ -584,6 +637,7 @@ function GroupAnalyticsPage() {
break
}
case 'activeHours': {
setActiveHours({})
updateBackgroundTask(taskId, {
detail: '正在计算群活跃时段',
progressText: '活跃时段'
@@ -601,6 +655,7 @@ function GroupAnalyticsPage() {
break
}
case 'mediaStats': {
setMediaStats(null)
updateBackgroundTask(taskId, {
detail: '正在统计群消息类型',
progressText: '消息类型'
@@ -633,6 +688,12 @@ function GroupAnalyticsPage() {
return num.toLocaleString()
}
const formatDate = (timestamp: number | null) => {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
}
const sanitizeFileName = (name: string) => {
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
}
@@ -764,6 +825,16 @@ function GroupAnalyticsPage() {
await loadFunctionData('memberMessages', selectedGroup, member.username)
}
const handleViewMemberAnalyticsFromModal = async (member: GroupMember) => {
if (!selectedGroup) return
setSelectedMember(null)
setSelectedFunction('memberAnalytics')
setSelectedMessageMemberUsername(member.username)
setMessageMemberSearchKeyword('')
setShowMessageMemberSelect(false)
await loadFunctionData('memberAnalytics', selectedGroup, member.username)
}
const handleOpenMemberExportModal = () => {
setShowMessageMemberSelect(false)
setShowFormatSelect(false)
@@ -982,6 +1053,14 @@ function GroupAnalyticsPage() {
<button
type="button"
className="member-modal-primary-btn"
onClick={() => void handleViewMemberAnalyticsFromModal(selectedMember)}
>
<BarChart3 size={16} />
<span></span>
</button>
<button
type="button"
className="member-modal-secondary-btn"
onClick={() => void handleViewMemberMessagesFromModal(selectedMember)}
>
<MessageSquare size={16} />
@@ -1080,6 +1159,11 @@ function GroupAnalyticsPage() {
<span></span>
<small></small>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('memberAnalytics')}>
<PieChart size={32} />
<span></span>
<small></small>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
<BarChart3 size={32} />
<span></span>
@@ -1104,6 +1188,7 @@ function GroupAnalyticsPage() {
switch (selectedFunction) {
case 'members': return '群成员查看'
case 'memberMessages': return '成员消息筛选与导出'
case 'memberAnalytics': return '群成员详细分析'
case 'ranking': return '群聊发言排行'
case 'activeHours': return '群聊活跃时段'
case 'mediaStats': return '媒体内容统计'
@@ -1282,6 +1367,187 @@ function GroupAnalyticsPage() {
)}
</div>
)}
{selectedFunction === 'memberAnalytics' && (
<div className="member-analytics-panel">
{members.length === 0 ? (
<div className="member-message-empty"></div>
) : (
<>
<div className="member-message-toolbar" style={{ marginBottom: 20 }}>
<div className="member-export-field" ref={messageMemberSelectDropdownRef}>
<span></span>
<button
type="button"
className={`select-trigger member-message-select-trigger ${showMessageMemberSelect ? 'open' : ''}`}
onClick={() => setShowMessageMemberSelect(prev => !prev)}
>
<div className="member-select-trigger-value">
<Avatar
src={selectedMessageMember?.avatarUrl}
name={selectedMessageMember?.displayName || selectedMessageMember?.username || '?'}
size={24}
/>
<span className="select-value">{selectedMessageMember?.displayName || selectedMessageMember?.username || '请选择成员'}</span>
</div>
<ChevronDown size={16} />
</button>
{showMessageMemberSelect && (
<div className="select-dropdown member-select-dropdown">
<div className="member-select-search">
<Search size={14} />
<input
type="text"
value={messageMemberSearchKeyword}
onChange={e => setMessageMemberSearchKeyword(e.target.value)}
placeholder="搜索 wxid / 昵称 / 备注 / 微信号"
onClick={e => e.stopPropagation()}
/>
</div>
<div className="member-select-options">
{filteredMessageMemberOptions.length === 0 ? (
<div className="member-select-empty"></div>
) : (
filteredMessageMemberOptions.map(member => (
<button
key={member.username}
type="button"
className={`select-option member-select-option ${selectedMessageMemberUsername === member.username ? 'active' : ''}`}
onClick={() => {
setSelectedMessageMemberUsername(member.username)
setShowMessageMemberSelect(false)
if (selectedGroup) {
void loadFunctionData('memberAnalytics', selectedGroup, member.username)
}
}}
>
<Avatar src={member.avatarUrl} name={member.displayName} size={28} />
<span className="member-option-main">{member.displayName || member.username}</span>
<span className="member-option-meta">
wxid: {member.username}
{member.alias ? ` · 微信号: ${member.alias}` : ''}
{member.remark ? ` · 备注: ${member.remark}` : ''}
{member.nickname ? ` · 昵称: ${member.nickname}` : ''}
{member.groupNickname ? ` · 群昵称: ${member.groupNickname}` : ''}
</span>
</button>
))
)}
</div>
</div>
)}
</div>
</div>
{analyticsError ? (
<div className="member-message-empty">{analyticsError}</div>
) : memberAnalyticsData ? (
<div className="analytics-content-scrollable" style={{ padding: '0', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div className="stats-overview">
<div className="stat-card">
<div className="stat-icon"><MessageSquare size={24} /></div>
<div className="stat-info">
<span className="stat-value">{formatNumber(memberAnalyticsData.statistics.sentMessages)}</span>
<span className="stat-label"></span>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"><Clock size={24} /></div>
<div className="stat-info">
<span className="stat-value">{memberAnalyticsData.statistics.activeDays}</span>
<span className="stat-label"></span>
</div>
</div>
<div className="stat-card" style={{ gridColumn: 'span 2' }}>
<div className="stat-icon"><Calendar size={24} /></div>
<div className="stat-info">
<span className="stat-value">
{formatDate(memberAnalyticsData.statistics.firstMessageTime)} - {formatDate(memberAnalyticsData.statistics.lastMessageTime)}
</span>
<span className="stat-label"></span>
</div>
</div>
</div>
<div className="charts-grid">
<div className="chart-card wide">
<h3></h3>
<div className="chart-wrapper">
<ReactECharts
option={{
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: Array.from({ length: 24 }, (_, i) => `${i}`) },
yAxis: { type: 'value' },
series: [{ type: 'bar', data: Array.from({ length: 24 }, (_, i) => memberAnalyticsData.timeDistribution[i] || 0), itemStyle: { color: '#07c160', borderRadius: [4, 4, 0, 0] } }]
}}
style={{ height: '300px', width: '100%' }}
/>
</div>
</div>
<div className="chart-card wide">
<h3></h3>
<div className="chart-wrapper">
<ReactECharts
option={{
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
data: [
{ name: '文本', value: memberAnalyticsData.statistics.textMessages, itemStyle: { color: '#3b82f6' } },
{ name: '图片', value: memberAnalyticsData.statistics.imageMessages, itemStyle: { color: '#22c55e' } },
{ name: '语音', value: memberAnalyticsData.statistics.voiceMessages, itemStyle: { color: '#f97316' } },
{ name: '视频', value: memberAnalyticsData.statistics.videoMessages, itemStyle: { color: '#a855f7' } },
{ name: '表情', value: memberAnalyticsData.statistics.emojiMessages, itemStyle: { color: '#ec4899' } },
{ name: '其他', value: memberAnalyticsData.statistics.otherMessages, itemStyle: { color: '#6b7280' } }
].filter(item => item.value > 0),
label: { show: true, formatter: '{b} {d}%' }
}]
}}
style={{ height: '300px', width: '100%' }}
/>
</div>
</div>
<div className="chart-card wide" style={{ display: 'flex', gap: '32px' }}>
<div style={{ flex: 1 }}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><Hash size={18} /> </h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{memberAnalyticsData.commonPhrases && memberAnalyticsData.commonPhrases.length > 0 ? (
memberAnalyticsData.commonPhrases.map((item: any, idx: number) => (
<div key={idx} style={{ background: 'var(--bg-tertiary)', padding: '6px 12px', borderRadius: '8px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)' }}>
<span style={{ color: 'var(--text-primary)' }}>{item.phrase}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '11px' }}>{item.count}</span>
</div>
))
) : (
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}></span>
)}
</div>
</div>
<div style={{ flex: 1 }}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><Smile size={18} /> </h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{memberAnalyticsData.commonEmojis && memberAnalyticsData.commonEmojis.length > 0 ? (
memberAnalyticsData.commonEmojis.map((item: any, idx: number) => (
<div key={idx} style={{ background: 'var(--bg-tertiary)', padding: '6px 12px', borderRadius: '8px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)' }}>
<span style={{ color: 'var(--text-primary)' }}>{item.emoji}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '11px' }}>{item.count}</span>
</div>
))
) : (
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}></span>
)}
</div>
</div>
</div>
</div>
</div>
) : (
<div className="content-loading"><Loader2 size={32} className="spin" /></div>
)}
</>
)}
</div>
)}
{selectedFunction === 'ranking' && (
<div className="rankings-list">
{rankings.map((item, index) => (

View File

@@ -1145,6 +1145,185 @@
}
}
.quote-layout-group {
margin-top: 14px;
}
.quote-layout-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 12px;
margin-top: 10px;
}
.quote-layout-card {
position: relative;
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 14px 14px 12px;
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
color: inherit;
cursor: pointer;
text-align: left;
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
&:hover {
border-color: color-mix(in srgb, var(--primary) 32%, var(--border-color));
}
&.active {
border-color: color-mix(in srgb, var(--primary) 68%, var(--border-color));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 12%, transparent);
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
}
}
.quote-layout-card-check {
position: absolute;
top: 12px;
left: 12px;
width: 18px;
height: 18px;
border-radius: 50%;
border: 1.5px solid color-mix(in srgb, var(--primary) 46%, var(--border-color));
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
&::after {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary) 78%, #6f8653);
transform: scale(0);
transition: transform 0.2s ease;
}
&.active {
border-color: color-mix(in srgb, var(--primary) 78%, var(--border-color));
}
&.active::after {
transform: scale(1);
}
}
.quote-layout-preview-shell {
padding-left: 22px;
min-height: 72px;
}
.quote-layout-preview-chat {
display: flex;
align-items: flex-start;
}
.quote-layout-preview-chat .message-bubble {
display: flex;
gap: 10px;
max-width: 70%;
align-items: flex-start;
}
.quote-layout-preview-chat .message-bubble.sent {
flex-direction: row-reverse;
}
.quote-layout-preview-chat .message-bubble.sent .bubble-content {
background: var(--primary);
color: var(--on-primary);
border-radius: 18px 18px 4px 18px;
}
.quote-layout-preview-chat .bubble-content {
padding: 10px 14px;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
white-space: pre-wrap;
}
.quote-layout-preview-chat .message-text {
font-size: 14px;
line-height: 1.6;
}
.quote-layout-preview-chat .quoted-message {
background: rgba(0, 0, 0, 0.04);
border-left: 2px solid var(--primary);
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
}
.quote-layout-preview-chat .quoted-sender {
color: var(--primary);
font-weight: 500;
margin-right: 4px;
}
.quote-layout-preview-chat .quoted-sender::after {
content: ':';
}
.quote-layout-preview-chat .quoted-text {
color: var(--text-secondary);
white-space: pre-wrap;
}
.quote-layout-preview-chat .message-bubble.sent .quoted-message {
background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
border-left-color: color-mix(in srgb, var(--on-primary) 36%, var(--primary));
}
.quote-layout-preview-chat .message-bubble.sent .quoted-sender {
color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
}
.quote-layout-preview-chat .message-bubble.sent .quoted-text {
color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
}
.quote-layout-preview-chat .bubble-content.quote-layout-top .quoted-message {
margin-bottom: 8px;
}
.quote-layout-preview-chat .bubble-content.quote-layout-bottom .quoted-message {
margin-top: 8px;
}
.quote-layout-card-footer {
margin-top: 8px;
padding-left: 22px;
}
.quote-layout-card-title-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.quote-layout-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.quote-layout-card-desc {
font-size: 11px;
color: var(--text-tertiary);
}
@media (max-width: 760px) {
.quote-layout-picker {
grid-template-columns: 1fr;
}
}
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));

View File

@@ -32,6 +32,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac
@@ -102,6 +103,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
const [httpApiToken, setHttpApiToken] = useState('')
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -110,6 +113,25 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const generateRandomToken = async () => {
// 生成 32 字符的十六进制随机字符串 (16 bytes)
const array = new Uint8Array(16)
crypto.getRandomValues(array)
const token = Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('')
setHttpApiToken(token)
await configService.setHttpApiToken(token)
showMessage('已生成并保存新的 Access Token', true)
}
const clearApiToken = async () => {
setHttpApiToken('')
await configService.setHttpApiToken('')
showMessage('已清除 Access TokenAPI 将允许无鉴权访问', true)
}
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
@@ -118,6 +140,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
@@ -167,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
// HTTP API 设置 state
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
const [httpApiPort, setHttpApiPort] = useState(5031)
const [httpApiHost, setHttpApiHost] = useState('127.0.0.1')
const [httpApiRunning, setHttpApiRunning] = useState(false)
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
const [isTogglingApi, setIsTogglingApi] = useState(false)
@@ -175,11 +199,26 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
const [isWayland, setIsWayland] = useState(false)
useEffect(() => {
const checkWaylandStatus = async () => {
if (window.electronAPI?.app?.checkWayland) {
try {
const wayland = await window.electronAPI.app.checkWayland()
setIsWayland(wayland)
} catch (e) {
console.error('检查 Wayland 状态失败:', e)
}
}
}
checkWaylandStatus()
}, [])
// 检查 Hello 可用性
useEffect(() => {
if (window.PublicKeyCredential) {
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
}
setHelloAvailable(isWindows)
}, [])
// 检查 HTTP API 服务状态
@@ -299,10 +338,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedNotificationFilterList = await configService.getNotificationFilterList()
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
const savedQuoteLayout = await configService.getQuoteLayout()
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
const savedAuthUseHello = await configService.getAuthUseHello()
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
const savedHttpApiToken = await configService.getHttpApiToken()
if (savedHttpApiToken) setHttpApiToken(savedHttpApiToken)
const savedApiPort = await configService.getHttpApiPort()
if (savedApiPort) setHttpApiPort(savedApiPort)
const savedApiHost = await configService.getHttpApiHost()
if (savedApiHost) setHttpApiHost(savedApiHost)
setAuthEnabled(savedAuthEnabled)
setAuthUseHello(savedAuthUseHello)
setIsLockMode(savedIsLockMode)
@@ -336,6 +386,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setNotificationFilterList(savedNotificationFilterList)
setMessagePushEnabled(savedMessagePushEnabled)
setWindowCloseBehavior(savedWindowCloseBehavior)
setQuoteLayout(savedQuoteLayout)
const savedExcludeWords = await configService.getWordCloudExcludeWords()
setWordCloudExcludeWords(savedExcludeWords)
@@ -344,6 +395,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
setAnalyticsConsent(savedAnalyticsConsent ?? false)
// 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
const defaultLanguages = ['zh']
@@ -1043,6 +1096,79 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
))}
</div>
<div className="form-group quote-layout-group">
<label></label>
<span className="form-hint"></span>
<div className="quote-layout-picker" role="radiogroup" aria-label="引用样式选择">
{[
{
value: 'quote-top' as const,
label: '引用在上',
description: '更接近当前 WeFlow 风格',
successMessage: '已切换为引用在上样式'
},
{
value: 'quote-bottom' as const,
label: '正文在上',
description: '更接近微信 / 密语风格',
successMessage: '已切换为正文在上样式'
}
].map(option => {
const selected = quoteLayout === option.value
const isQuoteBottom = option.value === 'quote-bottom'
return (
<button
key={option.value}
type="button"
className={`quote-layout-card ${selected ? 'active' : ''}`}
onClick={async () => {
if (selected) return
setQuoteLayout(option.value)
await configService.setQuoteLayout(option.value)
showMessage(option.successMessage, true)
}}
role="radio"
aria-checked={selected}
>
<span className={`quote-layout-card-check ${selected ? 'active' : ''}`} aria-hidden="true" />
<div className="quote-layout-preview-shell">
<div className="quote-layout-preview-chat">
<div className="message-bubble sent">
<div className={`bubble-content ${isQuoteBottom ? 'quote-layout-bottom' : 'quote-layout-top'}`}>
{isQuoteBottom ? (
<>
<div className="message-text">!</div>
<div className="quoted-message">
<span className="quoted-sender"></span>
<span className="quoted-text">...</span>
</div>
</>
) : (
<>
<div className="quoted-message">
<span className="quoted-sender"></span>
<span className="quoted-text">...</span>
</div>
<div className="message-text">!</div>
</>
)}
</div>
</div>
</div>
</div>
<div className="quote-layout-card-footer">
<div className="quote-layout-card-title-group">
<span className="quote-layout-card-title">{option.label}</span>
<span className="quote-layout-card-desc">{option.description}</span>
</div>
</div>
</button>
)
})}
</div>
</div>
<div className="divider" />
<div className="form-group">
@@ -1169,6 +1295,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="form-group">
<label></label>
<span className="form-hint"></span>
{isWayland && (
<span className="form-hint" style={{ color: '#ff4d4f', marginTop: '4px', display: 'block' }}>
Wayland
</span>
)}
<div className="custom-select">
<div
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
@@ -1652,34 +1783,49 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
)
const renderCacheTab = () => (
<div className="tab-content">
<p className="section-desc"></p>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使</span>
<input
type="text"
placeholder="留空使用默认目录"
value={cachePath}
onChange={(e) => {
const value = e.target.value
setCachePath(value)
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
}}
/>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button
className="btn btn-secondary"
onClick={async () => {
setCachePath('')
await configService.setCachePath('')
}}
>
<RotateCcw size={16} />
</button>
<div className="tab-content">
<p className="section-desc"></p>
<div className="form-group">
<label> <span className="optional">()</span></label>
<span className="form-hint">使</span>
<input
type="text"
placeholder="留空使用默认目录"
value={cachePath}
onChange={(e) => {
const value = e.target.value
setCachePath(value)
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
}}
/>
<div style={{ marginTop: '8px', fontSize: '13px', color: 'var(--text-secondary)' }}>
<code style={{
background: 'var(--bg-secondary)',
padding: '3px 6px',
borderRadius: '4px',
userSelect: 'all',
wordBreak: 'break-all',
marginLeft: '4px'
}}>
{cachePath || (isMac ? '~/Documents/WeFlow' : isLinux ? '~/Documents/WeFlow' : '系统 文档\\WeFlow 目录')}
</code>
</div>
<div className="btn-row" style={{ marginTop: '12px' }}>
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button
className="btn btn-secondary"
onClick={async () => {
setCachePath('')
await configService.setCachePath('')
}}
>
<RotateCcw size={16} />
</button>
</div>
</div>
</div>
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
@@ -1715,6 +1861,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
try {
await window.electronAPI.http.stop()
setHttpApiRunning(false)
await configService.setHttpApiEnabled(false)
showMessage('API 服务已停止', true)
} catch (e: any) {
showMessage(`操作失败: ${e}`, false)
@@ -1728,10 +1875,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setShowApiWarning(false)
setIsTogglingApi(true)
try {
const result = await window.electronAPI.http.start(httpApiPort)
const result = await window.electronAPI.http.start(httpApiPort, httpApiHost)
if (result.success) {
setHttpApiRunning(true)
if (result.port) setHttpApiPort(result.port)
await configService.setHttpApiEnabled(true)
await configService.setHttpApiPort(result.port || httpApiPort)
showMessage(`API 服务已启动,端口 ${result.port}`, true)
} else {
showMessage(`启动失败: ${result.error}`, false)
@@ -1744,7 +1895,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
const handleCopyApiUrl = () => {
const url = `http://127.0.0.1:${httpApiPort}`
const url = `http://${httpApiHost}:${httpApiPort}`
navigator.clipboard.writeText(url)
showMessage('已复制 API 地址', true)
}
@@ -1776,21 +1927,75 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
API <code>127.0.0.1</code> 访Docker/N8N <code>0.0.0.0</code> 访 Token
</span>
<input
type="text"
className="field-input"
value={httpApiHost}
placeholder="127.0.0.1"
onChange={(e) => {
const host = e.target.value.trim() || '127.0.0.1'
setHttpApiHost(host)
scheduleConfigSave('httpApiHost', () => configService.setHttpApiHost(host))
}}
disabled={httpApiRunning}
style={{ width: 180, fontFamily: 'monospace' }}
/>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">API 1024-65535</span>
<input
type="number"
className="field-input"
value={httpApiPort}
onChange={(e) => setHttpApiPort(parseInt(e.target.value, 10) || 5031)}
disabled={httpApiRunning}
style={{ width: 120 }}
min={1024}
max={65535}
type="number"
className="field-input"
value={httpApiPort}
onChange={(e) => {
const port = parseInt(e.target.value, 10) || 5031
setHttpApiPort(port)
scheduleConfigSave('httpApiPort', () => configService.setHttpApiPort(port))
}}
disabled={httpApiRunning}
style={{ width: 120 }}
min={1024}
max={65535}
/>
</div>
<div className="form-group">
<label>Access Token ()</label>
<span className="form-hint">
<code>Authorization: Bearer &lt;token&gt;</code>
<code>?access_token=&lt;token&gt;</code>
</span>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<input
type="text"
className="field-input"
value={httpApiToken}
placeholder="留空表示不验证 Token"
onChange={(e) => {
const val = e.target.value
setHttpApiToken(val)
scheduleConfigSave('httpApiToken', () => configService.setHttpApiToken(val))
}}
style={{ flex: 1, fontFamily: 'monospace' }}
/>
<button className="btn btn-secondary" onClick={generateRandomToken}>
<RefreshCw size={14} style={{ marginRight: 4 }} />
</button>
{httpApiToken && (
<button className="btn btn-danger" onClick={clearApiToken} title="清除 Token">
<Trash2 size={14} />
</button>
)}
</div>
</div>
{httpApiRunning && (
<div className="form-group">
<label>API </label>
@@ -1799,7 +2004,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<input
type="text"
className="field-input"
value={`http://127.0.0.1:${httpApiPort}`}
value={`http://${httpApiHost}:${httpApiPort}`}
readOnly
/>
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制">
@@ -1846,18 +2051,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="form-hint"> SSE `HTTP API 服务`</span>
<div className="api-url-display">
<input
type="text"
className="field-input"
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}
readOnly
type="text"
className="field-input"
value={`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`}
readOnly
/>
<button
className="btn btn-secondary"
onClick={() => {
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`)
showMessage('已复制推送地址', true)
}}
title="复制"
className="btn btn-secondary"
onClick={() => {
navigator.clipboard.writeText(`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`)
showMessage('已复制推送地址', true)
}}
title="复制"
>
<Copy size={16} />
</button>
@@ -1871,7 +2076,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="api-item">
<div className="api-endpoint">
<span className="method get">GET</span>
<code>{`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}</code>
<code>{`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages`}</code>
</div>
<p className="api-desc"> SSE `messageKey` </p>
<div className="api-params">
@@ -1928,33 +2133,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage('请输入当前密码以开启 Hello', false)
return
}
if (!isWindows) {
showMessage('当前系统不支持 Windows Hello', false)
return
}
setIsSettingHello(true)
try {
const challenge = new Uint8Array(32)
window.crypto.getRandomValues(challenge)
const credential = await navigator.credentials.create({
publicKey: {
challenge,
rp: { name: 'WeFlow', id: 'localhost' },
user: { id: new Uint8Array([1]), name: 'user', displayName: 'User' },
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
authenticatorSelection: { userVerification: 'required' },
timeout: 60000
}
})
if (credential) {
// 存储密码作为 Hello Secret以便 Hello 解锁时能派生密钥
await window.electronAPI.auth.setHelloSecret(helloPassword)
setAuthUseHello(true)
setHelloPassword('')
showMessage('Windows Hello 设置成功', true)
const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
if (!verifyResult.success) {
showMessage(verifyResult.error || 'Windows Hello 验证失败', false)
return
}
const saveResult = await window.electronAPI.auth.setHelloSecret(helloPassword)
if (!saveResult.success) {
showMessage('Windows Hello 配置保存失败', false)
return
}
setAuthUseHello(true)
setHelloPassword('')
showMessage('Windows Hello 设置成功', true)
} catch (e: any) {
if (e.name !== 'NotAllowedError') {
showMessage(`Windows Hello 设置失败: ${e.message}`, false)
}
showMessage(`Windows Hello 设置失败: ${e?.message || String(e)}`, false)
} finally {
setIsSettingHello(false)
}

View File

@@ -759,6 +759,26 @@
margin-bottom: 12px;
}
.post-location {
display: flex;
align-items: flex-start;
gap: 6px;
margin: -4px 0 12px;
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary);
svg {
flex-shrink: 0;
margin-top: 1px;
color: var(--text-tertiary);
}
}
.post-location-text {
word-break: break-word;
}
.post-media-container {
margin-bottom: 12px;
}

View File

@@ -176,6 +176,8 @@ export default function SnsPage() {
const selectedContactUsernamesRef = useRef<string[]>(selectedContactUsernames)
const cacheScopeKeyRef = useRef('')
const snsUserPostCountsCacheScopeKeyRef = useRef('')
const activeContactsLoadTaskIdRef = useRef<string | null>(null)
const activeContactsCountTaskIdRef = useRef<string | null>(null)
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const pendingResetFeedRef = useRef(false)
const contactsLoadTokenRef = useRef(0)
@@ -750,6 +752,12 @@ export default function SnsPage() {
window.clearTimeout(contactsCountBatchTimerRef.current)
contactsCountBatchTimerRef.current = null
}
if (activeContactsCountTaskIdRef.current) {
finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', {
detail: '已停止后续联系人朋友圈条数补算'
})
activeContactsCountTaskIdRef.current = null
}
if (resetProgress) {
setContactsCountProgress({
resolved: 0,
@@ -814,31 +822,56 @@ export default function SnsPage() {
cancelable: true
})
activeContactsCountTaskIdRef.current = taskId
let normalizedCounts: Record<string, number> = {}
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (isBackgroundTaskCancelRequested(taskId)) {
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,当前计数查询结束后不再继续分批写入'
})
return
}
if (runToken !== contactsCountHydrationTokenRef.current) return
if (runToken !== contactsCountHydrationTokenRef.current) {
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期'
})
return
}
if (result.success && result.counts) {
normalizedCounts = Object.fromEntries(
Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)])
)
normalizedCounts = pendingTargets.reduce<Record<string, number>>((acc, username) => {
acc[username] = normalizePostCount(result.counts?.[username])
return acc
}, {})
void (async () => {
try {
const scopeKey = await ensureSnsUserPostCountsCacheScopeKey()
await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts)
const currentCache = await configService.getExportSnsUserPostCountsCache(scopeKey)
await configService.setExportSnsUserPostCountsCache(scopeKey, {
...(currentCache?.counts || {}),
...normalizedCounts
})
} catch (cacheError) {
console.error('Failed to persist SNS user post counts cache:', cacheError)
}
})()
} else {
normalizedCounts = pendingTargets.reduce<Record<string, number>>((acc, username) => {
acc[username] = 0
return acc
}, {})
}
} catch (error) {
console.error('Failed to load contact post counts:', error)
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'failed', {
detail: String(error)
})
@@ -848,8 +881,19 @@ export default function SnsPage() {
let resolved = preResolved
let cursor = 0
const applyBatch = () => {
if (runToken !== contactsCountHydrationTokenRef.current) return
if (runToken !== contactsCountHydrationTokenRef.current) {
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期'
})
return
}
if (isBackgroundTaskCancelRequested(taskId)) {
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: `已停止后续加载,已完成 ${resolved}/${totalTargets}`
})
@@ -870,6 +914,9 @@ export default function SnsPage() {
running: false
})
contactsCountBatchTimerRef.current = null
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'completed', {
detail: '联系人朋友圈条数补算完成',
progressText: `${totalTargets}/${totalTargets}`
@@ -910,6 +957,18 @@ export default function SnsPage() {
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
} else {
contactsCountBatchTimerRef.current = null
setContactsCountProgress({
resolved: totalTargets,
total: totalTargets,
running: false
})
if (activeContactsCountTaskIdRef.current === taskId) {
activeContactsCountTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'completed', {
detail: '鑱旂郴浜烘湅鍙嬪湀鏉℃暟琛ョ畻瀹屾垚',
progressText: `${totalTargets}/${totalTargets}`
})
}
}
@@ -918,6 +977,12 @@ export default function SnsPage() {
// Load Contacts先按最近会话显示联系人再异步统计朋友圈条数并增量排序
const loadContacts = useCallback(async () => {
if (activeContactsLoadTaskIdRef.current) {
finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', {
detail: '新一轮联系人列表加载已开始,旧任务已取消'
})
activeContactsLoadTaskIdRef.current = null
}
const requestToken = ++contactsLoadTokenRef.current
const taskId = registerBackgroundTask({
sourcePage: 'sns',
@@ -926,6 +991,7 @@ export default function SnsPage() {
progressText: '初始化',
cancelable: true
})
activeContactsLoadTaskIdRef.current = taskId
stopContactsCountHydration(true)
setContactsLoading(true)
try {
@@ -955,7 +1021,15 @@ export default function SnsPage() {
}
})
if (requestToken !== contactsLoadTokenRef.current) return
if (requestToken !== contactsLoadTokenRef.current) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人列表加载已过期'
})
return
}
if (cachedContacts.length > 0) {
const cachedContactsSorted = sortContactsForRanking(cachedContacts)
setContacts(cachedContactsSorted)
@@ -977,6 +1051,9 @@ export default function SnsPage() {
window.electronAPI.chat.getSessions()
])
if (isBackgroundTaskCancelRequested(taskId)) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,当前联系人查询结束后未继续补齐'
})
@@ -1021,7 +1098,15 @@ export default function SnsPage() {
}
let contactsList = sortContactsForRanking(Array.from(contactMap.values()))
if (requestToken !== contactsLoadTokenRef.current) return
if (requestToken !== contactsLoadTokenRef.current) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人列表加载已过期'
})
return
}
setContacts(contactsList)
const readyUsernames = new Set(
contactsList
@@ -1043,6 +1128,9 @@ export default function SnsPage() {
})
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
if (isBackgroundTaskCancelRequested(taskId)) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,联系人补齐未继续写入'
})
@@ -1058,7 +1146,15 @@ export default function SnsPage() {
avatarUrl: extra.avatarUrl || contact.avatarUrl
}
})
if (requestToken !== contactsLoadTokenRef.current) return
if (requestToken !== contactsLoadTokenRef.current) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人列表加载已过期'
})
return
}
setContacts((prev) => {
const prevMap = new Map(prev.map((contact) => [contact.username, contact]))
const merged = contactsList.map((contact) => {
@@ -1074,18 +1170,35 @@ export default function SnsPage() {
})
}
}
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'completed', {
detail: `朋友圈联系人列表加载完成,共 ${contactsList.length}`,
progressText: `${contactsList.length}`
})
} catch (error) {
if (requestToken !== contactsLoadTokenRef.current) return
if (requestToken !== contactsLoadTokenRef.current) {
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'canceled', {
detail: '页面状态已刷新,本次联系人列表加载已过期'
})
return
}
console.error('Failed to load contacts:', error)
stopContactsCountHydration(true)
if (activeContactsLoadTaskIdRef.current === taskId) {
activeContactsLoadTaskIdRef.current = null
}
finishBackgroundTask(taskId, 'failed', {
detail: String(error)
})
} finally {
if (activeContactsLoadTaskIdRef.current === taskId && requestToken !== contactsLoadTokenRef.current) {
activeContactsLoadTaskIdRef.current = null
}
if (requestToken === contactsLoadTokenRef.current) {
setContactsLoading(false)
}
@@ -1185,6 +1298,18 @@ export default function SnsPage() {
window.clearTimeout(contactsCountBatchTimerRef.current)
contactsCountBatchTimerRef.current = null
}
if (activeContactsCountTaskIdRef.current) {
finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', {
detail: '已离开朋友圈页,联系人朋友圈条数补算已取消'
})
activeContactsCountTaskIdRef.current = null
}
if (activeContactsLoadTaskIdRef.current) {
finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', {
detail: '已离开朋友圈页,联系人列表加载已取消'
})
activeContactsLoadTaskIdRef.current = null
}
}
}, [])

View File

@@ -13,6 +13,7 @@ import './WelcomePage.scss'
const isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac
@@ -46,6 +47,19 @@ const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
return `${base};最近状态:${tailLogs.join(' | ')}`
}
const normalizeDbKeyStatusMessage = (message: string): string => {
if (isWindows && message.includes('Hook安装成功')) {
return '已准备就绪,现在登录微信或退出登录后重新登录微信'
}
return message
}
const isDbKeyReadyMessage = (message: string): boolean => (
message.includes('现在可以登录')
|| message.includes('Hook安装成功')
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
)
function WelcomePage({ standalone = false }: WelcomePageProps) {
const navigate = useNavigate()
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
@@ -89,9 +103,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
// 检查 Hello 可用性
useEffect(() => {
if (window.PublicKeyCredential) {
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
}
setHelloAvailable(isWindows)
}, [])
async function sha256(message: string) {
@@ -103,35 +115,27 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
const handleSetupHello = async () => {
if (!isWindows) {
setError('当前系统不支持 Windows Hello')
return
}
if (!authPassword || authPassword !== authConfirmPassword) {
setError('请先设置并确认应用密码,再开启 Windows Hello')
return
}
setIsSettingHello(true)
try {
// 注册凭证 (WebAuthn)
const challenge = new Uint8Array(32)
window.crypto.getRandomValues(challenge)
const credential = await navigator.credentials.create({
publicKey: {
challenge,
rp: { name: 'WeFlow', id: 'localhost' },
user: {
id: new Uint8Array([1]),
name: 'user',
displayName: 'User'
},
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
authenticatorSelection: { userVerification: 'required' },
timeout: 60000
}
})
if (credential) {
setEnableHello(true)
// 成功提示?
const result = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
if (!result.success) {
setError(`Windows Hello 设置失败: ${result.error || '验证失败'}`)
return
}
setEnableHello(true)
setError('')
} catch (e: any) {
if (e.name !== 'NotAllowedError') {
setError('Windows Hello 设置失败: ' + e.message)
}
setError(`Windows Hello 设置失败: ${e?.message || String(e)}`)
} finally {
setIsSettingHello(false)
}
@@ -139,8 +143,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message)
if (payload.message.includes('现在可以登录') || payload.message.includes('Hook安装成功')) {
const normalizedMessage = normalizeDbKeyStatusMessage(payload.message)
setDbKeyStatus(normalizedMessage)
if (isDbKeyReadyMessage(normalizedMessage)) {
window.electronAPI.notification?.show({
title: 'WeFlow 准备就绪',
content: '现在可以登录微信了',
@@ -487,7 +492,17 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const hash = await sha256(authPassword)
await configService.setAuthEnabled(true)
await configService.setAuthPassword(hash)
await configService.setAuthUseHello(enableHello)
if (enableHello) {
const helloResult = await window.electronAPI.auth.setHelloSecret(authPassword)
if (!helloResult.success) {
setError('Windows Hello 配置保存失败')
setLoading(false)
return
}
} else {
await window.electronAPI.auth.clearHelloSecret()
await configService.setAuthUseHello(false)
}
}
await configService.setOnboardingDone(true)
@@ -761,8 +776,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
)}
</div>
{dbKeyStatus && <div className={`status-message ${dbKeyStatus.includes('现在可以登录') || dbKeyStatus.includes('Hook安装成功') ? 'is-success' : ''}`}>{dbKeyStatus}</div>}
<div className="field-hint"></div>
{dbKeyStatus && <div className={`status-message ${isDbKeyReadyMessage(dbKeyStatus) ? 'is-success' : ''}`}>{dbKeyStatus}</div>}
</div>
)}

View File

@@ -34,6 +34,7 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
@@ -63,8 +64,13 @@ export const CONFIG_KEYS = {
NOTIFICATION_POSITION: 'notificationPosition',
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
HTTP_API_TOKEN: 'httpApiToken',
HTTP_API_ENABLED: 'httpApiEnabled',
HTTP_API_PORT: 'httpApiPort',
HTTP_API_HOST: 'httpApiHost',
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
QUOTE_LAYOUT: 'quoteLayout',
// 词云
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
@@ -89,6 +95,7 @@ export interface ExportDefaultMediaConfig {
}
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
export type QuoteLayout = 'quote-top' | 'quote-bottom'
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
images: true,
@@ -114,6 +121,17 @@ export async function getDbPath(): Promise<string | null> {
return value as string | null
}
// 获取api access_token
export async function getHttpApiToken(): Promise<string> {
const value = await config.get(CONFIG_KEYS.HTTP_API_TOKEN)
return (value as string) || ''
}
// 设置access_token
export async function setHttpApiToken(token: string): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_TOKEN, token)
}
// 设置数据库路径
export async function setDbPath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.DB_PATH, path)
@@ -462,6 +480,18 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
}
// 获取缺图时是否深度搜索(默认导出行为)
export async function getExportDefaultImageDeepSearchOnMiss(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS)
if (typeof value === 'boolean') return value
return null
}
// 设置缺图时是否深度搜索(默认导出行为)
export async function setExportDefaultImageDeepSearchOnMiss(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled)
}
export type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
@@ -580,6 +610,8 @@ export interface ExportSessionContentMetricCacheEntry {
imageMessages?: number
videoMessages?: number
emojiMessages?: number
firstTimestamp?: number
lastTimestamp?: number
}
export interface ExportSessionContentMetricCacheItem {
@@ -645,6 +677,10 @@ export interface ContactsListCacheContact {
displayName: string
remark?: string
nickname?: string
alias?: string
labels?: string[]
detailDescription?: string
region?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
@@ -742,6 +778,12 @@ export async function getExportSessionContentMetricCache(scopeKey: string): Prom
if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) {
metric.emojiMessages = Math.floor(source.emojiMessages)
}
if (typeof source.firstTimestamp === 'number' && Number.isFinite(source.firstTimestamp) && source.firstTimestamp > 0) {
metric.firstTimestamp = Math.floor(source.firstTimestamp)
}
if (typeof source.lastTimestamp === 'number' && Number.isFinite(source.lastTimestamp) && source.lastTimestamp > 0) {
metric.lastTimestamp = Math.floor(source.lastTimestamp)
}
if (Object.keys(metric).length === 0) continue
metrics[sessionId] = metric
}
@@ -781,6 +823,12 @@ export async function setExportSessionContentMetricCache(
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
metric.emojiMessages = Math.floor(rawMetric.emojiMessages)
}
if (typeof rawMetric.firstTimestamp === 'number' && Number.isFinite(rawMetric.firstTimestamp) && rawMetric.firstTimestamp > 0) {
metric.firstTimestamp = Math.floor(rawMetric.firstTimestamp)
}
if (typeof rawMetric.lastTimestamp === 'number' && Number.isFinite(rawMetric.lastTimestamp) && rawMetric.lastTimestamp > 0) {
metric.lastTimestamp = Math.floor(rawMetric.lastTimestamp)
}
if (Object.keys(metric).length === 0) continue
normalized[sessionId] = metric
}
@@ -1107,16 +1155,18 @@ export async function setSnsPageCache(
export async function getContactsLoadTimeoutMs(): Promise<number> {
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
return Math.floor(value)
const normalized = Math.floor(value)
// 兼容历史默认值 3000ms自动提升到新的更稳妥阈值。
return normalized === 3000 ? 10000 : normalized
}
return 3000
return 10000
}
// 设置通讯录加载超时阈值(毫秒)
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
const normalized = Number.isFinite(timeoutMs)
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
: 3000
: 10000
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
}
@@ -1145,6 +1195,12 @@ export async function getContactsListCache(scopeKey: string): Promise<ContactsLi
displayName,
remark: typeof item.remark === 'string' ? item.remark : undefined,
nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
alias: typeof item.alias === 'string' ? item.alias : undefined,
labels: Array.isArray(item.labels)
? Array.from(new Set(item.labels.map((label) => String(label || '').trim()).filter(Boolean)))
: undefined,
detailDescription: typeof item.detailDescription === 'string' ? (item.detailDescription.trim() || undefined) : undefined,
region: typeof item.region === 'string' ? (item.region.trim() || undefined) : undefined,
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
? type
: 'other'
@@ -1178,6 +1234,12 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
displayName,
remark: contact?.remark ? String(contact.remark) : undefined,
nickname: contact?.nickname ? String(contact.nickname) : undefined,
alias: contact?.alias ? String(contact.alias) : undefined,
labels: Array.isArray(contact?.labels)
? Array.from(new Set(contact.labels.map((label) => String(label || '').trim()).filter(Boolean)))
: undefined,
detailDescription: contact?.detailDescription ? (String(contact.detailDescription).trim() || undefined) : undefined,
region: contact?.region ? (String(contact.region).trim() || undefined) : undefined,
type
})
}
@@ -1382,6 +1444,16 @@ export async function setWindowCloseBehavior(behavior: WindowCloseBehavior): Pro
await config.set(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR, behavior)
}
export async function getQuoteLayout(): Promise<QuoteLayout> {
const value = await config.get(CONFIG_KEYS.QUOTE_LAYOUT)
if (value === 'quote-bottom') return value
return 'quote-top'
}
export async function setQuoteLayout(layout: QuoteLayout): Promise<void> {
await config.set(CONFIG_KEYS.QUOTE_LAYOUT, layout)
}
// 获取词云排除词列表
export async function getWordCloudExcludeWords(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)
@@ -1415,3 +1487,35 @@ export async function getAnalyticsDenyCount(): Promise<number> {
export async function setAnalyticsDenyCount(count: number): Promise<void> {
await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count)
}
// 获取 HTTP API 自动启动状态
export async function getHttpApiEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.HTTP_API_ENABLED)
return value === true
}
// 设置 HTTP API 自动启动状态
export async function setHttpApiEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_ENABLED, enabled)
}
// 获取 HTTP API 端口
export async function getHttpApiPort(): Promise<number> {
const value = await config.get(CONFIG_KEYS.HTTP_API_PORT)
return typeof value === 'number' ? value : 5031
}
// 设置 HTTP API 端口
export async function setHttpApiPort(port: number): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_PORT, port)
}
export async function getHttpApiHost(): Promise<string> {
const value = await config.get(CONFIG_KEYS.HTTP_API_HOST)
return typeof value === 'string' && value.trim() ? value.trim() : '127.0.0.1'
}
export async function setHttpApiHost(host: string): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_HOST, host)
}

View File

@@ -61,6 +61,7 @@ export interface ElectronAPI {
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
checkWayland: () => Promise<boolean>
}
notification: {
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
@@ -218,7 +219,7 @@ export interface ElectronAPI {
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
getContacts: () => Promise<{
getContacts: (options?: { lite?: boolean }) => Promise<{
success: boolean
contacts?: ContactInfo[]
error?: string
@@ -495,6 +496,28 @@ export interface ElectronAPI {
}
error?: string
}>
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => Promise<{
success: boolean
data?: {
statistics: {
totalMessages: number
textMessages: number
imageMessages: number
voiceMessages: number
videoMessages: number
emojiMessages: number
otherMessages: number
sentMessages: number
receivedMessages: number
firstMessageTime: number | null
lastMessageTime: number | null
activeDays: number
messageTypeCounts: Record<number, number>
}
timeDistribution: Record<number, number>
}
error?: string
}>
getGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
@@ -790,6 +813,16 @@ export interface ElectronAPI {
}>
likes: Array<string>
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: Array<{ url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }> }>
location?: {
latitude?: number
longitude?: number
city?: string
country?: string
poiName?: string
poiAddress?: string
poiAddressName?: string
label?: string
}
rawXml?: string
}>
error?: string
@@ -827,7 +860,7 @@ export interface ElectronAPI {
getLogs: () => Promise<string[]>
}
http: {
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
start: (port?: number, host?: string) => Promise<{ success: boolean; port?: number; error?: string }>
stop: () => Promise<{ success: boolean }>
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
}
@@ -852,6 +885,7 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
}
export interface ExportProgress {

View File

@@ -37,6 +37,9 @@ export interface ContactInfo {
remark?: string
nickname?: string
alias?: string
labels?: string[]
detailDescription?: string
region?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}

View File

@@ -34,6 +34,17 @@ export interface SnsComment {
emojis?: SnsCommentEmoji[]
}
export interface SnsLocation {
latitude?: number
longitude?: number
city?: string
country?: string
poiName?: string
poiAddress?: string
poiAddressName?: string
label?: string
}
export interface SnsPost {
id: string
tid?: string // 数据库主键(雪花 ID用于精确删除
@@ -46,6 +57,7 @@ export interface SnsPost {
media: SnsMedia[]
likes: string[]
comments: SnsComment[]
location?: SnsLocation
rawXml?: string
linkTitle?: string
linkUrl?: string

View File

@@ -4,6 +4,10 @@ import electron from 'vite-plugin-electron'
import renderer from 'vite-plugin-electron-renderer'
import { resolve } from 'path'
const handleElectronOnStart = (options: { reload: () => void }) => {
options.reload()
}
export default defineConfig({
base: './',
server: {
@@ -23,6 +27,7 @@ export default defineConfig({
electron([
{
entry: 'electron/main.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -43,6 +48,7 @@ export default defineConfig({
},
{
entry: 'electron/annualReportWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -61,6 +67,7 @@ export default defineConfig({
},
{
entry: 'electron/dualReportWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -79,6 +86,7 @@ export default defineConfig({
},
{
entry: 'electron/imageSearchWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -93,6 +101,7 @@ export default defineConfig({
},
{
entry: 'electron/wcdbWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -112,6 +121,7 @@ export default defineConfig({
},
{
entry: 'electron/transcribeWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -129,6 +139,7 @@ export default defineConfig({
},
{
entry: 'electron/exportWorker.ts',
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron',
@@ -149,9 +160,7 @@ export default defineConfig({
},
{
entry: 'electron/preload.ts',
onstart(options) {
options.reload()
},
onstart: handleElectronOnStart,
vite: {
build: {
outDir: 'dist-electron'