Compare commits

...

74 Commits

Author SHA1 Message Date
hicccc77
0cf8ea8166 chore: bump version to 4.2.0 2026-03-26 23:20:42 +08:00
hicccc77
74b830dd79 chore: update service files and xkey_helper
- xkey_helper: use mach exception port to intercept EXC_BREAKPOINT,
  fixes key capture failure on macOS 26.2
2026-03-26 23:19:08 +08:00
cc
8668c168a7 333 2026-03-26 22:43:59 +08:00
cc
8b8c5f33ce 333 2026-03-26 22:34:50 +08:00
cc
2fcbb026df 222 2026-03-26 22:32:33 +08:00
cc
66ee72380d 222 2026-03-26 22:30:21 +08:00
cc
4f16345351 111 2026-03-26 22:26:33 +08:00
cc
5110618996 再次修复 2026-03-26 22:19:30 +08:00
cc
bf51368cf4 修复密钥问题 2026-03-26 22:16:30 +08:00
cc
d6054745d6 修复macos打包错误 2026-03-26 22:00:42 +08:00
hicccc77
a4731f25f8 chore: update xkey_helper (macOS) with pure semantic scan mode
Always use pure semantic scan mode (KNOWN_RVA=0) regardless of
WeChat version, improving compatibility for versions < 4.1.8.
2026-03-26 21:16:14 +08:00
hicccc77
6c4507e495 fix(ci): remove invalid --no-fail-on-no-release flag from gh release edit 2026-03-26 20:33:18 +08:00
hicccc77
c8e0160d5c fix(ci): use bash shell for Windows packaging steps to avoid PowerShell variable expansion 2026-03-26 20:21:13 +08:00
hicccc77
ac40a81901 fix(ci): pre-release placeholder + fix latest.yml arm64 overwrite
- Add prepare-release job: immediately marks release as pre-release
  with "正在构建中,请勿下载" notice; all build jobs depend on it
- Fix arm64 job channel override: use -c.publish.channel=latest-arm64
  (correct syntax) instead of broken single-quoted CLI arg
- Fix artifactName quoting for both win x64 and arm64 jobs
- Add "Fix latest.yml" step in update-release-notes: downloads x64 exe,
  computes correct sha512/size, uploads latest.yml with --clobber
- Final step in update-release-notes: remove prerelease flag, mark as
  latest official release

Fixes #553
2026-03-26 19:57:45 +08:00
hicccc77
ca38a68a75 fix: 改善错误码 -3001 提示信息并增强 db_storage 路径解析兼容性\n\n- formatInitProtectionError 返回可读的中文错误说明,替代裸错误码\n- resolveDbStoragePath 新增向上查找(最多2级)兜底逻辑\n- 新增 findDbStorageRecursive 递归搜索(最多3层)兜底\n- 解决使用 wx_key 获取密钥后因路径层级不同导致 -3001 报错的问题\n\nFixes #552 2026-03-26 15:08:08 +08:00
hicccc77
64be2dd562 fix: 支持微信 4.0.5+ 新数据目录结构 (Application Support/com.tencent.xinWeChat/2.0b4.0.x)
- dbPathService.autoDetect: 自动枚举版本目录(如 2.0b4.0.9),优先检测新路径
- dbPathService.getDefaultPath: 同步返回新版本路径
- keyServiceMac.resolveXwechatRootFromPath: 兼容新路径标记
- keyServiceMac.getKvcommCandidates: 补充新路径下的 kvcomm 推导

Fixes #551
2026-03-26 12:08:47 +08:00
cc
ea2abb6c72 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-25 20:09:53 +08:00
cc
011e2ff37a 修复Action打包问题与渲染层过滤问题 2026-03-25 20:09:48 +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
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
H3CoF6
6f3b60ef2c fix: 修复linux打包后无法拉起wechat的bug 2026-03-20 06:44:03 +08:00
55 changed files with 13707 additions and 581 deletions

View File

@@ -12,8 +12,27 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs: jobs:
prepare-release:
runs-on: ubuntu-latest
steps:
- name: Mark release as pre-release (building)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="$GITHUB_REF_NAME"
REPO="$GITHUB_REPOSITORY"
# Create or update the release as a pre-release with a placeholder note
if gh release view "$TAG" --repo "$REPO" > /dev/null 2>&1; then
gh release edit "$TAG" --repo "$REPO" --prerelease --notes $'## ⚠️ 正在自动构建中,请勿下载\n\n各平台安装包正在构建完成后将自动更新本页面并正式发布。\n\n**请勿在此期间下载任何文件。**'
else
gh release create "$TAG" --repo "$REPO" --prerelease --title "$TAG" --notes $'## ⚠️ 正在自动构建中,请勿下载\n\n各平台安装包正在构建完成后将自动更新本页面并正式发布。\n\n**请勿在此期间下载任何文件。**'
fi
release-mac-arm64: release-mac-arm64:
runs-on: macos-14 runs-on: macos-14
needs: prepare-release
steps: steps:
- name: Check out git repository - name: Check out git repository
@@ -42,15 +61,16 @@ jobs:
npx tsc npx tsc
npx vite build npx vite build
- name: Package and Publish macOS arm64 (unsigned DMG) - name: Package and Publish macOS arm64 (unsigned DMG + ZIP)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: | run: |
npx electron-builder --mac dmg --arm64 --publish always npx electron-builder --mac --arm64 --publish always
release-linux: release-linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: prepare-release
steps: steps:
- name: Check out git repository - name: Check out git repository
@@ -87,6 +107,7 @@ jobs:
release: release:
runs-on: windows-latest runs-on: windows-latest
needs: prepare-release
steps: steps:
- name: Check out git repository - name: Check out git repository
@@ -118,8 +139,47 @@ jobs:
- name: Package and Publish - name: Package and Publish
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: | run: |
npx electron-builder --publish always npx electron-builder --win nsis --x64 --publish always "-c.artifactName=\${productName}-\${version}-x64-Setup.\${ext}"
release-windows-arm64:
runs-on: windows-latest
needs: prepare-release
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 }}
shell: bash
run: |
npx electron-builder --win nsis --arm64 --publish always -c.publish.channel=latest-arm64 "-c.artifactName=\${productName}-\${version}-arm64-Setup.\${ext}"
update-release-notes: update-release-notes:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -127,8 +187,56 @@ jobs:
- release-mac-arm64 - release-mac-arm64
- release-linux - release-linux
- release - release
- release-windows-arm64
steps: steps:
- name: Fix latest.yml to point to x64 installer
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="$GITHUB_REF_NAME"
VERSION="${TAG#v}"
REPO="$GITHUB_REPOSITORY"
# Find the x64 exe asset name
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
X64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')"
if [ -z "$X64_ASSET" ]; then
X64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
fi
if [ -z "$X64_ASSET" ]; then
echo "ERROR: Could not find x64 exe asset"
exit 1
fi
echo "Downloading x64 installer: $X64_ASSET"
gh release download "$TAG" --repo "$REPO" --pattern "$X64_ASSET" --dir /tmp/weflow-x64
SHA512_B64="$(sha512sum "/tmp/weflow-x64/$X64_ASSET" | awk '{print $1}' | xxd -r -p | base64 -w 0)"
SIZE="$(stat -c%s "/tmp/weflow-x64/$X64_ASSET")"
RELEASE_DATE="$(gh release view "$TAG" --repo "$REPO" --json publishedAt -q .publishedAt)"
cat > /tmp/latest.yml <<YMLEOF
version: $VERSION
files:
- url: $X64_ASSET
sha512: $SHA512_B64
size: $SIZE
path: $X64_ASSET
sha512: $SHA512_B64
releaseDate: '$RELEASE_DATE'
YMLEOF
# Strip leading spaces (heredoc indentation)
sed -i 's/^ //' /tmp/latest.yml
cat /tmp/latest.yml
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
echo "latest.yml updated successfully to point to $X64_ASSET"
- name: Generate release notes with platform download links - name: Generate release notes with platform download links
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -147,10 +255,14 @@ jobs:
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""' 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$")" MAC_ASSET="$(pick_asset "\\.dmg$")"
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")" LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
build_link() { build_link() {
local name="$1" local name="$1"
@@ -160,9 +272,10 @@ jobs:
} }
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")" WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")" MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")" LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
cat > release_notes.md <<EOF cat > release_notes.md <<EOF
## 更新日志 ## 更新日志
@@ -172,12 +285,27 @@ jobs:
[点击加入 Telegram 频道](https://t.me/weflow_cc) [点击加入 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} - macOSM系列芯片: ${MAC_URL:-$RELEASE_PAGE}
- Linux (.deb): ${LINUX_DEB_URL:-$RELEASE_PAGE}
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE} - Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
## macOS 安装提示(未知来源)
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
- xattr -rd com.apple.quarantine /Applications/WeFlow.app
- 执行后重新打开 WeFlow。
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE > 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
EOF EOF
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
- name: Mark release as published (no longer pre-release)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --latest --draft=false --prerelease=false

1
.gitignore vendored
View File

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

View File

@@ -43,9 +43,21 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
- HTTP API 接口(供开发者集成) - 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. 运行应用(开发模式) # 3. 运行应用(开发模式)
npm run dev npm run dev
# 4. 打包可执行文件
npm run build
``` ```
打包产物在 `release` 目录下。
## 致谢 ## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架

View File

@@ -1,6 +1,6 @@
# WeFlow HTTP API / Push 文档 # 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` - 基础地址:`http://127.0.0.1:5031`
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端 - 可选开启 `主动推送`,检测到新收到的消息后会通过 `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|POST /health`
- `GET /api/v1/health` - `GET|POST /api/v1/health`
- `GET /api/v1/push/messages` - `GET|POST /api/v1/push/messages`
- `GET /api/v1/messages` - `GET|POST /api/v1/messages`
- `GET /api/v1/messages/new` - `GET|POST /api/v1/messages/new`
- `GET /api/v1/sessions` - `GET|POST /api/v1/sessions`
- `GET /api/v1/contacts` - `GET|POST /api/v1/contacts`
- `GET /api/v1/group-members` - `GET|POST /api/v1/group-members`
- `GET /api/v1/media/*` - `GET|POST /api/v1/media/*`
--- ---
@@ -80,7 +90,7 @@ GET /api/v1/push/messages
### 示例 ### 示例
```bash ```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. 获取消息 ## 3. 获取消息
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。 读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
**请求** **请求**
@@ -231,6 +243,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
## 4. 获取会话列表 ## 4. 获取会话列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求** **请求**
```http ```http
@@ -276,6 +290,8 @@ GET /api/v1/sessions
## 5. 获取联系人列表 ## 5. 获取联系人列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求** **请求**
```http ```http
@@ -325,6 +341,8 @@ GET /api/v1/contacts
## 6. 获取群成员列表 ## 6. 获取群成员列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
返回群成员的 `wxid`、群昵称、备注、微信号等信息。 返回群成员的 `wxid`、群昵称、备注、微信号等信息。
**请求** **请求**
@@ -417,6 +435,8 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
## 7. 访问导出媒体 ## 7. 访问导出媒体
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。 通过消息接口启用 `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
```powershell ```powershell
Invoke-RestMethod http://127.0.0.1:5031/health $headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions $body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
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" Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
``` ```
### cURL ### cURL
```bash ```bash
curl http://127.0.0.1:5031/health # GET 带 Token Header
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
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" # 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 ### Python
@@ -482,19 +506,21 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
import requests import requests
BASE_URL = "http://127.0.0.1:5031" BASE_URL = "http://127.0.0.1:5031"
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
messages = requests.get( # POST 方式获取消息
f"{BASE_URL}/api/v1/messages", messages = requests.post(
params={"talker": "xxx@chatroom", "limit": 50} f"{BASE_URL}/api/v1/messages",
json={"talker": "xxx@chatroom", "limit": 50},
headers=headers
).json() ).json()
# GET 方式获取群成员
members = requests.get( members = requests.get(
f"{BASE_URL}/api/v1/group-members", f"{BASE_URL}/api/v1/group-members",
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1} params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
headers=headers
).json() ).json()
print(messages)
print(members)
``` ```
--- ---

View File

@@ -36,6 +36,10 @@ import { messagePushService } from './services/messagePushService'
autoUpdater.autoDownload = false autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true autoUpdater.autoInstallOnAppQuit = true
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载 autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
// Windows x64 与 arm64 使用不同更新通道,避免 latest.yml 互相覆盖导致下错架构安装包。
if (process.platform === 'win32' && process.arch === 'arm64') {
autoUpdater.channel = 'latest-arm64'
}
const AUTO_UPDATE_ENABLED = const AUTO_UPDATE_ENABLED =
process.env.AUTO_UPDATE_ENABLED === 'true' || process.env.AUTO_UPDATE_ENABLED === 'true' ||
process.env.AUTO_UPDATE_ENABLED === '1' || process.env.AUTO_UPDATE_ENABLED === '1' ||
@@ -122,6 +126,123 @@ let isDownloadInProgress = false
let downloadProgressHandler: ((progress: any) => void) | null = null let downloadProgressHandler: ((progress: any) => void) | null = null
let downloadedHandler: (() => 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 normalizeHeadingText = (raw: string): string => {
return raw
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, '\'')
.replace(/&#x27;/gi, '\'')
.toLowerCase()
.replace(/[:]/g, '')
.replace(/\s+/g, '')
.trim()
}
const shouldStripReleaseSection = (headingRaw: string): boolean => {
const heading = normalizeHeadingText(headingRaw)
if (!heading) return false
if (heading.startsWith('下载') || heading.startsWith('download')) return true
if ((heading.includes('macos') || heading.startsWith('mac')) && heading.includes('安装提示')) return true
return false
}
// 兼容 electron-updater 直接返回 HTML 的场景(含 dir/anchor 等标签嵌套)
const removeDownloadSectionFromHtml = (input: string): string => {
const headingPattern = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi
const headings: Array<{ start: number; end: number; headingText: string }> = []
let match: RegExpExecArray | null
while ((match = headingPattern.exec(input)) !== null) {
const full = match[0]
headings.push({
start: match.index,
end: match.index + full.length,
headingText: match[2] || ''
})
}
if (headings.length === 0) return input
const rangesToRemove: Array<{ start: number; end: number }> = []
for (let i = 0; i < headings.length; i += 1) {
const current = headings[i]
if (!shouldStripReleaseSection(current.headingText)) continue
const nextStart = i + 1 < headings.length ? headings[i + 1].start : input.length
rangesToRemove.push({ start: current.start, end: nextStart })
}
if (rangesToRemove.length === 0) return input
let output = ''
let cursor = 0
for (const range of rangesToRemove) {
output += input.slice(cursor, range.start)
cursor = range.end
}
output += input.slice(cursor)
return output
}
// 兼容 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))
// 兜底:即使没有匹配到标题,也不在弹窗展示 macOS 隔离标记清理命令
.replace(/^[ \t>*-]*`?\s*xattr\s+-[a-z]*d[a-z]*\s+com\.apple\.quarantine[^\n]*`?\s*$/gim, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
return cleaned
}
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid' type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done' type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
@@ -1043,6 +1164,13 @@ function registerIpcHandlers() {
return app.getVersion() 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 () => { ipcMain.handle('log:getPath', async () => {
return join(app.getPath('userData'), 'logs', 'wcdb.log') return join(app.getPath('userData'), 'logs', 'wcdb.log')
}) })
@@ -1114,7 +1242,7 @@ function registerIpcHandlers() {
return { return {
hasUpdate: true, hasUpdate: true,
version: latestVersion, version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes as string || '' releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
} }
} }
} }
@@ -1172,7 +1300,7 @@ function registerIpcHandlers() {
try { try {
console.log('[Update] 开始下载更新...') console.log('[Update] 开始下载更新...')
await autoUpdater.downloadUpdate() await autoUpdater.downloadUpdate()
} catch (error) { } catch (error: any) {
console.error('[Update] 下载更新失败:', error) console.error('[Update] 下载更新失败:', error)
// 失败时清理状态和监听器 // 失败时清理状态和监听器
isDownloadInProgress = false isDownloadInProgress = false
@@ -1184,7 +1312,10 @@ function registerIpcHandlers() {
autoUpdater.removeListener('update-downloaded', downloadedHandler) autoUpdater.removeListener('update-downloaded', downloadedHandler)
downloadedHandler = null downloadedHandler = null
} }
throw error
// 统一错误提示格式,避免出现 [object Object] 的 JSON 字符串
const errorMessage = error.message || (typeof error === 'string' ? error : JSON.stringify(error))
throw new Error(errorMessage)
} }
}) })
@@ -1459,8 +1590,8 @@ function registerIpcHandlers() {
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername) return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
}) })
ipcMain.handle('chat:getContacts', async () => { ipcMain.handle('chat:getContacts', async (_, options?: { lite?: boolean }) => {
return await chatService.getContacts() return await chatService.getContacts(options)
}) })
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => { ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
@@ -2071,6 +2202,13 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime) 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( ipcMain.handle(
'groupAnalytics:getGroupMemberMessages', 'groupAnalytics:getGroupMemberMessages',
async ( async (
@@ -2501,26 +2639,27 @@ function registerIpcHandlers() {
// 密钥获取 // 密钥获取
ipcMain.handle('key:autoGetDbKey', async (event) => { ipcMain.handle('key:autoGetDbKey', async (event) => {
return keyService.autoGetDbKey(180_000, (message, level) => { return keyService.autoGetDbKey(180_000, (message: string, level: number) => {
event.sender.send('key:dbKeyStatus', { message, level }) event.sender.send('key:dbKeyStatus', { message, level })
}) })
}) })
ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string, wxid?: string) => { ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string, wxid?: string) => {
return keyService.autoGetImageKey(manualDir, (message) => { return keyService.autoGetImageKey(manualDir, (message: string) => {
event.sender.send('key:imageKeyStatus', { message }) event.sender.send('key:imageKeyStatus', { message })
}, wxid) }, wxid)
}) })
ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => { ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => {
return keyService.autoGetImageKeyByMemoryScan(userDir, (message) => { return keyService.autoGetImageKeyByMemoryScan(userDir, (message: string) => {
event.sender.send('key:imageKeyStatus', { message }) event.sender.send('key:imageKeyStatus', { message })
}) })
}) })
// HTTP API 服务 // HTTP API 服务
ipcMain.handle('http:start', async (_, port?: number) => { ipcMain.handle('http:start', async (_, port?: number, host?: string) => {
return httpService.start(port || 5031) const bindHost = typeof host === 'string' && host.trim() ? host.trim() : '127.0.0.1'
return httpService.start(port || 5031, bindHost)
}) })
ipcMain.handle('http:stop', async () => { ipcMain.handle('http:stop', async () => {
@@ -2567,7 +2706,7 @@ function checkForUpdatesOnStartup() {
// 通知渲染进程有新版本 // 通知渲染进程有新版本
mainWindow.webContents.send('app:updateAvailable', { mainWindow.webContents.send('app:updateAvailable', {
version: latestVersion, version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes || '' releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
}) })
} }
} }
@@ -2739,6 +2878,8 @@ app.whenReady().then(async () => {
// 启动时检测更新(不阻塞启动) // 启动时检测更新(不阻塞启动)
checkForUpdatesOnStartup() checkForUpdatesOnStartup()
await httpService.autoStart()
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow() mainWindow = createWindow()

View File

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

View File

@@ -6,7 +6,7 @@ import * as https from 'https'
import * as http from 'http' import * as http from 'http'
import * as fzstd from 'fzstd' import * as fzstd from 'fzstd'
import * as crypto from 'crypto' import * as crypto from 'crypto'
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow, dialog } from 'electron'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService' import { MessageCacheService } from './messageCacheService'
@@ -16,6 +16,7 @@ import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from '
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService' import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
import { voiceTranscribeService } from './voiceTranscribeService' import { voiceTranscribeService } from './voiceTranscribeService'
import { ImageDecryptService } from './imageDecryptService' import { ImageDecryptService } from './imageDecryptService'
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
import { LRUCache } from '../utils/LRUCache.js' import { LRUCache } from '../utils/LRUCache.js'
export interface ChatSession { export interface ChatSession {
@@ -153,10 +154,17 @@ export interface ContactInfo {
remark?: string remark?: string
nickname?: string nickname?: string
alias?: string alias?: string
labels?: string[]
detailDescription?: string
region?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
interface GetContactsOptions {
lite?: boolean
}
interface ExportSessionStats { interface ExportSessionStats {
totalMessages: number totalMessages: number
voiceMessages: number voiceMessages: number
@@ -292,6 +300,22 @@ class ChatService {
private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000 private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000
private groupMyMessageCountCacheScope = '' private groupMyMessageCountCacheScope = ''
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>() 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() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
@@ -338,6 +362,55 @@ class ChatService {
return true 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 cleanedWxid = this.cleanAccountDirName(wxid)
const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid) const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!openOk) { 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 this.connected = true
@@ -376,7 +451,7 @@ class ChatService {
return { success: true } return { success: true }
} catch (e) { } catch (e) {
console.error('ChatService: 连接数据库失败:', 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 { 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() const connectResult = await this.ensureConnected()
captureStage('ensureConnected', connectStartedAt)
if (!connectResult.success) { if (!connectResult.success) {
return { success: false, error: connectResult.error } return { success: false, error: connectResult.error }
} }
const contactsCompactStartedAt = Date.now()
const contactResult = await wcdbService.getContactsCompact() const contactResult = await wcdbService.getContactsCompact()
captureStage('getContactsCompact', contactsCompactStartedAt)
if (!contactResult.success || !contactResult.contacts) { if (!contactResult.success || !contactResult.contacts) {
console.error('查询联系人失败:', contactResult.error) console.error('查询联系人失败:', contactResult.error)
return { success: false, 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 lastContactTimeMap = new Map<string, number>()
const sessionResult = await wcdbService.getSessions() const sessionResult = await wcdbService.getSessions()
captureStage('getSessions', sessionsStartedAt)
if (sessionResult.success && sessionResult.sessions) { if (sessionResult.success && sessionResult.sessions) {
for (const session of sessionResult.sessions as any[]) { for (const session of sessionResult.sessions as any[]) {
const username = session.username || session.user_name || session.userName || '' const username = session.username || session.user_name || session.userName || ''
@@ -1245,9 +1356,14 @@ class ChatService {
} }
// 转换为ContactInfo // 转换为ContactInfo
const transformStartedAt = Date.now()
const contacts: (ContactInfo & { lastContactTime: number })[] = [] 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) { for (const row of rows) {
const username = String(row.username || '').trim() const username = String(row.username || '').trim()
@@ -1261,7 +1377,7 @@ class ChatService {
type = 'group' type = 'group'
} else if (username.startsWith('gh_')) { } else if (username.startsWith('gh_')) {
type = 'official' type = 'official'
} else if (localType === 1 && !excludeNames.has(username)) { } else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
type = 'friend' type = 'friend'
} else if (localType === 0 && quanPin) { } else if (localType === 0 && quanPin) {
type = 'former_friend' type = 'former_friend'
@@ -1270,6 +1386,9 @@ class ChatService {
} }
const displayName = row.remark || row.nick_name || row.alias || username 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({ contacts.push({
username, username,
@@ -1277,16 +1396,19 @@ class ChatService {
remark: row.remark || undefined, remark: row.remark || undefined,
nickname: row.nick_name || undefined, nickname: row.nick_name || undefined,
alias: row.alias || undefined, alias: row.alias || undefined,
labels: labels.length > 0 ? labels : undefined,
detailDescription: detailDescription || undefined,
region: region || undefined,
avatarUrl: undefined, avatarUrl: undefined,
type, type,
lastContactTime: lastContactTimeMap.get(username) || 0 lastContactTime: lastContactTimeMap.get(username) || 0
}) })
} }
captureStage('transformContacts', transformStartedAt)
// 按最近联系时间排序 // 按最近联系时间排序
const sortStartedAt = Date.now()
contacts.sort((a, b) => { contacts.sort((a, b) => {
const timeA = a.lastContactTime || 0 const timeA = a.lastContactTime || 0
const timeB = b.lastContactTime || 0 const timeB = b.lastContactTime || 0
@@ -1295,13 +1417,22 @@ class ChatService {
} }
if (timeA && !timeB) return -1 if (timeA && !timeB) return -1
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字段 // 移除临时的lastContactTime字段
const finalizeStartedAt = Date.now()
const result = contacts.map(({ lastContactTime, ...rest }) => rest) 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 } return { success: true, contacts: result }
} catch (e) { } catch (e) {
console.error('ChatService: 获取通讯录失败:', e) console.error('ChatService: 获取通讯录失败:', e)
@@ -1828,6 +1959,568 @@ class ChatService {
return Number.isFinite(parsed) ? parsed : fallback 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 { private normalizeUnsignedIntegerToken(raw: any): string | undefined {
if (raw === undefined || raw === null || raw === '') return undefined if (raw === undefined || raw === null || raw === '') return undefined
@@ -5003,7 +5696,17 @@ class ChatService {
const contact = await this.getContact(username) const contact = await this.getContact(username)
const avatarResult = await wcdbService.getAvatarUrls([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 displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username
const cacheEntry: ContactCacheEntry = { const cacheEntry: ContactCacheEntry = {
avatarUrl, avatarUrl,
@@ -5034,14 +5737,35 @@ class ChatService {
} }
// 如果是群聊,尝试获取群昵称 // 如果是群聊,尝试获取群昵称
let groupNicknames: Record<string, string> = {} const groupNicknames = new Map<string, string>()
if (chatroomId.endsWith('@chatroom')) { if (chatroomId.endsWith('@chatroom')) {
const nickResult = await wcdbService.getGroupNicknames(chatroomId) const nickResult = await wcdbService.getGroupNicknames(chatroomId)
if (nickResult.success && nickResult.nicknames) { 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用于识别"自己" // 获取当前用户 wxid用于识别"自己"
const myWxid = this.configService.get('myWxid') const myWxid = this.configService.get('myWxid')
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : '' const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
@@ -5051,7 +5775,7 @@ class ChatService {
// 特判如果是当前用户自己contact 表通常不包含自己) // 特判如果是当前用户自己contact 表通常不包含自己)
if (myWxid && (username === myWxid || username === cleanedMyWxid)) { if (myWxid && (username === myWxid || username === cleanedMyWxid)) {
// 先查群昵称中是否有自己 // 先查群昵称中是否有自己
const myGroupNick = groupNicknames[username] const myGroupNick = lookupGroupNickname(username) || lookupGroupNickname(myWxid)
if (myGroupNick) return myGroupNick if (myGroupNick) return myGroupNick
// 尝试从缓存获取自己的昵称 // 尝试从缓存获取自己的昵称
const cached = this.avatarCache.get(username) || this.avatarCache.get(myWxid) 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 if (groupNick) return groupNick
// 再查联系人信息 // 再查联系人信息
@@ -5471,6 +6195,13 @@ class ChatService {
avatarUrl = avatarCandidate avatarUrl = avatarCandidate
} }
} }
if (!avatarUrl) {
const headImageAvatars = await this.getAvatarsFromHeadImageDb([normalizedSessionId])
const fallbackAvatarUrl = headImageAvatars[normalizedSessionId]
if (this.isValidAvatarUrl(fallbackAvatarUrl)) {
avatarUrl = fallbackAvatarUrl
}
}
if (!Number.isFinite(messageCount)) { if (!Number.isFinite(messageCount)) {
messageCount = messageCountResult.status === 'fulfilled' && messageCount = messageCountResult.status === 'fulfilled' &&

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -93,27 +93,39 @@ export class DbPathService {
const possiblePaths: string[] = [] const possiblePaths: string[] = []
const home = homedir() const home = homedir()
// macOS 微信路径(固定)
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
// macOS 微信 4.0.5+ 新路径(优先检测)
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
if (existsSync(appSupportBase)) {
try {
const entries = readdirSync(appSupportBase)
for (const entry of entries) {
// 匹配形如 2.0b4.0.9 的版本目录
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
possiblePaths.push(join(appSupportBase, entry))
}
}
} catch { }
}
// macOS 旧路径兜底
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')) possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
} else { } else {
// Windows 微信4.x 数据目录 // Windows 微信4.x 数据目录
possiblePaths.push(join(home, 'Documents', 'xwechat_files')) possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
} }
for (const path of possiblePaths) { for (const path of possiblePaths) {
if (existsSync(path)) { if (!existsSync(path)) continue
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
continue
}
// 检查是否有有效的账号目录 // 检查是否有有效的账号目录,或本身就是账号目录
const accounts = this.findAccountDirs(path) const accounts = this.findAccountDirs(path)
if (accounts.length > 0) { if (accounts.length > 0) {
return { success: true, path } return { success: true, path }
} }
// 如果该目录本身就是账号目录(直接包含 db_storage 等)
if (this.isAccountDir(path)) {
return { success: true, path }
} }
} }
@@ -295,6 +307,20 @@ export class DbPathService {
getDefaultPath(): string { getDefaultPath(): string {
const home = homedir() const home = homedir()
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
// 优先返回 4.0.5+ 新路径
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
if (existsSync(appSupportBase)) {
try {
const entries = readdirSync(appSupportBase)
for (const entry of entries) {
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
const candidate = join(appSupportBase, entry)
if (existsSync(candidate)) return candidate
}
}
} catch { }
}
// 旧版本路径兜底
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files') return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
} }
return join(home, 'Documents', 'xwechat_files') return join(home, 'Documents', 'xwechat_files')

View File

@@ -105,6 +105,7 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
} }
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
@@ -259,12 +260,20 @@ class ExportService {
private mediaFileCacheReadyDirs = new Set<string>() private mediaFileCacheReadyDirs = new Set<string>()
private mediaExportTelemetry: MediaExportTelemetry | null = null private mediaExportTelemetry: MediaExportTelemetry | null = null
private mediaRunSourceDedupMap = new Map<string, string>() private mediaRunSourceDedupMap = new Map<string, string>()
private mediaRunMissingImageKeys = new Set<string>()
private mediaFileCacheCleanupPending: Promise<void> | null = null private mediaFileCacheCleanupPending: Promise<void> | null = null
private mediaFileCacheLastCleanupAt = 0 private mediaFileCacheLastCleanupAt = 0
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000 private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024 private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024
private readonly mediaFileCacheMaxFiles = 120000 private readonly mediaFileCacheMaxFiles = 120000
private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000 private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000
private emojiCaptionCache = new Map<string, string | null>()
private emojiCaptionPending = new Map<string, Promise<string | null>>()
private emojiMd5ByCdnCache = new Map<string, string | null>()
private emojiMd5ByCdnPending = new Map<string, Promise<string | null>>()
private emoticonDbPathCache: string | null = null
private emoticonDbPathCacheToken = ''
private readonly emojiCaptionLookupConcurrency = 8
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
@@ -517,11 +526,13 @@ class ExportService {
private resetMediaRuntimeState(): void { private resetMediaRuntimeState(): void {
this.mediaExportTelemetry = this.createEmptyMediaTelemetry() this.mediaExportTelemetry = this.createEmptyMediaTelemetry()
this.mediaRunSourceDedupMap.clear() this.mediaRunSourceDedupMap.clear()
this.mediaRunMissingImageKeys.clear()
} }
private clearMediaRuntimeState(): void { private clearMediaRuntimeState(): void {
this.mediaExportTelemetry = null this.mediaExportTelemetry = null
this.mediaRunSourceDedupMap.clear() this.mediaRunSourceDedupMap.clear()
this.mediaRunMissingImageKeys.clear()
} }
private getMediaTelemetrySnapshot(): Partial<ExportProgress> { private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
@@ -915,7 +926,7 @@ class ExportService {
private shouldDecodeMessageContentInFastMode(localType: number): boolean { private shouldDecodeMessageContentInFastMode(localType: number): boolean {
// 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容 // 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容
if (localType === 3 || localType === 34 || localType === 42 || localType === 43 || localType === 47) { if (localType === 3 || localType === 34 || localType === 42 || localType === 43) {
return false return false
} }
return true return true
@@ -989,6 +1000,312 @@ class ExportService {
return `${localType}_${this.getStableMessageKey(msg)}` return `${localType}_${this.getStableMessageKey(msg)}`
} }
private getImageMissingRunCacheKey(
sessionId: string,
imageMd5?: unknown,
imageDatName?: unknown,
imageDeepSearchOnMiss = true
): string | null {
const normalizedSessionId = String(sessionId || '').trim()
const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase()
const normalizedImageDatName = String(imageDatName || '').trim().toLowerCase()
if (!normalizedSessionId) return null
if (!normalizedImageMd5 && !normalizedImageDatName) return null
const primaryToken = normalizedImageMd5 || normalizedImageDatName
const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5
? normalizedImageDatName
: ''
const lookupMode = imageDeepSearchOnMiss ? 'deep' : 'hardlink'
return `${lookupMode}\u001f${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
}
private normalizeEmojiMd5(value: unknown): string | undefined {
const md5 = String(value || '').trim().toLowerCase()
if (!/^[a-f0-9]{32}$/.test(md5)) return undefined
return md5
}
private normalizeEmojiCaption(value: unknown): string | null {
const caption = String(value || '').trim()
if (!caption) return null
return caption
}
private formatEmojiSemanticText(caption?: string | null): string {
const normalizedCaption = this.normalizeEmojiCaption(caption)
if (!normalizedCaption) return '[表情包]'
return `[表情包:${normalizedCaption}]`
}
private extractLooseHexMd5(content: string): string | undefined {
if (!content) return undefined
const keyedMatch =
/(?:emoji|sticker|md5)[^a-fA-F0-9]{0,32}([a-fA-F0-9]{32})/i.exec(content) ||
/([a-fA-F0-9]{32})/i.exec(content)
return this.normalizeEmojiMd5(keyedMatch?.[1] || keyedMatch?.[0])
}
private normalizeEmojiCdnUrl(value: unknown): string | undefined {
let url = String(value || '').trim()
if (!url) return undefined
url = url.replace(/&amp;/g, '&')
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch {
// keep original URL if decoding fails
}
return url.trim() || undefined
}
private resolveStrictEmoticonDbPath(): string | null {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const rawWxid = String(this.configService.get('myWxid') || '').trim()
const cleanedWxid = this.cleanAccountDirName(rawWxid)
const token = `${dbPath}::${rawWxid}::${cleanedWxid}`
if (token === this.emoticonDbPathCacheToken) {
return this.emoticonDbPathCache
}
this.emoticonDbPathCacheToken = token
this.emoticonDbPathCache = null
const dbStoragePath =
this.resolveDbStoragePathForExport(dbPath, cleanedWxid) ||
this.resolveDbStoragePathForExport(dbPath, rawWxid)
if (!dbStoragePath) return null
const strictPath = path.join(dbStoragePath, 'emoticon', 'emoticon.db')
if (fs.existsSync(strictPath)) {
this.emoticonDbPathCache = strictPath
return strictPath
}
return null
}
private resolveDbStoragePathForExport(basePath: string, wxid: string): string | null {
if (!basePath) return null
const normalized = basePath.replace(/[\\/]+$/, '')
if (normalized.toLowerCase().endsWith('db_storage') && fs.existsSync(normalized)) {
return normalized
}
const direct = path.join(normalized, 'db_storage')
if (fs.existsSync(direct)) {
return direct
}
if (!wxid) return null
const viaWxid = path.join(normalized, wxid, 'db_storage')
if (fs.existsSync(viaWxid)) {
return viaWxid
}
try {
const entries = fs.readdirSync(normalized)
const lowerWxid = wxid.toLowerCase()
const candidates = entries.filter((entry) => {
const entryPath = path.join(normalized, entry)
try {
if (!fs.statSync(entryPath).isDirectory()) return false
} catch {
return false
}
const lowerEntry = entry.toLowerCase()
return lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)
})
for (const entry of candidates) {
const candidate = path.join(normalized, entry, 'db_storage')
if (fs.existsSync(candidate)) {
return candidate
}
}
} catch {
// keep null
}
return null
}
private async queryEmojiMd5ByCdnUrlFallback(cdnUrlRaw: string): Promise<string | null> {
const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw)
if (!cdnUrl) return null
const emoticonDbPath = this.resolveStrictEmoticonDbPath()
if (!emoticonDbPath) return null
const candidates = Array.from(new Set([
cdnUrl,
cdnUrl.replace(/&/g, '&amp;')
]))
for (const candidate of candidates) {
const escaped = candidate.replace(/'/g, "''")
const result = await wcdbService.execQuery(
'message',
emoticonDbPath,
`SELECT md5, lower(hex(md5)) AS md5_hex FROM kNonStoreEmoticonTable WHERE cdn_url = '${escaped}' COLLATE NOCASE LIMIT 1`
)
const row = result.success && Array.isArray(result.rows) ? result.rows[0] : null
const md5 = this.normalizeEmojiMd5(this.getRowField(row || {}, ['md5', 'md5_hex']))
if (md5) return md5
}
return null
}
private async getEmojiMd5ByCdnUrl(cdnUrlRaw: string): Promise<string | null> {
const cdnUrl = this.normalizeEmojiCdnUrl(cdnUrlRaw)
if (!cdnUrl) return null
if (this.emojiMd5ByCdnCache.has(cdnUrl)) {
return this.emojiMd5ByCdnCache.get(cdnUrl) ?? null
}
const pending = this.emojiMd5ByCdnPending.get(cdnUrl)
if (pending) return pending
const task = (async (): Promise<string | null> => {
try {
return await this.queryEmojiMd5ByCdnUrlFallback(cdnUrl)
} catch {
return null
}
})()
this.emojiMd5ByCdnPending.set(cdnUrl, task)
try {
const md5 = await task
this.emojiMd5ByCdnCache.set(cdnUrl, md5)
return md5
} finally {
this.emojiMd5ByCdnPending.delete(cdnUrl)
}
}
private async getEmojiCaptionByMd5(md5Raw: string): Promise<string | null> {
const md5 = this.normalizeEmojiMd5(md5Raw)
if (!md5) return null
if (this.emojiCaptionCache.has(md5)) {
return this.emojiCaptionCache.get(md5) ?? null
}
const pending = this.emojiCaptionPending.get(md5)
if (pending) return pending
const task = (async (): Promise<string | null> => {
try {
const nativeResult = await wcdbService.getEmoticonCaptionStrict(md5)
if (nativeResult.success) {
const nativeCaption = this.normalizeEmojiCaption(nativeResult.caption)
if (nativeCaption) return nativeCaption
}
} catch {
// ignore and return null
}
return null
})()
this.emojiCaptionPending.set(md5, task)
try {
const caption = await task
if (caption) {
this.emojiCaptionCache.set(md5, caption)
} else {
this.emojiCaptionCache.delete(md5)
}
return caption
} finally {
this.emojiCaptionPending.delete(md5)
}
}
private async hydrateEmojiCaptionsForMessages(
sessionId: string,
messages: any[],
control?: ExportTaskControl
): Promise<void> {
if (!Array.isArray(messages) || messages.length === 0) return
// 某些环境下游标行缺失 47 的 md5先按 localId 回填详情再做 caption 查询。
await this.backfillMediaFieldsFromMessageDetail(sessionId, messages, new Set([47]), control)
const unresolvedByUrl = new Map<string, any[]>()
const uniqueMd5s = new Set<string>()
let scanIndex = 0
for (const msg of messages) {
if ((scanIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (Number(msg?.localType) !== 47) continue
const content = String(msg?.content || '')
const normalizedMd5 = this.normalizeEmojiMd5(msg?.emojiMd5)
|| this.extractEmojiMd5(content)
|| this.extractLooseHexMd5(content)
const normalizedCdnUrl = this.normalizeEmojiCdnUrl(msg?.emojiCdnUrl || this.extractEmojiUrl(content))
if (normalizedCdnUrl) {
msg.emojiCdnUrl = normalizedCdnUrl
}
if (!normalizedMd5) {
if (normalizedCdnUrl) {
const bucket = unresolvedByUrl.get(normalizedCdnUrl) || []
bucket.push(msg)
unresolvedByUrl.set(normalizedCdnUrl, bucket)
} else {
msg.emojiMd5 = undefined
msg.emojiCaption = undefined
}
continue
}
msg.emojiMd5 = normalizedMd5
uniqueMd5s.add(normalizedMd5)
}
const unresolvedUrls = Array.from(unresolvedByUrl.keys())
if (unresolvedUrls.length > 0) {
await parallelLimit(unresolvedUrls, this.emojiCaptionLookupConcurrency, async (url, index) => {
if ((index & 0x0f) === 0) {
this.throwIfStopRequested(control)
}
const resolvedMd5 = await this.getEmojiMd5ByCdnUrl(url)
if (!resolvedMd5) return
const attached = unresolvedByUrl.get(url) || []
for (const msg of attached) {
msg.emojiMd5 = resolvedMd5
uniqueMd5s.add(resolvedMd5)
}
})
}
const md5List = Array.from(uniqueMd5s)
if (md5List.length > 0) {
await parallelLimit(md5List, this.emojiCaptionLookupConcurrency, async (md5, index) => {
if ((index & 0x0f) === 0) {
this.throwIfStopRequested(control)
}
await this.getEmojiCaptionByMd5(md5)
})
}
let assignIndex = 0
for (const msg of messages) {
if ((assignIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (Number(msg?.localType) !== 47) continue
const md5 = this.normalizeEmojiMd5(msg?.emojiMd5)
if (!md5) {
msg.emojiCaption = undefined
continue
}
const caption = this.emojiCaptionCache.get(md5) ?? null
msg.emojiCaption = caption || undefined
}
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = this.configService.get('myWxid') const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath') const dbPath = this.configService.get('dbPath')
@@ -1087,33 +1404,60 @@ class ExportService {
} }
/** /**
* 获取群成员群昵称。优先使用 DLL必要时回退到 `contact.chat_room.ext_buffer` 解析 * 获取群成员群昵称。后端结果为唯一业务真值,前端仅做冲突净化防串号
*/ */
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> { async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
const nicknameMap = new Map<string, string>()
try { try {
const dllResult = await wcdbService.getGroupNicknames(chatroomId) const dllResult = await wcdbService.getGroupNicknames(chatroomId)
if (dllResult.success && dllResult.nicknames) { if (!dllResult.success || !dllResult.nicknames) {
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames)) return new Map<string, string>()
} }
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
} catch (e) { } catch (e) {
console.error('getGroupNicknamesForRoom dll error:', e) console.error('getGroupNicknamesForRoom dll error:', e)
return new Map<string, string>()
}
}
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]))
}
} }
try { const trusted = new Map<string, string>()
const result = await wcdbService.getChatRoomExtBuffer(chatroomId) for (const [identity, nicknameSet] of buckets.entries()) {
if (!result.success || !result.extBuffer) { if (nicknameSet.size !== 1) continue
return nicknameMap trusted.set(identity, Array.from(nicknameSet)[0])
}
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
} }
return trusted
} }
private mergeGroupNicknameEntries( private mergeGroupNicknameEntries(
@@ -1363,8 +1707,6 @@ class ExportService {
const raw = String(rawValue || '').trim() const raw = String(rawValue || '').trim()
if (!raw) continue if (!raw) continue
set.add(raw) set.add(raw)
const cleaned = this.cleanAccountDirName(raw)
if (cleaned && cleaned !== raw) set.add(cleaned)
} }
return Array.from(set) return Array.from(set)
} }
@@ -1373,29 +1715,20 @@ class ExportService {
const idCandidates = this.buildGroupNicknameIdCandidates(candidates) const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
if (idCandidates.length === 0) return '' if (idCandidates.length === 0) return ''
let resolved = ''
for (const id of idCandidates) { for (const id of idCandidates) {
const exact = this.normalizeGroupNickname(groupNicknamesMap.get(id) || '') const normalizedId = this.normalizeGroupNicknameIdentity(id)
if (exact) return exact if (!normalizedId) continue
const lower = this.normalizeGroupNickname(groupNicknamesMap.get(id.toLowerCase()) || '') const candidateNickname = this.normalizeGroupNickname(groupNicknamesMap.get(normalizedId) || '')
if (lower) return lower if (!candidateNickname) continue
} if (!resolved) {
resolved = candidateNickname
for (const id of idCandidates) { continue
const lower = id.toLowerCase()
let found = ''
let matched = 0
for (const [key, value] of groupNicknamesMap.entries()) {
if (String(key || '').toLowerCase() !== lower) continue
const normalized = this.normalizeGroupNickname(value || '')
if (!normalized) continue
found = normalized
matched += 1
if (matched > 1) return ''
} }
if (matched === 1 && found) return found if (resolved !== candidateNickname) return ''
} }
return '' return resolved
} }
/** /**
@@ -1574,8 +1907,12 @@ class ExportService {
createTime?: number, createTime?: number,
myWxid?: string, myWxid?: string,
senderWxid?: string, senderWxid?: string,
isSend?: boolean isSend?: boolean,
emojiCaption?: string
): string | null { ): string | null {
if (!content && localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (!content) return null if (!content) return null
const normalizedContent = this.normalizeAppMessageContent(content) const normalizedContent = this.normalizeAppMessageContent(content)
@@ -1601,7 +1938,7 @@ class ExportService {
} }
case 42: return '[名片]' case 42: return '[名片]'
case 43: return '[视频]' case 43: return '[视频]'
case 47: return '[动画表情]' case 47: return this.formatEmojiSemanticText(emojiCaption)
case 48: { case 48: {
const normalized48 = this.normalizeAppMessageContent(content) const normalized48 = this.normalizeAppMessageContent(content)
const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName') const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName')
@@ -1711,7 +2048,8 @@ class ExportService {
voiceTranscript?: string, voiceTranscript?: string,
myWxid?: string, myWxid?: string,
senderWxid?: string, senderWxid?: string,
isSend?: boolean isSend?: boolean,
emojiCaption?: string
): string { ): string {
const safeContent = content || '' const safeContent = content || ''
@@ -1741,6 +2079,9 @@ class ExportService {
const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null
return seconds ? `[视频]${seconds}s` : '[视频]' return seconds ? `[视频]${seconds}s` : '[视频]'
} }
if (localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (localType === 48) { if (localType === 48) {
const normalized = this.normalizeAppMessageContent(safeContent) const normalized = this.normalizeAppMessageContent(safeContent)
const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName') const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName')
@@ -2481,7 +2822,7 @@ class ExportService {
case 3: return '[图片]' case 3: return '[图片]'
case 34: return '[语音消息]' case 34: return '[语音消息]'
case 43: return '[视频]' case 43: return '[视频]'
case 47: return '[动画表情]' case 47: return '[表情]'
case 49: case 49:
case 8: return title ? `[文件] ${title}` : '[文件]' case 8: return title ? `[文件] ${title}` : '[文件]'
case 17: return item.chatRecordDesc || title || '[聊天记录]' case 17: return item.chatRecordDesc || title || '[聊天记录]'
@@ -2622,7 +2963,7 @@ class ExportService {
displayContent = '[视频]' displayContent = '[视频]'
break break
case '47': case '47':
displayContent = '[动画表情]' displayContent = '[表情]'
break break
case '49': case '49':
displayContent = '[链接]' displayContent = '[链接]'
@@ -2935,7 +3276,17 @@ class ExportService {
return rendered.join('') return rendered.join('')
} }
private formatHtmlMessageText(content: string, localType: number, myWxid?: string, senderWxid?: string, isSend?: boolean): string { private formatHtmlMessageText(
content: string,
localType: number,
myWxid?: string,
senderWxid?: string,
isSend?: boolean,
emojiCaption?: string
): string {
if (!content && localType === 47) {
return this.formatEmojiSemanticText(emojiCaption)
}
if (!content) return '' if (!content) return ''
if (localType === 1) { if (localType === 1) {
@@ -2943,10 +3294,10 @@ class ExportService {
} }
if (localType === 34) { if (localType === 34) {
return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend) || '' return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend, emojiCaption) || ''
} }
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend) return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend, emojiCaption)
} }
private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null { private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null {
@@ -3014,6 +3365,7 @@ class ExportService {
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
includeVideoPoster?: boolean includeVideoPoster?: boolean
includeVoiceWithTranscript?: boolean includeVoiceWithTranscript?: boolean
imageDeepSearchOnMiss?: boolean
dirCache?: Set<string> dirCache?: Set<string>
} }
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
@@ -3021,7 +3373,14 @@ class ExportService {
// 图片消息 // 图片消息
if (localType === 3 && options.exportImages) { if (localType === 3 && options.exportImages) {
const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) const result = await this.exportImage(
msg,
sessionId,
mediaRootDir,
mediaRelativePrefix,
options.dirCache,
options.imageDeepSearchOnMiss !== false
)
if (result) { if (result) {
} }
return result return result
@@ -3067,7 +3426,8 @@ class ExportService {
sessionId: string, sessionId: string,
mediaRootDir: string, mediaRootDir: string,
mediaRelativePrefix: string, mediaRelativePrefix: string,
dirCache?: Set<string> dirCache?: Set<string>,
imageDeepSearchOnMiss = true
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
try { try {
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
@@ -3084,16 +3444,34 @@ class ExportService {
return null return null
} }
const missingRunCacheKey = this.getImageMissingRunCacheKey(
sessionId,
imageMd5,
imageDatName,
imageDeepSearchOnMiss
)
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
return null
}
const result = await imageDecryptService.decryptImage({ const result = await imageDecryptService.decryptImage({
sessionId, sessionId,
imageMd5, imageMd5,
imageDatName, imageDatName,
force: true, // 导出优先高清,失败再回退缩略图 force: true, // 导出优先高清,失败再回退缩略图
preferFilePath: true preferFilePath: true,
hardlinkOnly: !imageDeepSearchOnMiss
}) })
if (!result.success || !result.localPath) { if (!result.success || !result.localPath) {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`) console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
if (!imageDeepSearchOnMiss) {
console.log(`[Export] 未命中 hardlink已关闭缺图深度搜索→ 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null
}
// 尝试获取缩略图 // 尝试获取缩略图
const thumbResult = await imageDecryptService.resolveCachedImage({ const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId, sessionId,
@@ -3114,6 +3492,9 @@ class ExportService {
result.localPath = cachedThumb result.localPath = cachedThumb
} else { } else {
console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`) console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null return null
} }
} }
@@ -3487,8 +3868,11 @@ class ExportService {
*/ */
private extractEmojiMd5(content: string): string | undefined { private extractEmojiMd5(content: string): string | undefined {
if (!content) return undefined if (!content) return undefined
const match = /md5="([^"]+)"/i.exec(content) || /<md5>([^<]+)<\/md5>/i.exec(content) const match =
return match?.[1] /md5\s*=\s*['"]([a-fA-F0-9]{32})['"]/i.exec(content) ||
/md5\s*=\s*([a-fA-F0-9]{32})/i.exec(content) ||
/<md5>([a-fA-F0-9]{32})<\/md5>/i.exec(content)
return this.normalizeEmojiMd5(match?.[1]) || this.extractLooseHexMd5(content)
} }
private extractVideoMd5(content: string): string | undefined { private extractVideoMd5(content: string): string | undefined {
@@ -3777,6 +4161,7 @@ class ExportService {
let locationPoiname: string | undefined let locationPoiname: string | undefined
let locationLabel: string | undefined let locationLabel: string | undefined
let chatRecordList: any[] | undefined let chatRecordList: any[] | undefined
let emojiCaption: string | undefined
if (localType === 48 && content) { if (localType === 48 && content) {
const locationMeta = this.extractLocationMeta(content, localType) const locationMeta = this.extractLocationMeta(content, localType)
@@ -3788,22 +4173,30 @@ class ExportService {
} }
} }
if (localType === 47) {
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
emojiMd5 = this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) || undefined
const packedInfoRaw = String(row.packed_info || row.packedInfo || row.PackedInfo || '')
const reserved0Raw = String(row.reserved0 || row.Reserved0 || '')
const supplementalPayload = `${this.decodeMaybeCompressed(packedInfoRaw)}\n${this.decodeMaybeCompressed(reserved0Raw)}`
if (content) {
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
emojiMd5 = emojiMd5 || this.normalizeEmojiMd5(this.extractEmojiMd5(content))
}
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(supplementalPayload)
emojiMd5 = emojiMd5 || this.extractEmojiMd5(supplementalPayload) || this.extractLooseHexMd5(supplementalPayload)
}
if (collectMode === 'full' || collectMode === 'media-fast') { if (collectMode === 'full' || collectMode === 'media-fast') {
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。 // 优先复用游标返回的字段,缺失时再回退到 XML 解析。
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || undefined
videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined
if (localType === 3 && content) { if (localType === 3 && content) {
// 图片消息 // 图片消息
imageMd5 = imageMd5 || this.extractImageMd5(content) imageMd5 = imageMd5 || this.extractImageMd5(content)
imageDatName = imageDatName || this.extractImageDatName(content) imageDatName = imageDatName || this.extractImageDatName(content)
} else if (localType === 47 && content) {
// 动画表情
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
emojiMd5 = emojiMd5 || this.extractEmojiMd5(content)
} else if (localType === 43 && content) { } else if (localType === 43 && content) {
// 视频消息 // 视频消息
videoMd5 = videoMd5 || this.extractVideoMd5(content) videoMd5 = videoMd5 || this.extractVideoMd5(content)
@@ -3830,6 +4223,7 @@ class ExportService {
imageDatName, imageDatName,
emojiCdnUrl, emojiCdnUrl,
emojiMd5, emojiMd5,
emojiCaption,
videoMd5, videoMd5,
locationLat, locationLat,
locationLng, locationLng,
@@ -3898,7 +4292,7 @@ class ExportService {
const needsBackfill = rows.filter((msg) => { const needsBackfill = rows.filter((msg) => {
if (!targetMediaTypes.has(msg.localType)) return false if (!targetMediaTypes.has(msg.localType)) return false
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
if (msg.localType === 47) return !msg.emojiMd5 && !msg.emojiCdnUrl if (msg.localType === 47) return !msg.emojiMd5
if (msg.localType === 43) return !msg.videoMd5 if (msg.localType === 43) return !msg.videoMd5
return false return false
}) })
@@ -3915,9 +4309,16 @@ class ExportService {
if (!detail.success || !detail.message) return if (!detail.success || !detail.message) return
const row = detail.message as any const row = detail.message as any
const rawMessageContent = row.message_content ?? row.messageContent ?? row.msg_content ?? row.msgContent ?? '' const rawMessageContent = this.getRowField(row, [
const rawCompressContent = row.compress_content ?? row.compressContent ?? row.msg_compress_content ?? row.msgCompressContent ?? '' 'message_content', 'messageContent', 'msg_content', 'msgContent', 'strContent', 'content', 'WCDB_CT_message_content'
]) ?? ''
const rawCompressContent = this.getRowField(row, [
'compress_content', 'compressContent', 'msg_compress_content', 'msgCompressContent', 'WCDB_CT_compress_content'
]) ?? ''
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
const packedInfoRaw = this.getRowField(row, ['packed_info', 'packedInfo', 'PackedInfo', 'WCDB_CT_packed_info']) ?? ''
const reserved0Raw = this.getRowField(row, ['reserved0', 'Reserved0', 'WCDB_CT_Reserved0']) ?? ''
const supplementalPayload = `${this.decodeMaybeCompressed(String(packedInfoRaw || ''))}\n${this.decodeMaybeCompressed(String(reserved0Raw || ''))}`
if (msg.localType === 3) { if (msg.localType === 3) {
const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content)
@@ -3928,8 +4329,15 @@ class ExportService {
} }
if (msg.localType === 47) { if (msg.localType === 47) {
const emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || this.extractEmojiMd5(content) const emojiMd5 =
const emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || this.extractEmojiUrl(content) this.normalizeEmojiMd5(row.emoji_md5 || row.emojiMd5) ||
this.extractEmojiMd5(content) ||
this.extractEmojiMd5(supplementalPayload) ||
this.extractLooseHexMd5(supplementalPayload)
const emojiCdnUrl =
String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() ||
this.extractEmojiUrl(content) ||
this.extractEmojiUrl(supplementalPayload)
if (emojiMd5) msg.emojiMd5 = emojiMd5 if (emojiMd5) msg.emojiMd5 = emojiMd5
if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl
return return
@@ -4409,6 +4817,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)
const voiceMessages = options.exportVoiceAsText const voiceMessages = options.exportVoiceAsText
? allMessages.filter(msg => msg.localType === 34) ? allMessages.filter(msg => msg.localType === 34)
: [] : []
@@ -4511,6 +4921,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -4634,7 +5045,8 @@ class ExportService {
msg.createTime, msg.createTime,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
} }
@@ -4726,7 +5138,7 @@ class ExportService {
break break
case 47: case 47:
recordType = 5 // EMOJI recordType = 5 // EMOJI
recordContent = '[动画表情]' recordContent = '[表情]'
break break
default: default:
recordType = 0 recordType = 0
@@ -4936,6 +5348,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34) ? collected.rows.filter(msg => msg.localType === 34)
: [] : []
@@ -5010,6 +5424,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -5114,7 +5529,7 @@ class ExportService {
if (msg.localType === 34 && options.exportVoiceAsText) { if (msg.localType === 34 && options.exportVoiceAsText) {
content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
} else if (mediaItem) { } else if (mediaItem && msg.localType !== 47) {
content = mediaItem.relativePath content = mediaItem.relativePath
} else { } else {
content = this.parseMessageContent( content = this.parseMessageContent(
@@ -5124,7 +5539,8 @@ class ExportService {
undefined, undefined,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
} }
@@ -5185,6 +5601,12 @@ class ExportService {
senderAvatarKey: msg.senderUsername senderAvatarKey: msg.senderUsername
} }
if (msg.localType === 47) {
if (msg.emojiMd5) msgObj.emojiMd5 = msg.emojiMd5
if (msg.emojiCdnUrl) msgObj.emojiCdnUrl = msg.emojiCdnUrl
if (msg.emojiCaption) msgObj.emojiCaption = msg.emojiCaption
}
const platformMessageId = this.getExportPlatformMessageId(msg) const platformMessageId = this.getExportPlatformMessageId(msg)
if (platformMessageId) msgObj.platformMessageId = platformMessageId if (platformMessageId) msgObj.platformMessageId = platformMessageId
@@ -5420,6 +5842,9 @@ class ExportService {
if (message.linkTitle) compactMessage.linkTitle = message.linkTitle if (message.linkTitle) compactMessage.linkTitle = message.linkTitle
if (message.linkUrl) compactMessage.linkUrl = message.linkUrl if (message.linkUrl) compactMessage.linkUrl = message.linkUrl
if (message.linkThumb) compactMessage.linkThumb = message.linkThumb if (message.linkThumb) compactMessage.linkThumb = message.linkThumb
if (message.emojiMd5) compactMessage.emojiMd5 = message.emojiMd5
if (message.emojiCdnUrl) compactMessage.emojiCdnUrl = message.emojiCdnUrl
if (message.emojiCaption) compactMessage.emojiCaption = message.emojiCaption
if (message.finderTitle) compactMessage.finderTitle = message.finderTitle if (message.finderTitle) compactMessage.finderTitle = message.finderTitle
if (message.finderDesc) compactMessage.finderDesc = message.finderDesc if (message.finderDesc) compactMessage.finderDesc = message.finderDesc
if (message.finderUsername) compactMessage.finderUsername = message.finderUsername if (message.finderUsername) compactMessage.finderUsername = message.finderUsername
@@ -5650,6 +6075,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34) ? collected.rows.filter(msg => msg.localType === 34)
: [] : []
@@ -5850,6 +6277,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -6007,9 +6435,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
: (mediaItem?.relativePath : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent( || this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
@@ -6017,7 +6446,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
)) ))
// 转账消息:追加 "谁转账给谁" 信息 // 转账消息:追加 "谁转账给谁" 信息
@@ -6269,9 +6699,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
: (mediaItem?.relativePath : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent( || this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
@@ -6279,7 +6710,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
)) ))
let enrichedContentValue = contentValue let enrichedContentValue = contentValue
@@ -6468,6 +6900,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const voiceMessages = options.exportVoiceAsText const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34) ? collected.rows.filter(msg => msg.localType === 34)
: [] : []
@@ -6551,6 +6985,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -6635,9 +7070,10 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
: (mediaItem?.relativePath : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined)
|| this.formatPlainExportContent( || this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
@@ -6645,7 +7081,8 @@ class ExportService {
voiceTranscriptMap.get(this.getStableMessageKey(msg)), voiceTranscriptMap.get(this.getStableMessageKey(msg)),
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
)) ))
// 转账消息:追加 "谁转账给谁" 信息 // 转账消息:追加 "谁转账给谁" 信息
@@ -6828,6 +7265,8 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>() const senderUsernames = new Set<string>()
let senderScanIndex = 0 let senderScanIndex = 0
for (const msg of collected.rows) { for (const msg of collected.rows) {
@@ -6916,6 +7355,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -7046,7 +7486,8 @@ class ExportService {
msg.createTime, msg.createTime,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) || '') ) || '')
const src = this.getWeCloneSource(msg, typeName, mediaItem) const src = this.getWeCloneSource(msg, typeName, mediaItem)
const platformMessageId = this.getExportPlatformMessageId(msg) || '' const platformMessageId = this.getExportPlatformMessageId(msg) || ''
@@ -7255,6 +7696,8 @@ class ExportService {
} }
const totalMessages = collected.rows.length const totalMessages = collected.rows.length
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
const senderUsernames = new Set<string>() const senderUsernames = new Set<string>()
let senderScanIndex = 0 let senderScanIndex = 0
for (const msg of collected.rows) { for (const msg of collected.rows) {
@@ -7334,6 +7777,7 @@ class ExportService {
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true, includeVoiceWithTranscript: true,
exportVideos: options.exportVideos, exportVideos: options.exportVideos,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -7545,12 +7989,13 @@ class ExportService {
msg.localType, msg.localType,
cleanedMyWxid, cleanedMyWxid,
msg.senderUsername, msg.senderUsername,
msg.isSend msg.isSend,
msg.emojiCaption
) )
if (msg.localType === 34 && useVoiceTranscript) { if (msg.localType === 34 && useVoiceTranscript) {
textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]'
} }
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { if (mediaItem && msg.localType === 3) {
textContent = '' textContent = ''
} }
if (this.isTransferExportContent(textContent) && msg.content) { if (this.isTransferExportContent(textContent) && msg.content) {

View File

@@ -5,6 +5,7 @@ import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { chatService } from './chatService' import { chatService } from './chatService'
import type { Message } from './chatService' import type { Message } from './chatService'
import type { ChatStatistics } from './analyticsService'
export interface GroupChatInfo { export interface GroupChatInfo {
username: string username: string
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
total: number 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 { export interface GroupMemberMessagesPage {
messages: Message[] messages: Message[]
hasMore: boolean hasMore: boolean
@@ -257,34 +265,60 @@ class GroupAnalyticsService {
} }
/** /**
* 从 DLL 获取群成员群昵称 * 从后端获取群成员群昵称,并在前端进行唯一性净化防串号。
*/ */
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> { private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
const nicknameMap = new Map<string, string>()
try { try {
const dllResult = await wcdbService.getGroupNicknames(chatroomId) const dllResult = await wcdbService.getGroupNicknames(chatroomId)
if (dllResult.success && dllResult.nicknames) { if (!dllResult.success || !dllResult.nicknames) {
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames)) return new Map<string, string>()
} }
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
} catch (e) { } catch (e) {
console.error('getGroupNicknamesForRoom dll error:', e) console.error('getGroupNicknamesForRoom dll error:', e)
return new Map<string, string>()
} }
}
try { private normalizeGroupNicknameIdentity(value: string): string {
const result = await wcdbService.getChatRoomExtBuffer(chatroomId) const raw = String(value || '').trim()
if (!result.success || !result.extBuffer) { if (!raw) return ''
return nicknameMap 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( private mergeGroupNicknameEntries(
@@ -475,6 +509,16 @@ class GroupAnalyticsService {
return Array.from(set) 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 { private toNonNegativeInteger(value: unknown): number {
const parsed = Number(value) const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0 if (!Number.isFinite(parsed)) return 0
@@ -663,30 +707,23 @@ class GroupAnalyticsService {
} }
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string { private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
const idCandidates = this.buildIdCandidates(candidates) const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
if (idCandidates.length === 0) return '' if (idCandidates.length === 0) return ''
let resolved = ''
for (const id of idCandidates) { for (const id of idCandidates) {
const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '') const normalizedId = this.normalizeGroupNicknameIdentity(id)
if (exact) return exact if (!normalizedId) continue
} const candidateNickname = this.normalizeGroupNickname(groupNicknames.get(normalizedId) || '')
if (!candidateNickname) continue
for (const id of idCandidates) { if (!resolved) {
const lower = id.toLowerCase() resolved = candidateNickname
let found = '' continue
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 ''
} }
if (matched === 1 && found) return found if (resolved !== candidateNickname) return ''
} }
return '' return resolved
} }
private sanitizeWorksheetName(name: string): string { private sanitizeWorksheetName(name: string): string {
@@ -768,7 +805,12 @@ class GroupAnalyticsService {
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized 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 = [ const candidates = [
row.sender_username, row.sender_username,
row.senderUsername, row.senderUsername,
@@ -791,13 +833,33 @@ class GroupAnalyticsService {
if (normalizedValue) return normalizedValue 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 '' return ''
} }
private parseSingleMessageRow(row: Record<string, any>): Message | null { private parseSingleMessageRow(row: Record<string, any>): Message | null {
try { try {
const mapped = chatService.mapRowsToMessagesForApi([row]) 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 { } catch {
return null return null
} }
@@ -852,7 +914,7 @@ class GroupAnalyticsService {
if (rows.length === 0) break if (rows.length === 0) break
for (const row of rows) { 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)) { if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue continue
} }
@@ -958,7 +1020,7 @@ class GroupAnalyticsService {
const row = rows[index] const row = rows[index]
consumedRows += 1 consumedRows += 1
const senderFromRow = this.extractRowSenderUsername(row) const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
if (senderFromRow && !matchesTargetSender(senderFromRow)) { if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue 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( async exportGroupMemberMessages(
chatroomId: string, chatroomId: string,
memberUsername: string, memberUsername: string,

View File

@@ -101,6 +101,7 @@ class HttpService {
private server: http.Server | null = null private server: http.Server | null = null
private configService: ConfigService private configService: ConfigService
private port: number = 5031 private port: number = 5031
private host: string = '127.0.0.1'
private running: boolean = false private running: boolean = false
private connections: Set<import('net').Socket> = new Set() private connections: Set<import('net').Socket> = new Set()
private messagePushClients: Set<http.ServerResponse> = new Set() private messagePushClients: Set<http.ServerResponse> = new Set()
@@ -114,12 +115,13 @@ class HttpService {
/** /**
* 启动 HTTP 服务 * 启动 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) { if (this.running && this.server) {
return { success: true, port: this.port } return { success: true, port: this.port }
} }
this.port = port this.port = port
this.host = host
return new Promise((resolve) => { return new Promise((resolve) => {
this.server = http.createServer((req, res) => this.handleRequest(req, res)) 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.running = true
this.startMessagePushHeartbeat() 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 }) resolve({ success: true, port: this.port })
}) })
}) })
@@ -225,7 +227,7 @@ class HttpService {
} }
getMessagePushStreamUrl(): string { 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 { broadcastMessagePush(payload: Record<string, unknown>): void {
@@ -246,49 +248,116 @@ class HttpService {
} }
} }
/** async autoStart(): Promise<void> {
* 处理 HTTP 请求 const enabled = this.configService.get('httpApiEnabled')
*/ if (enabled) {
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> { const port = Number(this.configService.get('httpApiPort')) || 5031
// 设置 CORS 头 const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1'
res.setHeader('Access-Control-Allow-Origin', '*') try {
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') await this.start(port, host)
res.setHeader('Access-Control-Allow-Headers', 'Content-Type') console.log(`[HttpService] Auto-started on port ${port}`)
} catch (err) {
if (req.method === 'OPTIONS') { console.error('[HttpService] Auto-start failed:', err)
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')
} }
} 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 { private startMessagePushHeartbeat(): void {
if (this.messagePushHeartbeatTimer) return if (this.messagePushHeartbeatTimer) return
this.messagePushHeartbeatTimer = setInterval(() => { this.messagePushHeartbeatTimer = setInterval(() => {
@@ -895,7 +964,7 @@ class HttpService {
parsedContent: msg.parsedContent, parsedContent: msg.parsedContent,
mediaType: media?.kind, mediaType: media?.kind,
mediaFileName: media?.fileName, 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 mediaLocalPath: media?.fullPath
} }
} }
@@ -1017,13 +1086,31 @@ class HttpService {
} }
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string { private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
if (!sender) return '' const key = String(sender || '').trim().toLowerCase()
const cleaned = this.normalizeAccountId(sender) if (!key) return ''
return groupNicknamesMap.get(sender) return groupNicknamesMap.get(key) || ''
|| groupNicknamesMap.get(sender.toLowerCase()) }
|| groupNicknamesMap.get(cleaned)
|| groupNicknamesMap.get(cleaned.toLowerCase()) 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( private resolveChatLabSenderInfo(
@@ -1094,21 +1181,7 @@ class HttpService {
try { try {
const result = await wcdbService.getGroupNicknames(talkerId) const result = await wcdbService.getGroupNicknames(talkerId)
if (result.success && result.nicknames) { if (result.success && result.nicknames) {
groupNicknamesMap = new Map() groupNicknamesMap = this.buildTrustedGroupNicknameMap(result.nicknames)
for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) {
const memberId = String(memberIdRaw || '').trim()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
groupNicknamesMap.set(memberId, nickname)
groupNicknamesMap.set(memberId.toLowerCase(), nickname)
const cleaned = this.normalizeAccountId(memberId)
if (cleaned) {
groupNicknamesMap.set(cleaned, nickname)
groupNicknamesMap.set(cleaned.toLowerCase(), nickname)
}
}
} }
} catch (e) { } catch (e) {
console.error('[HttpService] Failed to get group nicknames:', e) console.error('[HttpService] Failed to get group nicknames:', e)
@@ -1161,7 +1234,7 @@ class HttpService {
type: this.mapMessageType(msg.localType, msg), type: this.mapMessageType(msg.localType, msg),
content: this.getMessageContent(msg), content: this.getMessageContent(msg),
platformMessageId: msg.serverId ? String(msg.serverId) : undefined, 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 子类型 * 映射 Type 49 子类型
*/ */
private mapType49(msg: Message): number { private mapType49(msg: Message): number {
const xmlType = msg.xmlType const xmlType = this.resolveType49Subtype(msg)
switch (xmlType) { switch (xmlType) {
case '5': // 链接 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 { private getMessageContent(msg: Message): string | null {
if (msg.localType === 49) {
return this.getType49Content(msg)
}
// 优先使用已解析的内容 // 优先使用已解析的内容
if (msg.parsedContent) { if (msg.parsedContent) {
return msg.parsedContent return msg.parsedContent
@@ -1276,7 +1436,7 @@ class HttpService {
case 48: case 48:
return '[位置]' return '[位置]'
case 49: case 49:
return msg.linkTitle || msg.fileName || '[消息]' return this.getType49Content(msg)
default: default:
return msg.rawContent || null return msg.rawContent || null
} }
@@ -1302,4 +1462,3 @@ class HttpService {
} }
export const httpService = new HttpService() export const httpService = new HttpService()

View File

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

View File

@@ -1,7 +1,7 @@
import { app } from 'electron' import { app } from 'electron'
import { join } from 'path' import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs' 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 { promisify } from 'util'
import { createRequire } from 'module'; import { createRequire } from 'module';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
@@ -45,33 +45,104 @@ export class KeyServiceLinux {
onStatus?: (message: string, level: number) => void onStatus?: (message: string, level: number) => void
): Promise<DbKeyResult> { ): Promise<DbKeyResult> {
try { try {
// 1. 构造一个包含常用系统命令路径的环境变量,防止打包后找不到命令
const envWithPath = {
...process.env,
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
};
onStatus?.('正在尝试结束当前微信进程...', 0) 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)) await new Promise(r => setTimeout(r, 1000))
onStatus?.('正在尝试拉起微信...', 0) onStatus?.('正在尝试拉起微信...', 0)
const startCmds = [
'nohup wechat >/dev/null 2>&1 &', const cleanEnv = { ...process.env };
'nohup wechat-bin >/dev/null 2>&1 &', delete cleanEnv.ELECTRON_RUN_AS_NODE;
'nohup xwechat >/dev/null 2>&1 &' 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) onStatus?.('等待微信进程出现...', 0)
let pid = 0 let pid = 0
for (let i = 0; i < 15; i++) { // 最多等 15 秒 for (let i = 0; i < 15; i++) { // 最多等 15 秒
await new Promise(r => setTimeout(r, 1000)) 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) try {
if (pids.length > 0) { const { stdout } = await execAsync('pidof wechat wechat-bin xwechat', { env: envWithPath });
pid = parseInt(pids[0], 10) const pids = stdout.trim().split(/\s+/).filter(p => p);
break 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) { if (!pid) {
const err = '未能自动启动微信,手动启动并登录。' const err = '未能自动启动微信,或获取PID失败请查看控制台日志或手动启动并登录。'
onStatus?.(err, 2) onStatus?.(err, 2)
return { success: false, error: err } return { success: false, error: err }
} }
@@ -82,6 +153,7 @@ export class KeyServiceLinux {
return await this.getDbKey(pid, onStatus) return await this.getDbKey(pid, onStatus)
} catch (err: any) { } catch (err: any) {
console.error('[Debug] 自动获取流程彻底崩溃:', err);
const errMsg = '自动获取微信 PID 失败: ' + err.message const errMsg = '自动获取微信 PID 失败: ' + err.message
onStatus?.(errMsg, 2) onStatus?.(errMsg, 2)
return { success: false, error: errMsg } return { success: false, error: errMsg }

View File

@@ -389,7 +389,7 @@ export class KeyServiceMac {
`set timeoutSec to ${timeoutSec}`, `set timeoutSec to ${timeoutSec}`,
'try', 'try',
'with timeout of timeoutSec seconds', 'with timeout of timeoutSec seconds',
'set outText to do shell script cmd with administrator privileges', 'set outText to do shell script (cmd & " 2>&1") with administrator privileges',
'end timeout', 'end timeout',
'return "WF_OK::" & outText', 'return "WF_OK::" & outText',
'on error errMsg number errNum partial result pr', 'on error errMsg number errNum partial result pr',
@@ -935,10 +935,17 @@ export class KeyServiceMac {
private resolveXwechatRootFromPath(accountPath?: string): string | null { private resolveXwechatRootFromPath(accountPath?: string): string | null {
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '') const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
if (!normalized) return null if (!normalized) return null
// 旧路径xwechat_files
const marker = '/xwechat_files' const marker = '/xwechat_files'
const markerIdx = normalized.indexOf(marker) const markerIdx = normalized.indexOf(marker)
if (markerIdx < 0) return null if (markerIdx >= 0) return normalized.slice(0, markerIdx + marker.length)
return normalized.slice(0, markerIdx + marker.length)
// 新路径(微信 4.0.5+Application Support/com.tencent.xinWeChat/2.0b4.0.9
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/)
if (newMarkerMatch) return newMarkerMatch[1]
return null
} }
private pushAccountIdCandidates(candidates: string[], value?: string): void { private pushAccountIdCandidates(candidates: string[], value?: string): void {
@@ -1096,6 +1103,16 @@ export class KeyServiceMac {
candidates.add(`${base}/app_data/net/kvcomm`) candidates.add(`${base}/app_data/net/kvcomm`)
} }
// 微信 4.0.5+ 新路径推导:版本目录同级的 net/kvcomm
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/)
if (newMarkerMatch) {
const versionBase = newMarkerMatch[1]
candidates.add(`${versionBase}/net/kvcomm`)
// 上级目录也尝试
const parentBase = versionBase.replace(/\/[^\/]+$/, '')
candidates.add(`${parentBase}/net/kvcomm`)
}
let cursor = accountPath let cursor = accountPath
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
candidates.add(join(cursor, 'net', 'kvcomm')) candidates.add(join(cursor, 'net', 'kvcomm'))

View File

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

View File

@@ -27,6 +27,17 @@ export interface SnsMedia {
livePhoto?: SnsLivePhoto livePhoto?: SnsLivePhoto
} }
export interface SnsLocation {
latitude?: number
longitude?: number
city?: string
country?: string
poiName?: string
poiAddress?: string
poiAddressName?: string
label?: string
}
export interface SnsPost { export interface SnsPost {
id: string id: string
tid?: string // 数据库主键(雪花 ID用于精确删除 tid?: string // 数据库主键(雪花 ID用于精确删除
@@ -39,6 +50,7 @@ export interface SnsPost {
media: SnsMedia[] media: SnsMedia[]
likes: string[] 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 }[] }[] 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 rawXml?: string
linkTitle?: string linkTitle?: string
linkUrl?: string linkUrl?: string
@@ -287,6 +299,17 @@ function parseCommentsFromXml(xml: string): ParsedCommentItem[] {
return comments 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 { class SnsService {
private configService: ConfigService private configService: ConfigService
private contactCache: ContactCacheService private contactCache: ContactCacheService
@@ -647,6 +670,110 @@ class SnsService {
return { media, videoKey } 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 { private getSnsCacheDir(): string {
const cachePath = this.configService.getCacheBasePath() const cachePath = this.configService.getCacheBasePath()
const snsCacheDir = join(cachePath, 'sns_cache') const snsCacheDir = join(cachePath, 'sns_cache')
@@ -948,7 +1075,12 @@ class SnsService {
const enrichedTimeline = result.timeline.map((post: any) => { const enrichedTimeline = result.timeline.map((post: any) => {
const contact = this.contactCache.get(post.username) const contact = this.contactCache.get(post.username)
const isVideoPost = post.type === 15 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) => ({ const fixedMedia = (post.media || []).map((m: any) => ({
url: fixSnsUrl(m.url, m.token, isVideoPost), url: fixSnsUrl(m.url, m.token, isVideoPost),
@@ -971,7 +1103,6 @@ class SnsService {
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析 // 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
const dllComments: any[] = post.comments || [] const dllComments: any[] = post.comments || []
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0) const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
const rawXml = post.rawXml || ''
let finalComments: any[] let finalComments: any[]
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) { if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
@@ -990,7 +1121,8 @@ class SnsService {
avatarUrl: contact?.avatarUrl, avatarUrl: contact?.avatarUrl,
nickname: post.nickname || contact?.displayName || post.username, nickname: post.nickname || contact?.displayName || post.username,
media: fixedMedia, media: fixedMedia,
comments: finalComments comments: finalComments,
location
} }
}) })
@@ -1346,6 +1478,7 @@ class SnsService {
})), })),
likes: p.likes, likes: p.likes,
comments: p.comments, comments: p.comments,
location: p.location,
linkTitle: (p as any).linkTitle, linkTitle: (p as any).linkTitle,
linkUrl: (p as any).linkUrl linkUrl: (p as any).linkUrl
})) }))
@@ -1397,6 +1530,7 @@ class SnsService {
})), })),
likes: post.likes, likes: post.likes,
comments: post.comments, comments: post.comments,
location: post.location,
likesDetail, likesDetail,
commentsDetail, commentsDetail,
linkTitle: (post as any).linkTitle, linkTitle: (post as any).linkTitle,
@@ -1479,6 +1613,27 @@ class SnsService {
const ch = name.charAt(0) const ch = name.charAt(0)
return escapeHtml(ch || '?') 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 = '' let filterInfo = ''
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" ` if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
@@ -1502,6 +1657,10 @@ class SnsService {
const linkHtml = post.linkTitle && post.linkUrl 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>` ? `<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 const likesHtml = post.likes.length > 0
? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>` ? `<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="body">
<div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div> <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>` : ''} ${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''}
${locationHtml}
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''} ${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
${linkHtml} ${linkHtml}
${likesHtml} ${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} .nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px}
.tm{font-size:12px;color:var(--t3)} .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} .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} .mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px}

View File

@@ -75,6 +75,14 @@ export class VoiceTranscribeService {
if (candidates.length === 0) { if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) 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 return env
@@ -273,8 +281,20 @@ export class VoiceTranscribeService {
}) })
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) })) worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
worker.on('exit', (code: number) => { worker.on('exit', (code: number | null, signal: string | null) => {
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` }) 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) { } catch (error) {

View File

@@ -68,6 +68,8 @@ export class WcdbCore {
private wcdbListMediaDbs: any = null private wcdbListMediaDbs: any = null
private wcdbGetMessageById: any = null private wcdbGetMessageById: any = null
private wcdbGetEmoticonCdnUrl: any = null private wcdbGetEmoticonCdnUrl: any = null
private wcdbGetEmoticonCaption: any = null
private wcdbGetEmoticonCaptionStrict: any = null
private wcdbGetDbStatus: any = null private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null private wcdbGetVoiceData: any = null
private wcdbGetVoiceDataBatch: any = null private wcdbGetVoiceDataBatch: any = null
@@ -124,6 +126,10 @@ export class WcdbCore {
this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true) this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true)
} }
getLastInitError(): string | null {
return lastDllInitError
}
setLogEnabled(enabled: boolean): void { setLogEnabled(enabled: boolean): void {
this.logEnabled = enabled this.logEnabled = enabled
this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true) 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 { private getDllPath(): string {
const isMac = process.platform === 'darwin' const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux' const isLinux = process.platform === 'linux'
const isArm64 = process.arch === 'arm64'
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll' 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 const envDllPath = process.env.WCDB_DLL_PATH
if (envDllPath && envDllPath.length > 0) { if (envDllPath && envDllPath.length > 0) {
@@ -296,6 +303,20 @@ export class WcdbCore {
return candidates[0] || libName return candidates[0] || libName
} }
private formatInitProtectionError(code: number): string {
const messages: Record<number, string> = {
'-3001': '未找到数据库目录 (db_storage),请确认已选择正确的微信数据目录(应包含以 wxid_ 开头的子文件夹)',
'-3002': '未找到 session.db 文件,请确认微信已登录并且数据目录完整',
'-3003': '数据库句柄无效,请重试',
'-3004': '恢复数据库连接失败,请重试',
'-2301': '动态库加载失败,请检查安装是否完整',
'-2302': 'WCDB 初始化异常,请重试',
'-2303': 'WCDB 未能成功初始化',
}
const msg = messages[String(code) as keyof typeof messages]
return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}`
}
private isLogEnabled(): boolean { private isLogEnabled(): boolean {
// 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志 // 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志
if (process.env.WCDB_LOG_ENABLED === '1') return true if (process.env.WCDB_LOG_ENABLED === '1') return true
@@ -469,6 +490,49 @@ export class WcdbCore {
} }
} catch { } } catch { }
} }
// 兜底:向上查找 db_storage最多 2 级),处理用户选择了子目录的情况
try {
let parent = normalized
for (let i = 0; i < 2; i++) {
const up = join(parent, '..')
if (up === parent) break
parent = up
const candidateUp = join(parent, 'db_storage')
if (existsSync(candidateUp)) return candidateUp
if (wxid) {
const viaWxidUp = join(parent, wxid, 'db_storage')
if (existsSync(viaWxidUp)) return viaWxidUp
}
}
} catch { }
// 兜底:递归搜索 basePath 下的 db_storage 目录(最多 3 层深)
try {
const found = this.findDbStorageRecursive(normalized, 3)
if (found) return found
} catch { }
return null
}
private findDbStorageRecursive(dir: string, maxDepth: number): string | null {
if (maxDepth <= 0) return null
try {
const entries = readdirSync(dir)
for (const entry of entries) {
if (entry.toLowerCase() === 'db_storage') {
const candidate = join(dir, entry)
try { if (statSync(candidate).isDirectory()) return candidate } catch { }
}
}
for (const entry of entries) {
const entryPath = join(dir, entry)
try {
if (statSync(entryPath).isDirectory()) {
const found = this.findDbStorageRecursive(entryPath, maxDepth - 1)
if (found) return found
}
} catch { }
}
} catch { }
return null return null
} }
@@ -617,11 +681,13 @@ export class WcdbCore {
} }
} }
this.writeLog(`[bootstrap] koffi.load begin path=${dllPath}`, true)
this.lib = this.koffi.load(dllPath) this.lib = this.koffi.load(dllPath)
this.writeLog('[bootstrap] koffi.load ok', true)
// InitProtection (Added for security) // InitProtection (Added for security)
try { try {
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)') this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)')
// 尝试多个可能的资源路径 // 尝试多个可能的资源路径
const resourcePaths = [ const resourcePaths = [
@@ -634,26 +700,40 @@ export class WcdbCore {
].filter(Boolean) ].filter(Boolean)
let protectionOk = false 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) { for (const resPath of resourcePaths) {
try { try {
// this.writeLog(`[bootstrap] InitProtection call path=${resPath}`, true)
protectionOk = this.wcdbInitProtection(resPath) protectionCode = Number(this.wcdbInitProtection(resPath))
if (protectionOk) { if (protectionCode === 0) {
// protectionOk = true
break break
} }
if (bestFailCode === null || scoreFailCode(protectionCode) < scoreFailCode(bestFailCode)) {
bestFailCode = protectionCode
}
this.writeLog(`[bootstrap] InitProtection rc=${protectionCode} path=${resPath}`, true)
} catch (e) { } catch (e) {
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e) this.writeLog(`[bootstrap] InitProtection exception path=${resPath}: ${String(e)}`, true)
} }
} }
if (!protectionOk) { if (!protectionOk) {
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定') const finalCode = bestFailCode ?? protectionCode
// this.writeLog('InitProtection 失败,继续运行') lastDllInitError = this.formatInitProtectionError(finalCode)
// 不返回 false允许继续运行 this.writeLog(`[bootstrap] InitProtection failed finalCode=${finalCode}`, true)
return false
} }
} catch (e) { } 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 +932,22 @@ export class WcdbCore {
// wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url) // 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)') 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) // 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)') this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)')
@@ -1055,7 +1151,7 @@ export class WcdbCore {
const initResult = this.wcdbInit() const initResult = this.wcdbInit()
if (initResult !== 0) { if (initResult !== 0) {
console.error('WCDB 初始化失败:', initResult) console.error('WCDB 初始化失败:', initResult)
lastDllInitError = `初始化失败(错误码: ${initResult}` lastDllInitError = this.formatInitProtectionError(initResult)
return false return false
} }
@@ -1066,14 +1162,7 @@ export class WcdbCore {
const errorMsg = e instanceof Error ? e.message : String(e) const errorMsg = e instanceof Error ? e.message : String(e)
console.error('WCDB 初始化异常:', errorMsg) console.error('WCDB 初始化异常:', errorMsg)
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true) this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
lastDllInitError = errorMsg lastDllInitError = this.formatInitProtectionError(-2302)
// 检查是否是常见的 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 位版本的应用程序。'
}
return false return false
} }
} }
@@ -1100,8 +1189,7 @@ export class WcdbCore {
if (!this.initialized) { if (!this.initialized) {
const initOk = await this.initialize() const initOk = await this.initialize()
if (!initOk) { if (!initOk) {
// 返回更详细的错误信息,帮助用户诊断问题 const detailedError = lastDllInitError || this.formatInitProtectionError(-2303)
const detailedError = lastDllInitError || 'WCDB 初始化失败'
return { success: false, error: detailedError } return { success: false, error: detailedError }
} }
} }
@@ -1111,7 +1199,7 @@ export class WcdbCore {
this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
if (!dbStoragePath || !existsSync(dbStoragePath)) { if (!dbStoragePath || !existsSync(dbStoragePath)) {
return { success: false, error: `数据库目录不存在: ${dbPath}` } return { success: false, error: this.formatInitProtectionError(-3001) }
} }
// 递归查找 session.db // 递归查找 session.db
@@ -1119,7 +1207,7 @@ export class WcdbCore {
this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`) this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`)
if (!sessionDbPath) { if (!sessionDbPath) {
return { success: false, error: `未找到 session.db 文件` } return { success: false, error: this.formatInitProtectionError(-3002) }
} }
// 分配输出参数内存 // 分配输出参数内存
@@ -1128,17 +1216,13 @@ export class WcdbCore {
if (result !== 0) { if (result !== 0) {
await this.printLogs() 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}`) this.writeLog(`testConnection openAccount failed code=${result}`)
return { success: false, error: `${errorMsg} (错误码: ${result})` } return { success: false, error: this.formatInitProtectionError(result) }
} }
const tempHandle = handleOut[0] const tempHandle = handleOut[0]
if (tempHandle <= 0) { if (tempHandle <= 0) {
return { success: false, error: '无效的数据库句柄' } return { success: false, error: this.formatInitProtectionError(-3003) }
} }
// 测试成功:使用 shutdown 清理资源(包括测试句柄) // 测试成功:使用 shutdown 清理资源(包括测试句柄)
@@ -1167,7 +1251,7 @@ export class WcdbCore {
} catch (e) { } catch (e) {
console.error('测试连接异常:', e) console.error('测试连接异常:', e)
this.writeLog(`testConnection exception: ${String(e)}`) this.writeLog(`testConnection exception: ${String(e)}`)
return { success: false, error: String(e) } return { success: false, error: this.formatInitProtectionError(-3004) }
} }
} }
@@ -1359,6 +1443,7 @@ export class WcdbCore {
*/ */
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> { async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
try { try {
lastDllInitError = null
if (!this.initialized) { if (!this.initialized) {
const initOk = await this.initialize() const initOk = await this.initialize()
if (!initOk) return false if (!initOk) return false
@@ -1386,6 +1471,7 @@ export class WcdbCore {
if (!dbStoragePath || !existsSync(dbStoragePath)) { if (!dbStoragePath || !existsSync(dbStoragePath)) {
console.error('数据库目录不存在:', dbPath) console.error('数据库目录不存在:', dbPath)
this.writeLog(`open failed: dbStorage not found for ${dbPath}`) this.writeLog(`open failed: dbStorage not found for ${dbPath}`)
lastDllInitError = this.formatInitProtectionError(-3001)
return false return false
} }
@@ -1394,6 +1480,7 @@ export class WcdbCore {
if (!sessionDbPath) { if (!sessionDbPath) {
console.error('未找到 session.db 文件') console.error('未找到 session.db 文件')
this.writeLog('open failed: session.db not found') this.writeLog('open failed: session.db not found')
lastDllInitError = this.formatInitProtectionError(-3002)
return false return false
} }
@@ -1404,11 +1491,13 @@ export class WcdbCore {
console.error('打开数据库失败:', result) console.error('打开数据库失败:', result)
await this.printLogs() await this.printLogs()
this.writeLog(`open failed: openAccount code=${result}`) this.writeLog(`open failed: openAccount code=${result}`)
lastDllInitError = this.formatInitProtectionError(result)
return false return false
} }
const handle = handleOut[0] const handle = handleOut[0]
if (handle <= 0) { if (handle <= 0) {
lastDllInitError = this.formatInitProtectionError(-3003)
return false return false
} }
@@ -1418,6 +1507,7 @@ export class WcdbCore {
this.currentWxid = wxid this.currentWxid = wxid
this.currentDbStoragePath = dbStoragePath this.currentDbStoragePath = dbStoragePath
this.initialized = true this.initialized = true
lastDllInitError = null
if (this.wcdbSetMyWxid && wxid) { if (this.wcdbSetMyWxid && wxid) {
try { try {
this.wcdbSetMyWxid(this.handle, wxid) this.wcdbSetMyWxid(this.handle, wxid)
@@ -1435,6 +1525,7 @@ export class WcdbCore {
} catch (e) { } catch (e) {
console.error('打开数据库异常:', e) console.error('打开数据库异常:', e)
this.writeLog(`open exception: ${String(e)}`) this.writeLog(`open exception: ${String(e)}`)
lastDllInitError = this.formatInitProtectionError(-3004)
return false return false
} }
} }
@@ -2700,6 +2791,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 }> { async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
try { try {

View File

@@ -164,6 +164,10 @@ export class WcdbService {
return this.callWorker('open', { dbPath, hexKey, wxid }) 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 }) 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': case 'open':
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid) result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
break break
case 'getLastInitError':
result = core.getLastInitError()
break
case 'close': case 'close':
core.close() core.close()
result = { success: true } result = { success: true }
@@ -170,6 +173,12 @@ if (parentPort) {
case 'getEmoticonCdnUrl': case 'getEmoticonCdnUrl':
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5) result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
break break
case 'getEmoticonCaption':
result = await core.getEmoticonCaption(payload.dbPath, payload.md5)
break
case 'getEmoticonCaptionStrict':
result = await core.getEmoticonCaptionStrict(payload.md5)
break
case 'listMessageDbs': case 'listMessageDbs':
result = await core.listMessageDbs() result = await core.listMessageDbs()
break break

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "weflow", "name": "weflow",
"version": "2.1.0", "version": "4.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "weflow", "name": "weflow",
"version": "2.1.0", "version": "4.2.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"echarts": "^5.5.1", "echarts": "^5.5.1",
@@ -11062,4 +11062,4 @@
} }
} }
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "weflow", "name": "weflow",
"version": "2.1.0", "version": "4.2.0",
"description": "WeFlow", "description": "WeFlow",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": { "author": {
@@ -95,12 +95,18 @@
"linux": { "linux": {
"icon": "public/icon.png", "icon": "public/icon.png",
"target": [ "target": [
"deb", "appimage",
"tar.gz" "tar.gz"
], ],
"category": "Utility", "category": "Utility",
"executableName": "weflow", "executableName": "weflow",
"synopsis": "WeFlow for Linux" "synopsis": "WeFlow for Linux",
"extraFiles": [
{
"from": "resources/linux/install.sh",
"to": "install.sh"
}
]
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
@@ -172,4 +178,4 @@
], ],
"icon": "resources/icon.icns" "icon": "resources/icon.icns"
} }
} }

BIN
resources/arm64/WCDB.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
resources/libwcdb_api.so Executable file

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.

Binary file not shown.

Binary file not shown.

BIN
resources/xkey_helper_macos Normal file

Binary file not shown.

View File

@@ -104,6 +104,44 @@ function App() {
// 数据收集同意状态 // 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false) 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(() => { useEffect(() => {
if (location.pathname !== '/settings') { if (location.pathname !== '/settings') {
settingsBackgroundRef.current = location settingsBackgroundRef.current = location
@@ -432,6 +470,8 @@ function App() {
checkLock() checkLock()
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow]) }, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
// 独立协议窗口 // 独立协议窗口
if (isAgreementWindow) { if (isAgreementWindow) {
return <AgreementPage /> return <AgreementPage />
@@ -614,6 +654,33 @@ function App() {
</div> </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 <UpdateDialog
open={showUpdateDialog} open={showUpdateDialog}

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo, useEffect } from 'react' import React, { useState, useMemo, useEffect } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react' import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
import { SnsPost, SnsLinkCardData } from '../../types/sns' import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
import { Avatar } from '../Avatar' import { Avatar } from '../Avatar'
import { SnsMediaGrid } from './SnsMediaGrid' import { SnsMediaGrid } from './SnsMediaGrid'
import { getEmojiPath } from 'wechat-emojis' 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 SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
const [thumbFailed, setThumbFailed] = useState(false) const [thumbFailed, setThumbFailed] = useState(false)
const hostname = useMemo(() => { const hostname = useMemo(() => {
@@ -254,6 +278,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const linkCard = buildLinkCardData(post) 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 hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
const showMediaGrid = post.media.length > 0 && !showLinkCard 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> <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 && ( {showLinkCard && linkCard && (
<SnsLinkCard card={linkCard} /> <SnsLinkCard card={linkCard} />
)} )}

View File

@@ -625,7 +625,7 @@
.bubble-content { .bubble-content {
background: var(--primary-gradient); background: var(--primary-gradient);
color: #fff; color: var(--on-primary);
border-radius: 18px 18px 4px 18px; border-radius: 18px 18px 4px 18px;
padding: 10px 14px; padding: 10px 14px;
font-size: 14px; font-size: 14px;
@@ -1962,7 +1962,7 @@
.bubble-content { .bubble-content {
background: var(--primary); background: var(--primary);
color: white; color: var(--on-primary);
border-radius: 18px 18px 4px 18px; border-radius: 18px 18px 4px 18px;
} }
} }
@@ -2420,7 +2420,6 @@
background: rgba(0, 0, 0, 0.04); background: rgba(0, 0, 0, 0.04);
border-left: 2px solid var(--primary); border-left: 2px solid var(--primary);
padding: 6px 10px; padding: 6px 10px;
margin-bottom: 8px;
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: 13px;
@@ -2454,15 +2453,15 @@
// 自己发送的消息中的引用样式 // 自己发送的消息中的引用样式
.message-bubble.sent .quoted-message { .message-bubble.sent .quoted-message {
background: rgba(255, 255, 255, 0.15); background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
border-left-color: rgba(255, 255, 255, 0.5); border-left-color: color-mix(in srgb, var(--on-primary) 36%, var(--primary));
.quoted-sender { .quoted-sender {
color: rgba(255, 255, 255, 0.9); color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
} }
.quoted-text { .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 { .bubble-content {
-webkit-app-region: no-drag; -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 completed: boolean
} }
type QuoteLayout = configService.QuoteLayout
const GLOBAL_MSG_PER_SESSION_LIMIT = 10 const GLOBAL_MSG_PER_SESSION_LIMIT = 10
const GLOBAL_MSG_SEED_LIMIT = 120 const GLOBAL_MSG_SEED_LIMIT = 120
const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3 const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3
@@ -7556,6 +7558,7 @@ function MessageBubble({
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined) const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
const [senderName, setSenderName] = useState<string | undefined>(undefined) const [senderName, setSenderName] = useState<string | undefined>(undefined)
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined) const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
const [quoteLayout, setQuoteLayout] = useState<QuoteLayout>('quote-top')
const senderProfileRequestSeqRef = useRef(0) const senderProfileRequestSeqRef = useRef(0)
const [emojiError, setEmojiError] = useState(false) const [emojiError, setEmojiError] = useState(false)
const [emojiLoading, setEmojiLoading] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false)
@@ -7611,6 +7614,12 @@ function MessageBubble({
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([]) const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
const voiceAutoDecryptTriggered = useRef(false) const voiceAutoDecryptTriggered = useRef(false)
const [systemAlert, setSystemAlert] = useState<{
title: string;
message: React.ReactNode;
} | null>(null)
// 转账消息双方名称 // 转账消息双方名称
const [transferPayerName, setTransferPayerName] = useState<string | undefined>(undefined) const [transferPayerName, setTransferPayerName] = useState<string | undefined>(undefined)
const [transferReceiverName, setTransferReceiverName] = useState<string | undefined>(undefined) const [transferReceiverName, setTransferReceiverName] = useState<string | undefined>(undefined)
@@ -8290,9 +8299,9 @@ function MessageBubble({
} }
const result = await window.electronAPI.chat.getVoiceTranscript( const result = await window.electronAPI.chat.getVoiceTranscript(
session.username, session.username,
String(message.localId), String(message.localId),
message.createTime message.createTime
) )
if (result.success) { if (result.success) {
@@ -8300,6 +8309,21 @@ function MessageBubble({
voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText) voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText)
setVoiceTranscript(transcriptText) setVoiceTranscript(transcriptText)
} else { } else {
if (result.error === 'SEGFAULT_ERROR') {
console.warn('[ChatPage] 捕获到语音引擎底层段错误');
setSystemAlert({
title: '引擎崩溃提示',
message: (
<>
(Segmentation Fault)<br /><br />
使 Linux <code>sherpa-onnx</code> ( glibc )
</>
)
});
}
setVoiceTranscriptError(true) setVoiceTranscriptError(true)
voiceTranscriptRequestedRef.current = false voiceTranscriptRequestedRef.current = false
} }
@@ -8528,6 +8552,18 @@ function MessageBubble({
myWxid 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(() => { const locationMessageMeta = useMemo(() => {
if (message.localType !== 48) return null if (message.localType !== 48) return null
const raw = message.rawContent || '' const raw = message.rawContent || ''
@@ -8563,6 +8599,31 @@ function MessageBubble({
// 是否有引用消息 // 是否有引用消息
const hasQuote = quotedContent.length > 0 const hasQuote = quotedContent.length > 0
const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName 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 () => { const handlePlayVideo = useCallback(async () => {
if (!videoInfo?.videoUrl) return if (!videoInfo?.videoUrl) return
@@ -9002,13 +9063,10 @@ function MessageBubble({
} }
return ( return (
<div className="bubble-content"> renderBubbleWithQuote(
<div className="quoted-message"> renderQuotedMessageBlock(renderReferContent()),
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderReferContent()}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div> <div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div> )
) )
} }
@@ -9101,13 +9159,10 @@ function MessageBubble({
const replyText = message.linkTitle || q('title') || cleanedParsedContent || '' const replyText = message.linkTitle || q('title') || cleanedParsedContent || ''
const referContent = message.quotedContent || q('refermsg > content') || '' const referContent = message.quotedContent || q('refermsg > content') || ''
return ( return (
<div className="bubble-content"> renderBubbleWithQuote(
<div className="quoted-message"> renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))),
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div> <div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div> )
) )
} }
@@ -9317,13 +9372,10 @@ function MessageBubble({
} }
return ( return (
<div className="bubble-content"> renderBubbleWithQuote(
<div className="quoted-message"> renderQuotedMessageBlock(renderReferContent2()),
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderReferContent2()}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div> <div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div> )
) )
} }
@@ -9602,14 +9654,9 @@ function MessageBubble({
// 带引用的消息 // 带引用的消息
if (hasQuote) { if (hasQuote) {
return ( return renderBubbleWithQuote(
<div className="bubble-content"> renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))),
<div className="quoted-message"> <div className="message-text">{renderTextWithEmoji(cleanedParsedContent)}</div>
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(quotedContent))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanedParsedContent)}</div>
</div>
) )
} }
@@ -9699,6 +9746,31 @@ function MessageBubble({
{isSelected && <Check size={14} strokeWidth={3} />} {isSelected && <Check size={14} strokeWidth={3} />}
</div> </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> </div>
</> </>
) )

View File

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

View File

@@ -49,6 +49,7 @@ import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm' import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm'
import { Avatar } from '../components/Avatar'
import type { SnsPost } from '../types/sns' import type { SnsPost } from '../types/sns'
import { import {
cloneExportDateRange, cloneExportDateRange,
@@ -92,6 +93,7 @@ interface ExportOptions {
txtColumns: string[] txtColumns: string[]
displayNamePreference: DisplayNamePreference displayNamePreference: DisplayNamePreference
exportConcurrency: number exportConcurrency: number
imageDeepSearchOnMiss: boolean
} }
interface SessionRow extends AppChatSession { interface SessionRow extends AppChatSession {
@@ -537,6 +539,14 @@ const getAvatarLetter = (name: string): string => {
return [...name][0] || '?' 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 toComparableNameSet = (values: Array<string | undefined | null>): Set<string> => {
const set = new Set<string>() const set = new Set<string>()
for (const value of values) { for (const value of values) {
@@ -558,7 +568,7 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(
const CONTACT_ENRICH_TIMEOUT_MS = 7000 const CONTACT_ENRICH_TIMEOUT_MS = 7000
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 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_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
const EXPORT_REENTER_CONTACTS_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 const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
@@ -1026,7 +1036,7 @@ const toSessionRowsWithContacts = (
kind: toKindByContact(contact), kind: toKindByContact(contact),
wechatId: contact.username, wechatId: contact.username,
displayName: contact.displayName || session?.displayName || contact.username, displayName: contact.displayName || session?.displayName || contact.username,
avatarUrl: contact.avatarUrl || session?.avatarUrl, avatarUrl: session?.avatarUrl || contact.avatarUrl,
hasSession: Boolean(session) hasSession: Boolean(session)
} as SessionRow } as SessionRow
}) })
@@ -1046,7 +1056,7 @@ const toSessionRowsWithContacts = (
kind: toKindByContactType(session, contact), kind: toKindByContactType(session, contact),
wechatId: contact?.username || session.username, wechatId: contact?.username || session.username,
displayName: contact?.displayName || session.displayName || session.username, displayName: contact?.displayName || session.displayName || session.username,
avatarUrl: contact?.avatarUrl || session.avatarUrl, avatarUrl: session.avatarUrl || contact?.avatarUrl,
hasSession: true hasSession: true
} as SessionRow } as SessionRow
}) })
@@ -1593,6 +1603,7 @@ function ExportPage() {
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'json', format: 'json',
@@ -1611,7 +1622,8 @@ function ExportPage() {
excelCompactColumns: true, excelCompactColumns: true,
txtColumns: defaultTxtColumns, txtColumns: defaultTxtColumns,
displayNamePreference: 'remark', displayNamePreference: 'remark',
exportConcurrency: 2 exportConcurrency: 2,
imageDeepSearchOnMiss: true
}) })
const [exportDialog, setExportDialog] = useState<ExportDialogState>({ const [exportDialog, setExportDialog] = useState<ExportDialogState>({
@@ -1710,6 +1722,7 @@ function ExportPage() {
startIndex: 0, startIndex: 0,
endIndex: -1 endIndex: -1
}) })
const avatarHydrationRequestedRef = useRef<Set<string>>(new Set())
const sessionMutualFriendsMetricsRef = useRef<Record<string, SessionMutualFriendsMetric>>({}) const sessionMutualFriendsMetricsRef = useRef<Record<string, SessionMutualFriendsMetric>>({})
const sessionMutualFriendsDirectMetricsRef = useRef<Record<string, SessionMutualFriendsMetric>>({}) const sessionMutualFriendsDirectMetricsRef = useRef<Record<string, SessionMutualFriendsMetric>>({})
const sessionMutualFriendsQueueRef = useRef<string[]>([]) const sessionMutualFriendsQueueRef = useRef<string[]>([])
@@ -1915,7 +1928,7 @@ function ExportPage() {
setIsContactsListLoading(true) setIsContactsListLoading(true)
try { try {
const contactsResult = await window.electronAPI.chat.getContacts() const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
if (contactsLoadVersionRef.current !== loadVersion) return if (contactsLoadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) { if (contactsResult.success && contactsResult.contacts) {
@@ -1954,6 +1967,7 @@ function ExportPage() {
displayName: contact.displayName, displayName: contact.displayName,
remark: contact.remark, remark: contact.remark,
nickname: contact.nickname, nickname: contact.nickname,
alias: contact.alias,
type: contact.type type: contact.type
})) }))
).catch((error) => { ).catch((error) => {
@@ -1995,6 +2009,94 @@ function ExportPage() {
} }
}, [ensureExportCacheScope, syncContactTypeCounts]) }, [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(() => { useEffect(() => {
if (!isExportRoute) return if (!isExportRoute) return
let cancelled = false let cancelled = false
@@ -2138,7 +2240,7 @@ function ExportPage() {
setIsBaseConfigLoading(true) setIsBaseConfigLoading(true)
let isReady = true let isReady = true
try { 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.getExportPath(),
configService.getExportDefaultFormat(), configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(), configService.getExportDefaultAvatars(),
@@ -2147,6 +2249,7 @@ function ExportPage() {
configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(), configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency(), configService.getExportDefaultConcurrency(),
configService.getExportDefaultImageDeepSearchOnMiss(),
configService.getExportLastSessionRunMap(), configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(), configService.getExportLastContentRunMap(),
configService.getExportSessionRecordMap(), configService.getExportSessionRecordMap(),
@@ -2183,6 +2286,7 @@ function ExportPage() {
setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2) setExportDefaultConcurrency(savedConcurrency ?? 2)
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
setExportDefaultDateRangeSelection(resolvedDefaultDateRange) setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
setTimeRangeSelection(resolvedDefaultDateRange) setTimeRangeSelection(resolvedDefaultDateRange)
@@ -2215,7 +2319,8 @@ function ExportPage() {
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
txtColumns, txtColumns,
exportConcurrency: savedConcurrency ?? prev.exportConcurrency exportConcurrency: savedConcurrency ?? prev.exportConcurrency,
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
})) }))
} catch (error) { } catch (error) {
isReady = false isReady = false
@@ -3677,7 +3782,7 @@ function ExportPage() {
if (isStale()) return if (isStale()) return
if (detailStatsPriorityRef.current) 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 if (isStale()) return
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
@@ -3818,10 +3923,12 @@ function ExportPage() {
displayName: contact.displayName || contact.username, displayName: contact.displayName || contact.username,
remark: contact.remark, remark: contact.remark,
nickname: contact.nickname, nickname: contact.nickname,
alias: contact.alias,
type: contact.type type: contact.type
})) }))
const persistAt = Date.now() const persistAt = Date.now()
setContactsList(contactsForPersist)
setSessions(nextSessions) setSessions(nextSessions)
sessionsHydratedAtRef.current = persistAt sessionsHydratedAtRef.current = persistAt
if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) { if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) {
@@ -3989,7 +4096,8 @@ function ExportPage() {
exportEmojis: exportDefaultMedia.emojis, exportEmojis: exportDefaultMedia.emojis,
exportVoiceAsText: exportDefaultVoiceAsText, exportVoiceAsText: exportDefaultVoiceAsText,
excelCompactColumns: exportDefaultExcelCompactColumns, excelCompactColumns: exportDefaultExcelCompactColumns,
exportConcurrency: exportDefaultConcurrency exportConcurrency: exportDefaultConcurrency,
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
} }
if (payload.scope === 'sns') { if (payload.scope === 'sns') {
@@ -4022,7 +4130,8 @@ function ExportPage() {
exportDefaultAvatars, exportDefaultAvatars,
exportDefaultMedia, exportDefaultMedia,
exportDefaultVoiceAsText, exportDefaultVoiceAsText,
exportDefaultConcurrency exportDefaultConcurrency,
exportDefaultImageDeepSearchOnMiss
]) ])
const closeExportDialog = useCallback(() => { const closeExportDialog = useCallback(() => {
@@ -4241,6 +4350,7 @@ function ExportPage() {
txtColumns: options.txtColumns, txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference, displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency, exportConcurrency: options.exportConcurrency,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
sessionLayout, sessionLayout,
sessionNameWithTypePrefix, sessionNameWithTypePrefix,
dateRange: options.useAllTime dateRange: options.useAllTime
@@ -4833,6 +4943,8 @@ function ExportPage() {
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
await configService.setExportDefaultTxtColumns(options.txtColumns) await configService.setExportDefaultTxtColumns(options.txtColumns)
await configService.setExportDefaultConcurrency(options.exportConcurrency) await configService.setExportDefaultConcurrency(options.exportConcurrency)
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
} }
const openSingleExport = useCallback((session: SessionRow) => { const openSingleExport = useCallback((session: SessionRow) => {
@@ -5369,6 +5481,11 @@ function ExportPage() {
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex } sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex } sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex }
void hydrateVisibleContactAvatars(
filteredContacts
.slice(startIndex, endIndex + 1)
.map((contact) => contact.username)
)
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
if (visibleTargets.length === 0) return if (visibleTargets.length === 0) return
enqueueSessionMediaMetricRequests(visibleTargets, { front: true }) enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
@@ -5384,10 +5501,23 @@ function ExportPage() {
enqueueSessionMediaMetricRequests, enqueueSessionMediaMetricRequests,
enqueueSessionMutualFriendsRequests, enqueueSessionMutualFriendsRequests,
filteredContacts, filteredContacts,
hydrateVisibleContactAvatars,
scheduleSessionMediaMetricWorker, scheduleSessionMediaMetricWorker,
scheduleSessionMutualFriendsWorker 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(() => { useEffect(() => {
if (activeTaskCount > 0) return if (activeTaskCount > 0) return
if (filteredContacts.length === 0) return if (filteredContacts.length === 0) return
@@ -5582,6 +5712,45 @@ function ExportPage() {
return map return map
}, [contactsList]) }, [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 currentSessionExportRecords = useMemo(() => {
const sessionId = String(sessionDetail?.wxid || '').trim() const sessionId = String(sessionDetail?.wxid || '').trim()
if (!sessionId) return [] as configService.ExportSessionRecordEntry[] if (!sessionId) return [] as configService.ExportSessionRecordEntry[]
@@ -5700,7 +5869,7 @@ function ExportPage() {
displayName: mappedSession?.displayName || mappedContact?.displayName || prev?.displayName || normalizedSessionId, displayName: mappedSession?.displayName || mappedContact?.displayName || prev?.displayName || normalizedSessionId,
remark: sameSession ? prev?.remark : mappedContact?.remark, remark: sameSession ? prev?.remark : mappedContact?.remark,
nickName: sameSession ? prev?.nickName : mappedContact?.nickname, 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), avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined),
messageCount: initialMessageCount ?? (sameSession ? prev.messageCount : Number.NaN), messageCount: initialMessageCount ?? (sameSession ? prev.messageCount : Number.NaN),
voiceMessages: metricVoice ?? (sameSession ? prev?.voiceMessages : undefined), voiceMessages: metricVoice ?? (sameSession ? prev?.voiceMessages : undefined),
@@ -5987,7 +6156,11 @@ function ExportPage() {
loadSnsUserPostCounts({ force: true }) loadSnsUserPostCounts({ force: true })
]) ])
if (String(sessionDetail?.wxid || '').trim()) { const currentDetailSessionId = showSessionDetailPanel
? String(sessionDetail?.wxid || '').trim()
: ''
if (currentDetailSessionId) {
await loadSessionDetail(currentDetailSessionId)
void loadSessionRelationStats({ forceRefresh: true }) void loadSessionRelationStats({ forceRefresh: true })
} }
}, [ }, [
@@ -5998,11 +6171,13 @@ function ExportPage() {
filteredContacts, filteredContacts,
isSessionCountStageReady, isSessionCountStageReady,
loadContactsList, loadContactsList,
loadSessionDetail,
loadSessionRelationStats, loadSessionRelationStats,
loadSnsStats, loadSnsStats,
loadSnsUserPostCounts, loadSnsUserPostCounts,
resetSessionMutualFriendsLoader, resetSessionMutualFriendsLoader,
scheduleSessionMutualFriendsWorker, scheduleSessionMutualFriendsWorker,
showSessionDetailPanel,
sessionDetail?.wxid sessionDetail?.wxid
]) ])
@@ -6270,6 +6445,10 @@ function ExportPage() {
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog const shouldShowMediaSection = !isContentScopeDialog
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
(isSessionScopeDialog && options.exportImages) ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像' const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。' const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
const activeDialogFormatLabel = exportDialog.scope === 'sns' const activeDialogFormatLabel = exportDialog.scope === 'sns'
@@ -6567,11 +6746,12 @@ function ExportPage() {
</button> </button>
</div> </div>
<div className="contact-avatar"> <div className="contact-avatar">
{contact.avatarUrl ? ( <Avatar
<img src={contact.avatarUrl} alt="" loading="lazy" /> src={normalizeExportAvatarUrl(contact.avatarUrl)}
) : ( name={contact.displayName}
<span>{getAvatarLetter(contact.displayName)}</span> size="100%"
)} shape="rounded"
/>
</div> </div>
<div className="contact-info"> <div className="contact-info">
<div className="contact-name">{contact.displayName}</div> <div className="contact-name">{contact.displayName}</div>
@@ -7454,11 +7634,12 @@ function ExportPage() {
<div className="session-mutual-friends-header"> <div className="session-mutual-friends-header">
<div className="session-mutual-friends-header-main"> <div className="session-mutual-friends-header-main">
<div className="session-mutual-friends-avatar"> <div className="session-mutual-friends-avatar">
{sessionMutualFriendsDialogTarget.avatarUrl ? ( <Avatar
<img src={sessionMutualFriendsDialogTarget.avatarUrl} alt="" /> src={normalizeExportAvatarUrl(sessionMutualFriendsDialogTarget.avatarUrl)}
) : ( name={sessionMutualFriendsDialogTarget.displayName}
<span>{getAvatarLetter(sessionMutualFriendsDialogTarget.displayName)}</span> size="100%"
)} shape="rounded"
/>
</div> </div>
<div className="session-mutual-friends-meta"> <div className="session-mutual-friends-meta">
<h4>{sessionMutualFriendsDialogTarget.displayName} </h4> <h4>{sessionMutualFriendsDialogTarget.displayName} </h4>
@@ -7539,11 +7720,12 @@ function ExportPage() {
<div className="detail-header"> <div className="detail-header">
<div className="detail-header-main"> <div className="detail-header-main">
<div className="detail-header-avatar"> <div className="detail-header-avatar">
{sessionDetail?.avatarUrl ? ( <Avatar
<img src={sessionDetail.avatarUrl} alt="" /> src={normalizeExportAvatarUrl(sessionDetail?.avatarUrl)}
) : ( name={sessionDetail?.displayName || sessionDetail?.wxid || ''}
<span>{getAvatarLetter(sessionDetail?.displayName || sessionDetail?.wxid || '')}</span> size="100%"
)} shape="rounded"
/>
</div> </div>
<div className="detail-header-meta"> <div className="detail-header-meta">
<h4>{sessionDetail?.displayName || '会话详情'}</h4> <h4>{sessionDetail?.displayName || '会话详情'}</h4>
@@ -7986,6 +8168,26 @@ function ExportPage() {
</div> </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 && ( {isSessionScopeDialog && (
<div className="dialog-section"> <div className="dialog-section">
<div className="dialog-switch-row"> <div className="dialog-switch-row">

View File

@@ -834,11 +834,13 @@
} }
.member-export-panel, .member-export-panel,
.member-messages-panel { .member-messages-panel,
.member-analytics-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
min-height: 0; min-height: 0;
flex: 1;
.member-export-empty { .member-export-empty {
padding: 20px; padding: 20px;
@@ -1521,29 +1523,73 @@
} }
} }
.stats-cards { .stats-overview {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 12px; gap: 16px;
margin-bottom: 20px; margin-bottom: 24px;
padding-top: 10px;
}
.stat-card { .stat-card {
background: transparent; 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; border-radius: 12px;
padding: 16px; color: var(--primary);
text-align: center; }
.value { .stat-info {
display: block; display: flex;
flex-direction: column;
gap: 4px;
.stat-value {
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
color: var(--primary); color: var(--text-primary);
margin-bottom: 4px;
} }
.label { .stat-label {
font-size: 13px; 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 { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useLocation } from 'react-router-dom' 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 { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker' import DateRangePicker from '../components/DateRangePicker'
@@ -37,7 +37,7 @@ interface GroupMessageRank {
messageCount: number 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' type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
interface MemberMessageExportOptions { interface MemberMessageExportOptions {
@@ -167,6 +167,8 @@ function GroupAnalyticsPage() {
const [isExportingMembers, setIsExportingMembers] = useState(false) const [isExportingMembers, setIsExportingMembers] = useState(false)
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
const [memberMessages, setMemberMessages] = useState<Message[]>([]) 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 [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false)
const [memberMessagesCursor, setMemberMessagesCursor] = useState(0) const [memberMessagesCursor, setMemberMessagesCursor] = useState(0)
const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false) const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false)
@@ -524,6 +526,7 @@ function GroupAnalyticsPage() {
break break
} }
case 'memberMessages': { case 'memberMessages': {
resetMemberMessageState(false)
updateBackgroundTask(taskId, { updateBackgroundTask(taskId, {
detail: '正在读取成员列表与消息', detail: '正在读取成员列表与消息',
progressText: '成员消息' progressText: '成员消息'
@@ -566,7 +569,57 @@ function GroupAnalyticsPage() {
}) })
break 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': { case 'ranking': {
setRankings([])
updateBackgroundTask(taskId, { updateBackgroundTask(taskId, {
detail: '正在计算群消息排行', detail: '正在计算群消息排行',
progressText: '消息排行' progressText: '消息排行'
@@ -584,6 +637,7 @@ function GroupAnalyticsPage() {
break break
} }
case 'activeHours': { case 'activeHours': {
setActiveHours({})
updateBackgroundTask(taskId, { updateBackgroundTask(taskId, {
detail: '正在计算群活跃时段', detail: '正在计算群活跃时段',
progressText: '活跃时段' progressText: '活跃时段'
@@ -601,6 +655,7 @@ function GroupAnalyticsPage() {
break break
} }
case 'mediaStats': { case 'mediaStats': {
setMediaStats(null)
updateBackgroundTask(taskId, { updateBackgroundTask(taskId, {
detail: '正在统计群消息类型', detail: '正在统计群消息类型',
progressText: '消息类型' progressText: '消息类型'
@@ -633,6 +688,12 @@ function GroupAnalyticsPage() {
return num.toLocaleString() 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) => { const sanitizeFileName = (name: string) => {
return name.replace(/[<>:"/\\|?*]+/g, '_').trim() return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
} }
@@ -764,6 +825,16 @@ function GroupAnalyticsPage() {
await loadFunctionData('memberMessages', selectedGroup, member.username) 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 = () => { const handleOpenMemberExportModal = () => {
setShowMessageMemberSelect(false) setShowMessageMemberSelect(false)
setShowFormatSelect(false) setShowFormatSelect(false)
@@ -982,6 +1053,14 @@ function GroupAnalyticsPage() {
<button <button
type="button" type="button"
className="member-modal-primary-btn" 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)} onClick={() => void handleViewMemberMessagesFromModal(selectedMember)}
> >
<MessageSquare size={16} /> <MessageSquare size={16} />
@@ -1080,6 +1159,11 @@ function GroupAnalyticsPage() {
<span></span> <span></span>
<small></small> <small></small>
</div> </div>
<div className="function-card" onClick={() => handleFunctionSelect('memberAnalytics')}>
<PieChart size={32} />
<span></span>
<small></small>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}> <div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
<BarChart3 size={32} /> <BarChart3 size={32} />
<span></span> <span></span>
@@ -1104,6 +1188,7 @@ function GroupAnalyticsPage() {
switch (selectedFunction) { switch (selectedFunction) {
case 'members': return '群成员查看' case 'members': return '群成员查看'
case 'memberMessages': return '成员消息筛选与导出' case 'memberMessages': return '成员消息筛选与导出'
case 'memberAnalytics': return '群成员详细分析'
case 'ranking': return '群聊发言排行' case 'ranking': return '群聊发言排行'
case 'activeHours': return '群聊活跃时段' case 'activeHours': return '群聊活跃时段'
case 'mediaStats': return '媒体内容统计' case 'mediaStats': return '媒体内容统计'
@@ -1282,6 +1367,187 @@ function GroupAnalyticsPage() {
)} )}
</div> </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' && ( {selectedFunction === 'ranking' && (
<div className="rankings-list"> <div className="rankings-list">
{rankings.map((item, index) => ( {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 { .theme-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); 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 isMac = navigator.userAgent.toLowerCase().includes('mac')
const isLinux = navigator.userAgent.toLowerCase().includes('linux') const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录' const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const dbPathPlaceholder = isMac 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 [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 [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
const [httpApiToken, setHttpApiToken] = useState('')
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024; const k = 1024;
@@ -110,6 +113,25 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 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 [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh']) const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
@@ -118,6 +140,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([]) const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask') const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
const [filterSearchKeyword, setFilterSearchKeyword] = useState('') const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
@@ -167,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
// HTTP API 设置 state // HTTP API 设置 state
const [httpApiEnabled, setHttpApiEnabled] = useState(false) const [httpApiEnabled, setHttpApiEnabled] = useState(false)
const [httpApiPort, setHttpApiPort] = useState(5031) const [httpApiPort, setHttpApiPort] = useState(5031)
const [httpApiHost, setHttpApiHost] = useState('127.0.0.1')
const [httpApiRunning, setHttpApiRunning] = useState(false) const [httpApiRunning, setHttpApiRunning] = useState(false)
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('') const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
const [isTogglingApi, setIsTogglingApi] = useState(false) const [isTogglingApi, setIsTogglingApi] = useState(false)
@@ -175,11 +199,26 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache 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 可用性 // 检查 Hello 可用性
useEffect(() => { useEffect(() => {
if (window.PublicKeyCredential) { setHelloAvailable(isWindows)
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
}
}, []) }, [])
// 检查 HTTP API 服务状态 // 检查 HTTP API 服务状态
@@ -299,10 +338,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedNotificationFilterList = await configService.getNotificationFilterList() const savedNotificationFilterList = await configService.getNotificationFilterList()
const savedMessagePushEnabled = await configService.getMessagePushEnabled() const savedMessagePushEnabled = await configService.getMessagePushEnabled()
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
const savedQuoteLayout = await configService.getQuoteLayout()
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
const savedAuthUseHello = await configService.getAuthUseHello() const savedAuthUseHello = await configService.getAuthUseHello()
const savedIsLockMode = await window.electronAPI.auth.isLockMode() 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) setAuthEnabled(savedAuthEnabled)
setAuthUseHello(savedAuthUseHello) setAuthUseHello(savedAuthUseHello)
setIsLockMode(savedIsLockMode) setIsLockMode(savedIsLockMode)
@@ -336,6 +386,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setNotificationFilterList(savedNotificationFilterList) setNotificationFilterList(savedNotificationFilterList)
setMessagePushEnabled(savedMessagePushEnabled) setMessagePushEnabled(savedMessagePushEnabled)
setWindowCloseBehavior(savedWindowCloseBehavior) setWindowCloseBehavior(savedWindowCloseBehavior)
setQuoteLayout(savedQuoteLayout)
const savedExcludeWords = await configService.getWordCloudExcludeWords() const savedExcludeWords = await configService.getWordCloudExcludeWords()
setWordCloudExcludeWords(savedExcludeWords) setWordCloudExcludeWords(savedExcludeWords)
@@ -344,6 +395,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAnalyticsConsent = await configService.getAnalyticsConsent() const savedAnalyticsConsent = await configService.getAnalyticsConsent()
setAnalyticsConsent(savedAnalyticsConsent ?? false) setAnalyticsConsent(savedAnalyticsConsent ?? false)
// 如果语言列表为空,保存默认值 // 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
const defaultLanguages = ['zh'] const defaultLanguages = ['zh']
@@ -1043,6 +1096,79 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
))} ))}
</div> </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="divider" />
<div className="form-group"> <div className="form-group">
@@ -1169,6 +1295,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"></span> <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">
<div <div
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`} className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
@@ -1652,34 +1783,49 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) )
const renderCacheTab = () => ( const renderCacheTab = () => (
<div className="tab-content"> <div className="tab-content">
<p className="section-desc"></p> <p className="section-desc"></p>
<div className="form-group"> <div className="form-group">
<label> <span className="optional">()</span></label> <label> <span className="optional">()</span></label>
<span className="form-hint">使</span> <span className="form-hint">使</span>
<input <input
type="text" type="text"
placeholder="留空使用默认目录" placeholder="留空使用默认目录"
value={cachePath} value={cachePath}
onChange={(e) => { onChange={(e) => {
const value = e.target.value const value = e.target.value
setCachePath(value) setCachePath(value)
scheduleConfigSave('cachePath', () => configService.setCachePath(value)) scheduleConfigSave('cachePath', () => configService.setCachePath(value))
}} }}
/> />
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button> <div style={{ marginTop: '8px', fontSize: '13px', color: 'var(--text-secondary)' }}>
<button
className="btn btn-secondary" <code style={{
onClick={async () => { background: 'var(--bg-secondary)',
setCachePath('') padding: '3px 6px',
await configService.setCachePath('') borderRadius: '4px',
}} userSelect: 'all',
> wordBreak: 'break-all',
<RotateCcw size={16} /> marginLeft: '4px'
</button> }}>
{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>
<div className="btn-row"> <div className="btn-row">
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}> <button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
@@ -1715,6 +1861,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
try { try {
await window.electronAPI.http.stop() await window.electronAPI.http.stop()
setHttpApiRunning(false) setHttpApiRunning(false)
await configService.setHttpApiEnabled(false)
showMessage('API 服务已停止', true) showMessage('API 服务已停止', true)
} catch (e: any) { } catch (e: any) {
showMessage(`操作失败: ${e}`, false) showMessage(`操作失败: ${e}`, false)
@@ -1728,10 +1875,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setShowApiWarning(false) setShowApiWarning(false)
setIsTogglingApi(true) setIsTogglingApi(true)
try { try {
const result = await window.electronAPI.http.start(httpApiPort) const result = await window.electronAPI.http.start(httpApiPort, httpApiHost)
if (result.success) { if (result.success) {
setHttpApiRunning(true) setHttpApiRunning(true)
if (result.port) setHttpApiPort(result.port) if (result.port) setHttpApiPort(result.port)
await configService.setHttpApiEnabled(true)
await configService.setHttpApiPort(result.port || httpApiPort)
showMessage(`API 服务已启动,端口 ${result.port}`, true) showMessage(`API 服务已启动,端口 ${result.port}`, true)
} else { } else {
showMessage(`启动失败: ${result.error}`, false) showMessage(`启动失败: ${result.error}`, false)
@@ -1744,7 +1895,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
} }
const handleCopyApiUrl = () => { const handleCopyApiUrl = () => {
const url = `http://127.0.0.1:${httpApiPort}` const url = `http://${httpApiHost}:${httpApiPort}`
navigator.clipboard.writeText(url) navigator.clipboard.writeText(url)
showMessage('已复制 API 地址', true) showMessage('已复制 API 地址', true)
} }
@@ -1776,21 +1927,75 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
</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"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint">API 1024-65535</span> <span className="form-hint">API 1024-65535</span>
<input <input
type="number" type="number"
className="field-input" className="field-input"
value={httpApiPort} value={httpApiPort}
onChange={(e) => setHttpApiPort(parseInt(e.target.value, 10) || 5031)} onChange={(e) => {
disabled={httpApiRunning} const port = parseInt(e.target.value, 10) || 5031
style={{ width: 120 }} setHttpApiPort(port)
min={1024} scheduleConfigSave('httpApiPort', () => configService.setHttpApiPort(port))
max={65535} }}
disabled={httpApiRunning}
style={{ width: 120 }}
min={1024}
max={65535}
/> />
</div> </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 && ( {httpApiRunning && (
<div className="form-group"> <div className="form-group">
<label>API </label> <label>API </label>
@@ -1799,7 +2004,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<input <input
type="text" type="text"
className="field-input" className="field-input"
value={`http://127.0.0.1:${httpApiPort}`} value={`http://${httpApiHost}:${httpApiPort}`}
readOnly readOnly
/> />
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制"> <button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制">
@@ -1846,18 +2051,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="form-hint"> SSE `HTTP API 服务`</span> <span className="form-hint"> SSE `HTTP API 服务`</span>
<div className="api-url-display"> <div className="api-url-display">
<input <input
type="text" type="text"
className="field-input" className="field-input"
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`} value={`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`}
readOnly readOnly
/> />
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`) navigator.clipboard.writeText(`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`)
showMessage('已复制推送地址', true) showMessage('已复制推送地址', true)
}} }}
title="复制" title="复制"
> >
<Copy size={16} /> <Copy size={16} />
</button> </button>
@@ -1871,7 +2076,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="api-item"> <div className="api-item">
<div className="api-endpoint"> <div className="api-endpoint">
<span className="method get">GET</span> <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> </div>
<p className="api-desc"> SSE `messageKey` </p> <p className="api-desc"> SSE `messageKey` </p>
<div className="api-params"> <div className="api-params">
@@ -1928,33 +2133,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage('请输入当前密码以开启 Hello', false) showMessage('请输入当前密码以开启 Hello', false)
return return
} }
if (!isWindows) {
showMessage('当前系统不支持 Windows Hello', false)
return
}
setIsSettingHello(true) setIsSettingHello(true)
try { try {
const challenge = new Uint8Array(32) const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
window.crypto.getRandomValues(challenge) if (!verifyResult.success) {
showMessage(verifyResult.error || 'Windows Hello 验证失败', false)
const credential = await navigator.credentials.create({ return
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 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) { } catch (e: any) {
if (e.name !== 'NotAllowedError') { showMessage(`Windows Hello 设置失败: ${e?.message || String(e)}`, false)
showMessage(`Windows Hello 设置失败: ${e.message}`, false)
}
} finally { } finally {
setIsSettingHello(false) setIsSettingHello(false)
} }

View File

@@ -759,6 +759,26 @@
margin-bottom: 12px; 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 { .post-media-container {
margin-bottom: 12px; margin-bottom: 12px;
} }

View File

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

View File

@@ -34,6 +34,7 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns', EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency', EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout', EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled', EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
@@ -63,8 +64,13 @@ export const CONFIG_KEYS = {
NOTIFICATION_POSITION: 'notificationPosition', NOTIFICATION_POSITION: 'notificationPosition',
NOTIFICATION_FILTER_MODE: 'notificationFilterMode', NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
NOTIFICATION_FILTER_LIST: 'notificationFilterList', NOTIFICATION_FILTER_LIST: 'notificationFilterList',
HTTP_API_TOKEN: 'httpApiToken',
HTTP_API_ENABLED: 'httpApiEnabled',
HTTP_API_PORT: 'httpApiPort',
HTTP_API_HOST: 'httpApiHost',
MESSAGE_PUSH_ENABLED: 'messagePushEnabled', MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
QUOTE_LAYOUT: 'quoteLayout',
// 词云 // 词云
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords', WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
@@ -89,6 +95,7 @@ export interface ExportDefaultMediaConfig {
} }
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit' export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
export type QuoteLayout = 'quote-top' | 'quote-bottom'
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = { const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
images: true, images: true,
@@ -114,6 +121,17 @@ export async function getDbPath(): Promise<string | null> {
return value as 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> { export async function setDbPath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.DB_PATH, path) 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) 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 type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> { export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
@@ -647,6 +677,10 @@ export interface ContactsListCacheContact {
displayName: string displayName: string
remark?: string remark?: string
nickname?: string nickname?: string
alias?: string
labels?: string[]
detailDescription?: string
region?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
@@ -1121,16 +1155,18 @@ export async function setSnsPageCache(
export async function getContactsLoadTimeoutMs(): Promise<number> { export async function getContactsLoadTimeoutMs(): Promise<number> {
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS) const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) { 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> { export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
const normalized = Number.isFinite(timeoutMs) const normalized = Number.isFinite(timeoutMs)
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs))) ? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
: 3000 : 10000
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized) await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
} }
@@ -1159,6 +1195,12 @@ export async function getContactsListCache(scopeKey: string): Promise<ContactsLi
displayName, displayName,
remark: typeof item.remark === 'string' ? item.remark : undefined, remark: typeof item.remark === 'string' ? item.remark : undefined,
nickname: typeof item.nickname === 'string' ? item.nickname : 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: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
? type ? type
: 'other' : 'other'
@@ -1192,6 +1234,12 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
displayName, displayName,
remark: contact?.remark ? String(contact.remark) : undefined, remark: contact?.remark ? String(contact.remark) : undefined,
nickname: contact?.nickname ? String(contact.nickname) : 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 type
}) })
} }
@@ -1396,6 +1444,16 @@ export async function setWindowCloseBehavior(behavior: WindowCloseBehavior): Pro
await config.set(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR, behavior) 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[]> { export async function getWordCloudExcludeWords(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS) const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)
@@ -1429,3 +1487,35 @@ export async function getAnalyticsDenyCount(): Promise<number> {
export async function setAnalyticsDenyCount(count: number): Promise<void> { export async function setAnalyticsDenyCount(count: number): Promise<void> {
await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count) 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 }> ignoreUpdate: (version: string) => Promise<{ success: boolean }>
onDownloadProgress: (callback: (progress: number) => void) => () => void onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
checkWayland: () => Promise<boolean>
} }
notification: { notification: {
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void> 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 }> 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 }> 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 }> resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
getContacts: () => Promise<{ getContacts: (options?: { lite?: boolean }) => Promise<{
success: boolean success: boolean
contacts?: ContactInfo[] contacts?: ContactInfo[]
error?: string error?: string
@@ -495,6 +496,28 @@ export interface ElectronAPI {
} }
error?: string 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: ( getGroupMemberMessages: (
chatroomId: string, chatroomId: string,
memberUsername: string, memberUsername: string,
@@ -790,6 +813,16 @@ export interface ElectronAPI {
}> }>
likes: Array<string> 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 }> }> 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 rawXml?: string
}> }>
error?: string error?: string
@@ -827,7 +860,7 @@ export interface ElectronAPI {
getLogs: () => Promise<string[]> getLogs: () => Promise<string[]>
} }
http: { 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 }> stop: () => Promise<{ success: boolean }>
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }> status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
} }
@@ -852,6 +885,7 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
} }
export interface ExportProgress { export interface ExportProgress {

View File

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

View File

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

BIN
temp_assets.json Normal file

Binary file not shown.

View File

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